Break the SDK out into its own repo
I prototyped this in https://github.com/negz/function-auto-python. Signed-off-by: Nic Cope <nicc@rk0n.org>
This commit is contained in:
parent
e930e1ddf8
commit
e247d8766e
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
name: Bug Report
|
||||
about: Help us diagnose and fix bugs in this Function
|
||||
labels: bug
|
||||
---
|
||||
<!--
|
||||
Thank you for helping to improve Crossplane!
|
||||
|
||||
Please be sure to search for open issues before raising a new one. We use issues
|
||||
for bug reports and feature requests. Please find us at https://slack.crossplane.io
|
||||
for questions, support, and discussion.
|
||||
-->
|
||||
|
||||
### What happened?
|
||||
<!--
|
||||
Please let us know what behaviour you expected and how this Function diverged
|
||||
from that behaviour.
|
||||
-->
|
||||
|
||||
|
||||
### How can we reproduce it?
|
||||
<!--
|
||||
Help us to reproduce your bug as succinctly and precisely as possible. Artifacts
|
||||
such as example manifests or a script that triggers the issue are highly
|
||||
appreciated!
|
||||
-->
|
||||
|
||||
### What environment did it happen in?
|
||||
Function version:
|
||||
|
||||
<!--
|
||||
Include at least the version or commit of Crossplane you were running. Consider
|
||||
also including your:
|
||||
|
||||
* Cloud provider or hardware configuration
|
||||
* Kubernetes version (use `kubectl version`)
|
||||
* Kubernetes distribution (e.g. Tectonic, GKE, OpenShift)
|
||||
* OS (e.g. from /etc/os-release)
|
||||
* Kernel (e.g. `uname -a`)
|
||||
-->
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
name: Feature Request
|
||||
about: Help us make this Function more useful
|
||||
labels: enhancement
|
||||
---
|
||||
<!--
|
||||
Thank you for helping to improve Crossplane!
|
||||
|
||||
Please be sure to search for open issues before raising a new one. We use issues
|
||||
for bug reports and feature requests. Please find us at https://slack.crossplane.io
|
||||
for questions, support, and discussion.
|
||||
-->
|
||||
|
||||
### What problem are you facing?
|
||||
<!--
|
||||
Please tell us a little about your use case - it's okay if it's hypothetical!
|
||||
Leading with this context helps frame the feature request so we can ensure we
|
||||
implement it sensibly.
|
||||
--->
|
||||
|
||||
### How could this Function help solve your problem?
|
||||
<!--
|
||||
Let us know how you think this Function could help with your use case.
|
||||
-->
|
|
@ -0,0 +1,30 @@
|
|||
<!--
|
||||
Thank you for helping to improve Crossplane!
|
||||
|
||||
Please read through https://git.io/fj2m9 if this is your first time opening a
|
||||
Crossplane pull request. Find us in https://slack.crossplane.io/messages/dev if
|
||||
you need any help contributing.
|
||||
-->
|
||||
|
||||
### Description of your changes
|
||||
|
||||
<!--
|
||||
Briefly describe what this pull request does, and how it is covered by tests.
|
||||
Be proactive - direct your reviewers' attention to anything that needs special
|
||||
consideration.
|
||||
|
||||
You MUST either [x] check or ~strikethrough~ every item in the checklist below.
|
||||
|
||||
We love pull requests that fix an open issue. If yours does, use the below line
|
||||
to indicate which issue it fixes, for example "Fixes #500".
|
||||
-->
|
||||
|
||||
Fixes #
|
||||
|
||||
I have:
|
||||
|
||||
- [ ] Read and followed Crossplane's [contribution process].
|
||||
- [ ] Added or updated unit tests for my change.
|
||||
|
||||
[contribution process]: https://git.io/fj2m9
|
||||
[docs]: https://docs.crossplane.io/contribute/contribute
|
|
@ -0,0 +1,80 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- release-*
|
||||
pull_request: {}
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: PyPI module version (e.g. v0.1.0)
|
||||
required: false
|
||||
|
||||
env:
|
||||
# Common versions
|
||||
PYTHON_VERSION: '3.11.5'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Setup Hatch
|
||||
run: pipx install hatch==1.7.0
|
||||
|
||||
- name: Lint
|
||||
run: |
|
||||
hatch run lint:check-format
|
||||
hatch run lint:check
|
||||
|
||||
unit-test:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Setup Hatch
|
||||
run: pipx install hatch==1.7.0
|
||||
|
||||
- name: Run Unit Tests
|
||||
run: hatch run test:unit
|
||||
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Setup Hatch
|
||||
run: pipx install hatch==1.7.0
|
||||
|
||||
- name: Run Unit Tests
|
||||
run: hatch build
|
||||
|
||||
- name: Upload Sdist and Wheel to GitHub
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: module
|
||||
path: "dist/*"
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
39
README.md
39
README.md
|
@ -1,2 +1,39 @@
|
|||
# function-sdk-python
|
||||
The Python SDK for composition functions
|
||||
[](https://github.com/crossplane/function-sdk-python/actions/workflows/ci.yml) 
|
||||
|
||||
The [Python][python] SDK for writing [composition functions][functions].
|
||||
|
||||
This SDK is currently a beta. We try to avoid breaking changes, but it will not
|
||||
have a stable API until it reaches v1.0.0. It follows the same [contributing
|
||||
guidelines] as Crossplane.
|
||||
|
||||
To learn how to use this SDK:
|
||||
|
||||
* [Learn about how composition functions work][functions]
|
||||
|
||||
## Contributing
|
||||
|
||||
This project follows the Crossplane [contributing guidelines], where applicable
|
||||
to Python. It is linted, tested, and built using [Hatch][hatch].
|
||||
|
||||
Some useful commands:
|
||||
|
||||
```shell
|
||||
# Generate gRPC stubs.
|
||||
hatch run generate:protoc
|
||||
|
||||
# Lint the code, using ruff.
|
||||
hatch run lint:check
|
||||
hatch run lint:check-format
|
||||
|
||||
# Run unit tests.
|
||||
hatch run test:unit
|
||||
|
||||
# Build an sdist and wheel
|
||||
hatch build
|
||||
```
|
||||
|
||||
[python]: https://python.org
|
||||
[functions]: https://docs.crossplane.io/latest/concepts/composition-functions
|
||||
[contributing guidelines]: https://github.com/crossplane/crossplane/tree/master/contributing
|
||||
[hatch]: https://github.com/pypa/hatch
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
"""Logging utilities for composition functions."""
|
||||
|
||||
|
||||
import enum
|
||||
import logging
|
||||
|
||||
import structlog
|
||||
|
||||
|
||||
class Level(enum.Enum):
|
||||
"""Supported log levels."""
|
||||
|
||||
DISABLED = 0
|
||||
DEBUG = 1
|
||||
INFO = 2
|
||||
|
||||
|
||||
def configure(level: Level = Level.INFO) -> None:
|
||||
"""Configure logging.
|
||||
|
||||
Args:
|
||||
level: What log level to enable.
|
||||
|
||||
Must be called before calling get_logger. When debug logging is enabled logs
|
||||
will be printed in a human readable fashion. When not enabled, logs will be
|
||||
printed as JSON lines.
|
||||
"""
|
||||
|
||||
def dropper(logger, method_name, event_dict): # noqa: ARG001 # We need this signature.
|
||||
raise structlog.DropEvent
|
||||
|
||||
if level == Level.DISABLED:
|
||||
structlog.configure(processors=[dropper])
|
||||
return
|
||||
|
||||
processors = [
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.CallsiteParameterAdder(
|
||||
{
|
||||
structlog.processors.CallsiteParameter.FILENAME,
|
||||
structlog.processors.CallsiteParameter.LINENO,
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
if level == Level.DEBUG:
|
||||
structlog.configure(
|
||||
processors=[
|
||||
*processors,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.dev.ConsoleRenderer(),
|
||||
]
|
||||
)
|
||||
return
|
||||
|
||||
# Attempt to match function-sdk-go's production logger.
|
||||
structlog.configure(
|
||||
processors=[
|
||||
*processors,
|
||||
structlog.processors.dict_tracebacks,
|
||||
structlog.processors.TimeStamper(key="ts"),
|
||||
structlog.processors.EventRenamer(to="msg"),
|
||||
structlog.processors.JSONRenderer(),
|
||||
],
|
||||
wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
|
||||
)
|
||||
|
||||
|
||||
def get_logger() -> structlog.stdlib.BoundLogger:
|
||||
"""Get a logger.
|
||||
|
||||
You must call configure before calling get_logger.
|
||||
"""
|
||||
return structlog.stdlib.get_logger()
|
|
@ -0,0 +1,202 @@
|
|||
/*
|
||||
Copyright 2022 The Crossplane Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
import "google/protobuf/struct.proto";
|
||||
import "google/protobuf/duration.proto";
|
||||
|
||||
// Note that the authoritative Composition Functions protobuf definition lives
|
||||
// at the below URL. Each SDK maintains and manually syncs its own copy.
|
||||
// https://github.com/crossplane/crossplane/tree/master/apis/apiextensions/fn/proto
|
||||
package apiextensions.fn.proto.v1beta1;
|
||||
|
||||
// A FunctionRunnerService is a Composition Function.
|
||||
service FunctionRunnerService {
|
||||
// RunFunction runs the Composition Function.
|
||||
rpc RunFunction(RunFunctionRequest) returns (RunFunctionResponse) {}
|
||||
}
|
||||
|
||||
// A RunFunctionRequest requests that the Composition Function be run.
|
||||
message RunFunctionRequest {
|
||||
// Metadata pertaining to this request.
|
||||
RequestMeta meta = 1;
|
||||
|
||||
// The observed state prior to invocation of a Function pipeline. State passed
|
||||
// to each Function is fresh as of the time the pipeline was invoked, not as
|
||||
// of the time each Function was invoked.
|
||||
State observed = 2;
|
||||
|
||||
// Desired state according to a Function pipeline. The state passed to a
|
||||
// particular Function may have been accumulated by previous Functions in the
|
||||
// pipeline.
|
||||
//
|
||||
// Note that the desired state must be a partial object with only the fields
|
||||
// that this function (and its predecessors in the pipeline) wants to have
|
||||
// set in the object. Copying a non-partial observed state to desired is most
|
||||
// likely not what you want to do. Leaving out fields that had been returned
|
||||
// as desired before will result in them being deleted from the objects in the
|
||||
// cluster.
|
||||
State desired = 3;
|
||||
|
||||
// Optional input specific to this Function invocation. A JSON representation
|
||||
// of the 'input' block of the relevant entry in a Composition's pipeline.
|
||||
optional google.protobuf.Struct input = 4;
|
||||
|
||||
// Optional context. Crossplane may pass arbitary contextual information to a
|
||||
// Function. A Function may also return context in its RunFunctionResponse,
|
||||
// and that context will be passed to subsequent Functions. Crossplane
|
||||
// discards all context returned by the last Function in the pipeline.
|
||||
optional google.protobuf.Struct context = 5;
|
||||
}
|
||||
|
||||
// A RunFunctionResponse contains the result of a Composition Function run.
|
||||
message RunFunctionResponse {
|
||||
// Metadata pertaining to this response.
|
||||
ResponseMeta meta = 1;
|
||||
|
||||
// Desired state according to a Function pipeline. Functions may add desired
|
||||
// state, and may mutate or delete any part of the desired state they are
|
||||
// concerned with. A Function must pass through any part of the desired state
|
||||
// that it is not concerned with.
|
||||
//
|
||||
//
|
||||
// Note that the desired state must be a partial object with only the fields
|
||||
// that this function (and its predecessors in the pipeline) wants to have
|
||||
// set in the object. Copying a non-partial observed state to desired is most
|
||||
// likely not what you want to do. Leaving out fields that had been returned
|
||||
// as desired before will result in them being deleted from the objects in the
|
||||
// cluster.
|
||||
State desired = 2;
|
||||
|
||||
// Results of the Function run. Results are used for observability purposes.
|
||||
repeated Result results = 3;
|
||||
|
||||
// Optional context to be passed to the next Function in the pipeline as part
|
||||
// of the RunFunctionRequest. Dropped on the last function in the pipeline.
|
||||
optional google.protobuf.Struct context = 4;
|
||||
}
|
||||
|
||||
// RequestMeta contains metadata pertaining to a RunFunctionRequest.
|
||||
message RequestMeta {
|
||||
// An opaque string identifying the content of the request. Two identical
|
||||
// requests should have the same tag.
|
||||
string tag = 1;
|
||||
}
|
||||
|
||||
// ResponseMeta contains metadata pertaining to a RunFunctionResponse.
|
||||
message ResponseMeta {
|
||||
// An opaque string identifying the content of the request. Must match the
|
||||
// meta.tag of the corresponding RunFunctionRequest.
|
||||
string tag = 1;
|
||||
|
||||
// Time-to-live of this response. Deterministic Functions with no side-effects
|
||||
// (e.g. simple templating Functions) may specify a TTL. Crossplane may choose
|
||||
// to cache responses until the TTL expires.
|
||||
optional google.protobuf.Duration ttl = 2;
|
||||
}
|
||||
|
||||
// State of the composite resource (XR) and any composed resources.
|
||||
message State {
|
||||
// The state of the composite resource (XR).
|
||||
Resource composite = 1;
|
||||
|
||||
// The state of any composed resources.
|
||||
map<string, Resource> resources = 2;
|
||||
}
|
||||
|
||||
// A Resource represents the state of a composite or composed resource.
|
||||
message Resource {
|
||||
// The JSON representation of the resource.
|
||||
//
|
||||
// * Crossplane will set this field in a RunFunctionRequest to the entire
|
||||
// observed state of a resource - including its metadata, spec, and status.
|
||||
//
|
||||
// * A Function should set this field in a RunFunctionRequest to communicate
|
||||
// the desired state of a composite or composed resource.
|
||||
//
|
||||
// * A Function may only specify the desired status of a composite resource -
|
||||
// not its metadata or spec. A Function should not return desired metadata
|
||||
// or spec for a composite resource. This will be ignored.
|
||||
//
|
||||
// * A Function may not specify the desired status of a composed resource -
|
||||
// only its metadata and spec. A Function should not return desired status
|
||||
// for a composed resource. This will be ignored.
|
||||
google.protobuf.Struct resource = 1;
|
||||
|
||||
// The resource's connection details.
|
||||
//
|
||||
// * Crossplane will set this field in a RunFunctionRequest to communicate the
|
||||
// the observed connection details of a composite or composed resource.
|
||||
//
|
||||
// * A Function should set this field in a RunFunctionResponse to indicate the
|
||||
// desired connection details of the composite resource.
|
||||
//
|
||||
// * A Function should not set this field in a RunFunctionResponse to indicate
|
||||
// the desired connection details of a composed resource. This will be
|
||||
// ignored.
|
||||
map<string, bytes> connection_details = 2;
|
||||
|
||||
// Ready indicates whether the resource should be considered ready.
|
||||
//
|
||||
// * Crossplane will never set this field in a RunFunctionRequest.
|
||||
//
|
||||
// * A Function should set this field to READY_TRUE in a RunFunctionResponse
|
||||
// to indicate that a desired composed resource is ready.
|
||||
//
|
||||
// * A Function should not set this field in a RunFunctionResponse to indicate
|
||||
// that the desired composite resource is ready. This will be ignored.
|
||||
Ready ready = 3;
|
||||
}
|
||||
|
||||
// Ready indicates whether a composed resource should be considered ready.
|
||||
enum Ready {
|
||||
READY_UNSPECIFIED = 0;
|
||||
|
||||
// True means the composed resource has been observed to be ready.
|
||||
READY_TRUE = 1;
|
||||
|
||||
// False means the composed resource has not been observed to be ready.
|
||||
READY_FALSE = 2;
|
||||
}
|
||||
|
||||
// A Result of running a Function.
|
||||
message Result {
|
||||
// Severity of this result.
|
||||
Severity severity = 1;
|
||||
|
||||
// Human-readable details about the result.
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
// Severity of Function results.
|
||||
enum Severity {
|
||||
SEVERITY_UNSPECIFIED = 0;
|
||||
|
||||
// Fatal results are fatal; subsequent Composition Functions may run, but
|
||||
// the Composition Function pipeline run will be considered a failure and
|
||||
// the first fatal result will be returned as an error.
|
||||
SEVERITY_FATAL = 1;
|
||||
|
||||
// Warning results are non-fatal; the entire Composition will run to
|
||||
// completion but warning events and debug logs associated with the
|
||||
// composite resource will be emitted.
|
||||
SEVERITY_WARNING = 2;
|
||||
|
||||
// Normal results are emitted as normal events and debug logs associated
|
||||
// with the composite resource.
|
||||
SEVERITY_NORMAL = 3;
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: crossplane/function/proto/v1beta1/run_function.proto
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2
|
||||
from google.protobuf import duration_pb2 as google_dot_protobuf_dot_duration__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n4crossplane/function/proto/v1beta1/run_function.proto\x12\x1e\x61piextensions.fn.proto.v1beta1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1egoogle/protobuf/duration.proto\"\xb2\x02\n\x12RunFunctionRequest\x12\x39\n\x04meta\x18\x01 \x01(\x0b\x32+.apiextensions.fn.proto.v1beta1.RequestMeta\x12\x37\n\x08observed\x18\x02 \x01(\x0b\x32%.apiextensions.fn.proto.v1beta1.State\x12\x36\n\x07\x64\x65sired\x18\x03 \x01(\x0b\x32%.apiextensions.fn.proto.v1beta1.State\x12+\n\x05input\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x12-\n\x07\x63ontext\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructH\x01\x88\x01\x01\x42\x08\n\x06_inputB\n\n\x08_context\"\xfd\x01\n\x13RunFunctionResponse\x12:\n\x04meta\x18\x01 \x01(\x0b\x32,.apiextensions.fn.proto.v1beta1.ResponseMeta\x12\x36\n\x07\x64\x65sired\x18\x02 \x01(\x0b\x32%.apiextensions.fn.proto.v1beta1.State\x12\x37\n\x07results\x18\x03 \x03(\x0b\x32&.apiextensions.fn.proto.v1beta1.Result\x12-\n\x07\x63ontext\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x88\x01\x01\x42\n\n\x08_context\"\x1a\n\x0bRequestMeta\x12\x0b\n\x03tag\x18\x01 \x01(\t\"P\n\x0cResponseMeta\x12\x0b\n\x03tag\x18\x01 \x01(\t\x12+\n\x03ttl\x18\x02 \x01(\x0b\x32\x19.google.protobuf.DurationH\x00\x88\x01\x01\x42\x06\n\x04_ttl\"\xe9\x01\n\x05State\x12;\n\tcomposite\x18\x01 \x01(\x0b\x32(.apiextensions.fn.proto.v1beta1.Resource\x12G\n\tresources\x18\x02 \x03(\x0b\x32\x34.apiextensions.fn.proto.v1beta1.State.ResourcesEntry\x1aZ\n\x0eResourcesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x37\n\x05value\x18\x02 \x01(\x0b\x32(.apiextensions.fn.proto.v1beta1.Resource:\x02\x38\x01\"\x82\x02\n\x08Resource\x12)\n\x08resource\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12[\n\x12\x63onnection_details\x18\x02 \x03(\x0b\x32?.apiextensions.fn.proto.v1beta1.Resource.ConnectionDetailsEntry\x12\x34\n\x05ready\x18\x03 \x01(\x0e\x32%.apiextensions.fn.proto.v1beta1.Ready\x1a\x38\n\x16\x43onnectionDetailsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x0c:\x02\x38\x01\"U\n\x06Result\x12:\n\x08severity\x18\x01 \x01(\x0e\x32(.apiextensions.fn.proto.v1beta1.Severity\x12\x0f\n\x07message\x18\x02 \x01(\t*?\n\x05Ready\x12\x15\n\x11READY_UNSPECIFIED\x10\x00\x12\x0e\n\nREADY_TRUE\x10\x01\x12\x0f\n\x0bREADY_FALSE\x10\x02*c\n\x08Severity\x12\x18\n\x14SEVERITY_UNSPECIFIED\x10\x00\x12\x12\n\x0eSEVERITY_FATAL\x10\x01\x12\x14\n\x10SEVERITY_WARNING\x10\x02\x12\x13\n\x0fSEVERITY_NORMAL\x10\x03\x32\x91\x01\n\x15\x46unctionRunnerService\x12x\n\x0bRunFunction\x12\x32.apiextensions.fn.proto.v1beta1.RunFunctionRequest\x1a\x33.apiextensions.fn.proto.v1beta1.RunFunctionResponse\"\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'crossplane.function.proto.v1beta1.run_function_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
_STATE_RESOURCESENTRY._options = None
|
||||
_STATE_RESOURCESENTRY._serialized_options = b'8\001'
|
||||
_RESOURCE_CONNECTIONDETAILSENTRY._options = None
|
||||
_RESOURCE_CONNECTIONDETAILSENTRY._serialized_options = b'8\001'
|
||||
_globals['_READY']._serialized_start=1409
|
||||
_globals['_READY']._serialized_end=1472
|
||||
_globals['_SEVERITY']._serialized_start=1474
|
||||
_globals['_SEVERITY']._serialized_end=1573
|
||||
_globals['_RUNFUNCTIONREQUEST']._serialized_start=151
|
||||
_globals['_RUNFUNCTIONREQUEST']._serialized_end=457
|
||||
_globals['_RUNFUNCTIONRESPONSE']._serialized_start=460
|
||||
_globals['_RUNFUNCTIONRESPONSE']._serialized_end=713
|
||||
_globals['_REQUESTMETA']._serialized_start=715
|
||||
_globals['_REQUESTMETA']._serialized_end=741
|
||||
_globals['_RESPONSEMETA']._serialized_start=743
|
||||
_globals['_RESPONSEMETA']._serialized_end=823
|
||||
_globals['_STATE']._serialized_start=826
|
||||
_globals['_STATE']._serialized_end=1059
|
||||
_globals['_STATE_RESOURCESENTRY']._serialized_start=969
|
||||
_globals['_STATE_RESOURCESENTRY']._serialized_end=1059
|
||||
_globals['_RESOURCE']._serialized_start=1062
|
||||
_globals['_RESOURCE']._serialized_end=1320
|
||||
_globals['_RESOURCE_CONNECTIONDETAILSENTRY']._serialized_start=1264
|
||||
_globals['_RESOURCE_CONNECTIONDETAILSENTRY']._serialized_end=1320
|
||||
_globals['_RESULT']._serialized_start=1322
|
||||
_globals['_RESULT']._serialized_end=1407
|
||||
_globals['_FUNCTIONRUNNERSERVICE']._serialized_start=1576
|
||||
_globals['_FUNCTIONRUNNERSERVICE']._serialized_end=1721
|
||||
# @@protoc_insertion_point(module_scope)
|
|
@ -0,0 +1,109 @@
|
|||
from google.protobuf import struct_pb2 as _struct_pb2
|
||||
from google.protobuf import duration_pb2 as _duration_pb2
|
||||
from google.protobuf.internal import containers as _containers
|
||||
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import message as _message
|
||||
from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
|
||||
|
||||
DESCRIPTOR: _descriptor.FileDescriptor
|
||||
|
||||
class Ready(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = []
|
||||
READY_UNSPECIFIED: _ClassVar[Ready]
|
||||
READY_TRUE: _ClassVar[Ready]
|
||||
READY_FALSE: _ClassVar[Ready]
|
||||
|
||||
class Severity(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = []
|
||||
SEVERITY_UNSPECIFIED: _ClassVar[Severity]
|
||||
SEVERITY_FATAL: _ClassVar[Severity]
|
||||
SEVERITY_WARNING: _ClassVar[Severity]
|
||||
SEVERITY_NORMAL: _ClassVar[Severity]
|
||||
READY_UNSPECIFIED: Ready
|
||||
READY_TRUE: Ready
|
||||
READY_FALSE: Ready
|
||||
SEVERITY_UNSPECIFIED: Severity
|
||||
SEVERITY_FATAL: Severity
|
||||
SEVERITY_WARNING: Severity
|
||||
SEVERITY_NORMAL: Severity
|
||||
|
||||
class RunFunctionRequest(_message.Message):
|
||||
__slots__ = ["meta", "observed", "desired", "input", "context"]
|
||||
META_FIELD_NUMBER: _ClassVar[int]
|
||||
OBSERVED_FIELD_NUMBER: _ClassVar[int]
|
||||
DESIRED_FIELD_NUMBER: _ClassVar[int]
|
||||
INPUT_FIELD_NUMBER: _ClassVar[int]
|
||||
CONTEXT_FIELD_NUMBER: _ClassVar[int]
|
||||
meta: RequestMeta
|
||||
observed: State
|
||||
desired: State
|
||||
input: _struct_pb2.Struct
|
||||
context: _struct_pb2.Struct
|
||||
def __init__(self, meta: _Optional[_Union[RequestMeta, _Mapping]] = ..., observed: _Optional[_Union[State, _Mapping]] = ..., desired: _Optional[_Union[State, _Mapping]] = ..., input: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class RunFunctionResponse(_message.Message):
|
||||
__slots__ = ["meta", "desired", "results", "context"]
|
||||
META_FIELD_NUMBER: _ClassVar[int]
|
||||
DESIRED_FIELD_NUMBER: _ClassVar[int]
|
||||
RESULTS_FIELD_NUMBER: _ClassVar[int]
|
||||
CONTEXT_FIELD_NUMBER: _ClassVar[int]
|
||||
meta: ResponseMeta
|
||||
desired: State
|
||||
results: _containers.RepeatedCompositeFieldContainer[Result]
|
||||
context: _struct_pb2.Struct
|
||||
def __init__(self, meta: _Optional[_Union[ResponseMeta, _Mapping]] = ..., desired: _Optional[_Union[State, _Mapping]] = ..., results: _Optional[_Iterable[_Union[Result, _Mapping]]] = ..., context: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class RequestMeta(_message.Message):
|
||||
__slots__ = ["tag"]
|
||||
TAG_FIELD_NUMBER: _ClassVar[int]
|
||||
tag: str
|
||||
def __init__(self, tag: _Optional[str] = ...) -> None: ...
|
||||
|
||||
class ResponseMeta(_message.Message):
|
||||
__slots__ = ["tag", "ttl"]
|
||||
TAG_FIELD_NUMBER: _ClassVar[int]
|
||||
TTL_FIELD_NUMBER: _ClassVar[int]
|
||||
tag: str
|
||||
ttl: _duration_pb2.Duration
|
||||
def __init__(self, tag: _Optional[str] = ..., ttl: _Optional[_Union[_duration_pb2.Duration, _Mapping]] = ...) -> None: ...
|
||||
|
||||
class State(_message.Message):
|
||||
__slots__ = ["composite", "resources"]
|
||||
class ResourcesEntry(_message.Message):
|
||||
__slots__ = ["key", "value"]
|
||||
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
VALUE_FIELD_NUMBER: _ClassVar[int]
|
||||
key: str
|
||||
value: Resource
|
||||
def __init__(self, key: _Optional[str] = ..., value: _Optional[_Union[Resource, _Mapping]] = ...) -> None: ...
|
||||
COMPOSITE_FIELD_NUMBER: _ClassVar[int]
|
||||
RESOURCES_FIELD_NUMBER: _ClassVar[int]
|
||||
composite: Resource
|
||||
resources: _containers.MessageMap[str, Resource]
|
||||
def __init__(self, composite: _Optional[_Union[Resource, _Mapping]] = ..., resources: _Optional[_Mapping[str, Resource]] = ...) -> None: ...
|
||||
|
||||
class Resource(_message.Message):
|
||||
__slots__ = ["resource", "connection_details", "ready"]
|
||||
class ConnectionDetailsEntry(_message.Message):
|
||||
__slots__ = ["key", "value"]
|
||||
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||
VALUE_FIELD_NUMBER: _ClassVar[int]
|
||||
key: str
|
||||
value: bytes
|
||||
def __init__(self, key: _Optional[str] = ..., value: _Optional[bytes] = ...) -> None: ...
|
||||
RESOURCE_FIELD_NUMBER: _ClassVar[int]
|
||||
CONNECTION_DETAILS_FIELD_NUMBER: _ClassVar[int]
|
||||
READY_FIELD_NUMBER: _ClassVar[int]
|
||||
resource: _struct_pb2.Struct
|
||||
connection_details: _containers.ScalarMap[str, bytes]
|
||||
ready: Ready
|
||||
def __init__(self, resource: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., connection_details: _Optional[_Mapping[str, bytes]] = ..., ready: _Optional[_Union[Ready, str]] = ...) -> None: ...
|
||||
|
||||
class Result(_message.Message):
|
||||
__slots__ = ["severity", "message"]
|
||||
SEVERITY_FIELD_NUMBER: _ClassVar[int]
|
||||
MESSAGE_FIELD_NUMBER: _ClassVar[int]
|
||||
severity: Severity
|
||||
message: str
|
||||
def __init__(self, severity: _Optional[_Union[Severity, str]] = ..., message: _Optional[str] = ...) -> None: ...
|
|
@ -0,0 +1,70 @@
|
|||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
|
||||
from crossplane.function.proto.v1beta1 import run_function_pb2 as crossplane_dot_function_dot_proto_dot_v1beta1_dot_run__function__pb2
|
||||
|
||||
|
||||
class FunctionRunnerServiceStub(object):
|
||||
"""A FunctionRunnerService is a Composition Function.
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.RunFunction = channel.unary_unary(
|
||||
'/apiextensions.fn.proto.v1beta1.FunctionRunnerService/RunFunction',
|
||||
request_serializer=crossplane_dot_function_dot_proto_dot_v1beta1_dot_run__function__pb2.RunFunctionRequest.SerializeToString,
|
||||
response_deserializer=crossplane_dot_function_dot_proto_dot_v1beta1_dot_run__function__pb2.RunFunctionResponse.FromString,
|
||||
)
|
||||
|
||||
|
||||
class FunctionRunnerServiceServicer(object):
|
||||
"""A FunctionRunnerService is a Composition Function.
|
||||
"""
|
||||
|
||||
def RunFunction(self, request, context):
|
||||
"""RunFunction runs the Composition Function.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_FunctionRunnerServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'RunFunction': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.RunFunction,
|
||||
request_deserializer=crossplane_dot_function_dot_proto_dot_v1beta1_dot_run__function__pb2.RunFunctionRequest.FromString,
|
||||
response_serializer=crossplane_dot_function_dot_proto_dot_v1beta1_dot_run__function__pb2.RunFunctionResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'apiextensions.fn.proto.v1beta1.FunctionRunnerService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class FunctionRunnerService(object):
|
||||
"""A FunctionRunnerService is a Composition Function.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def RunFunction(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/apiextensions.fn.proto.v1beta1.FunctionRunnerService/RunFunction',
|
||||
crossplane_dot_function_dot_proto_dot_v1beta1_dot_run__function__pb2.RunFunctionRequest.SerializeToString,
|
||||
crossplane_dot_function_dot_proto_dot_v1beta1_dot_run__function__pb2.RunFunctionResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
|
@ -0,0 +1,94 @@
|
|||
"""A composition function SDK."""
|
||||
|
||||
import dataclasses
|
||||
import datetime
|
||||
|
||||
from google.protobuf import struct_pb2 as structpb
|
||||
|
||||
# TODO(negz): Do we really need dict_to_struct and struct_to_dict? They don't do
|
||||
# much, but are perhaps useful for discoverability/"documentation" purposes.
|
||||
|
||||
|
||||
def dict_to_struct(d: dict) -> structpb.Struct:
|
||||
"""Create a Struct well-known type from the supplied dict.
|
||||
|
||||
Functions must return desired resources encoded as a protobuf struct. This
|
||||
function makes it possible to work with a Python dict, then convert it to a
|
||||
struct in a RunFunctionResponse.
|
||||
"""
|
||||
s = structpb.Struct()
|
||||
s.update(d)
|
||||
return s
|
||||
|
||||
|
||||
def struct_to_dict(s: structpb.Struct) -> dict:
|
||||
"""Create a dict from the supplied Struct well-known type.
|
||||
|
||||
Crossplane sends observed and desired resources to a function encoded as a
|
||||
protobuf struct. This function makes it possible to convert resources to a
|
||||
dictionary.
|
||||
"""
|
||||
return dict(s)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Condition:
|
||||
"""A status condition."""
|
||||
|
||||
"""Type of the condition - e.g. Ready."""
|
||||
typ: str
|
||||
|
||||
"""Status of the condition - True, False, or Unknown."""
|
||||
status: str
|
||||
|
||||
"""Reason for the condition status - typically CamelCase."""
|
||||
reason: str | None = None
|
||||
|
||||
"""Optional message."""
|
||||
message: str | None = None
|
||||
|
||||
"""The last time the status transitioned to this status."""
|
||||
last_transition_time: datetime.time | None = None
|
||||
|
||||
|
||||
def get_condition(resource: structpb.Struct, typ: str) -> Condition:
|
||||
"""Get the supplied status condition of the supplied resource.
|
||||
|
||||
Args:
|
||||
resource: A Crossplane resource.
|
||||
typ: The type of status condition to get (e.g. Ready).
|
||||
|
||||
Returns:
|
||||
The requested status condition.
|
||||
|
||||
A status condition is always returned. If the status condition isn't present
|
||||
in the supplied resource, a condition with status "Unknown" is returned.
|
||||
"""
|
||||
unknown = Condition(typ=typ, status="Unknown")
|
||||
|
||||
if "status" not in resource:
|
||||
return unknown
|
||||
|
||||
if "conditions" not in resource["status"]:
|
||||
return unknown
|
||||
|
||||
for c in resource["status"]["conditions"]:
|
||||
if c["type"] != typ:
|
||||
continue
|
||||
|
||||
condition = Condition(
|
||||
typ=c["type"],
|
||||
status=c["status"],
|
||||
)
|
||||
if "message" in c:
|
||||
condition.message = c["message"]
|
||||
if "reason" in c:
|
||||
condition.reason = c["reason"]
|
||||
if "lastTransitionTime" in c:
|
||||
condition.last_transition_time = datetime.datetime.fromisoformat(
|
||||
c["lastTransitionTime"]
|
||||
)
|
||||
|
||||
return condition
|
||||
|
||||
return unknown
|
|
@ -0,0 +1,35 @@
|
|||
"""Utilities for working with RunFunctionResponses."""
|
||||
|
||||
import datetime
|
||||
|
||||
from google.protobuf import duration_pb2 as durationpb
|
||||
|
||||
import crossplane.function.proto.v1beta1.run_function_pb2 as fnv1beta1
|
||||
|
||||
"""The default TTL for which a RunFunctionResponse may be cached."""
|
||||
DEFAULT_TTL = datetime.timedelta(minutes=1)
|
||||
|
||||
|
||||
def to(
|
||||
req: fnv1beta1.RunFunctionRequest,
|
||||
ttl: datetime.timedelta = DEFAULT_TTL,
|
||||
) -> fnv1beta1.RunFunctionResponse:
|
||||
"""Create a response to the supplied request.
|
||||
|
||||
Args:
|
||||
req: The request to respond to.
|
||||
ttl: How long Crossplane may optionally cache the response.
|
||||
|
||||
Returns:
|
||||
A response to the supplied request.
|
||||
|
||||
The request's tag, desired resources, and context is automatically copied to
|
||||
the response. Using response.to is a good pattern to ensure
|
||||
"""
|
||||
dttl = durationpb.Duration()
|
||||
dttl.FromTimedelta(ttl)
|
||||
return fnv1beta1.RunFunctionResponse(
|
||||
meta=fnv1beta1.ResponseMeta(tag=req.meta.tag, ttl=dttl),
|
||||
desired=req.desired,
|
||||
context=req.context,
|
||||
)
|
|
@ -0,0 +1,99 @@
|
|||
"""Utilities to create a composition function runtime."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import grpc
|
||||
from grpc_reflection.v1alpha import reflection
|
||||
|
||||
import crossplane.function.proto.v1beta1.run_function_pb2 as fnv1beta1
|
||||
import crossplane.function.proto.v1beta1.run_function_pb2_grpc as grpcv1beta1
|
||||
|
||||
SERVICE_NAMES = (
|
||||
reflection.SERVICE_NAME,
|
||||
fnv1beta1.DESCRIPTOR.services_by_name["FunctionRunnerService"].full_name,
|
||||
)
|
||||
|
||||
|
||||
def load_credentials(tls_certs_dir: str) -> grpc.ServerCredentials:
|
||||
"""Load TLS credentials for a composition function gRPC server.
|
||||
|
||||
Args:
|
||||
tls_certs_dir: A directory containing tls.crt, tls.key, and ca.crt.
|
||||
|
||||
Returns:
|
||||
gRPC mTLS server credentials.
|
||||
|
||||
tls.crt and tls.key must be the function's PEM-encoded certificate and
|
||||
private key. ca.cert must be a PEM-encoded CA certificate used to
|
||||
authenticate callers (i.e. Crossplane).
|
||||
"""
|
||||
if tls_certs_dir is None:
|
||||
return None
|
||||
|
||||
with open(os.path.join(tls_certs_dir, "tls.crt"), "rb") as f:
|
||||
crt = f.read()
|
||||
|
||||
with open(os.path.join(tls_certs_dir, "tls.key"), "rb") as f:
|
||||
key = f.read()
|
||||
|
||||
with open(os.path.join(tls_certs_dir, "ca.crt"), "rb") as f:
|
||||
ca = f.read()
|
||||
|
||||
return grpc.ssl_server_credentials(
|
||||
private_key_certificate_chain_pairs=[(key, crt)],
|
||||
root_certificates=ca,
|
||||
require_client_auth=True,
|
||||
)
|
||||
|
||||
|
||||
def serve(
|
||||
function: grpcv1beta1.FunctionRunnerService,
|
||||
address: str,
|
||||
*,
|
||||
creds: grpc.ServerCredentials,
|
||||
insecure: bool,
|
||||
) -> None:
|
||||
"""Start a gRPC server and serve requests asychronously.
|
||||
|
||||
Args:
|
||||
function: The function (class) to use to serve requests.
|
||||
address: The address at which to listen for requests.
|
||||
creds: The credentials used to authenticate requests.
|
||||
insecure: Serve insecurely, without credentials or encryption.
|
||||
|
||||
Raises:
|
||||
ValueError if creds is None and insecure is False.
|
||||
|
||||
If insecure is true requests will be served insecurely, even if credentials
|
||||
are supplied.
|
||||
"""
|
||||
server = grpc.aio.server()
|
||||
|
||||
grpcv1beta1.add_FunctionRunnerServiceServicer_to_server(function, server)
|
||||
reflection.enable_server_reflection(SERVICE_NAMES, server)
|
||||
|
||||
if creds is None and insecure is False:
|
||||
msg = (
|
||||
"no credentials were provided - did you provide credentials or use "
|
||||
"the insecure flag?"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
if creds is not None:
|
||||
server.add_secure_port(address, creds)
|
||||
|
||||
# TODO(negz): Does this override add_secure_port?
|
||||
if insecure:
|
||||
server.add_insecure_port(address)
|
||||
|
||||
async def start():
|
||||
await server.start()
|
||||
await server.wait_for_termination()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(start())
|
||||
finally:
|
||||
loop.run_until_complete(server.stop(grace=5))
|
||||
loop.close()
|
|
@ -0,0 +1,109 @@
|
|||
[build-system]
|
||||
requires = ["hatchling", "hatch-vcs"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "function-sdk-python"
|
||||
description = 'The Python SDK for Crossplane composition functions'
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = "Apache-2.0"
|
||||
keywords = []
|
||||
authors = [{ name = "Crossplane Maintainers", email = "info@crossplane.io" }]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"click==8.*",
|
||||
"grpcio==1.*",
|
||||
"grpcio-reflection==1.*",
|
||||
"structlog==23.*",
|
||||
]
|
||||
|
||||
dynamic = ["version"]
|
||||
|
||||
[project.urls]
|
||||
Documentation = "https://github.com/crossplane/function-sdk-python#readme"
|
||||
Issues = "https://github.com/crossplane/function-sdk-python/issues"
|
||||
Source = "https://github.com/crossplane/function-sdk-python"
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "vcs"
|
||||
|
||||
[tool.hatch.envs.default]
|
||||
type = "virtual"
|
||||
path = ".venv-default"
|
||||
dependencies = ["ipython==8.17.2"]
|
||||
|
||||
[tool.hatch.envs.generate]
|
||||
type = "virtual"
|
||||
detached = true
|
||||
path = ".venv-generate"
|
||||
dependencies = ["grpcio-tools==1.59.2"]
|
||||
|
||||
[tool.hatch.envs.generate.scripts]
|
||||
protoc = "python -m grpc_tools.protoc --proto_path=. --python_out=. --pyi_out=. --grpc_python_out=. crossplane/function/proto/v1beta1/run_function.proto"
|
||||
|
||||
[tool.hatch.envs.lint]
|
||||
type = "virtual"
|
||||
detached = true
|
||||
path = ".venv-lint"
|
||||
dependencies = ["ruff==0.1.6"]
|
||||
|
||||
[tool.hatch.envs.lint.scripts]
|
||||
check = "ruff check crossplane tests"
|
||||
check-format = "ruff format --diff crossplane tests"
|
||||
|
||||
[tool.hatch.envs.test]
|
||||
type = "virtual"
|
||||
path = ".venv-test"
|
||||
|
||||
[tool.hatch.envs.test.scripts]
|
||||
unit = "python -m unittest tests/*.py"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
exclude = ["crossplane/function/proto/*"]
|
||||
select = [
|
||||
"A",
|
||||
"ARG",
|
||||
"ASYNC",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"DTZ",
|
||||
"E",
|
||||
"EM",
|
||||
"ERA",
|
||||
"F",
|
||||
"FBT",
|
||||
"I",
|
||||
"ICN",
|
||||
"ISC",
|
||||
"N",
|
||||
"PLC",
|
||||
"PLE",
|
||||
"PLR",
|
||||
"PLW",
|
||||
"Q",
|
||||
"RUF",
|
||||
"S",
|
||||
"T",
|
||||
"TID",
|
||||
"UP",
|
||||
"W",
|
||||
"YTT",
|
||||
]
|
||||
ignore = ["ISC001"] # Ruff warns this is incompatible with ruff format.
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = ["D"] # Don't require docstrings for tests.
|
||||
|
||||
[tool.ruff.isort]
|
||||
known-first-party = ["function"]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
|
@ -0,0 +1,113 @@
|
|||
import dataclasses
|
||||
import datetime
|
||||
import unittest
|
||||
|
||||
from google.protobuf import struct_pb2 as structpb
|
||||
|
||||
from crossplane.function import logging, resource
|
||||
|
||||
|
||||
class TestResource(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
logging.configure(level=logging.Level.DISABLED)
|
||||
|
||||
def test_get_condition(self) -> None:
|
||||
@dataclasses.dataclass
|
||||
class TestCase:
|
||||
reason: str
|
||||
res: structpb.Struct
|
||||
typ: str
|
||||
want: resource.Condition
|
||||
|
||||
cases = [
|
||||
TestCase(
|
||||
reason="Return an unknown condition if the resource has no status.",
|
||||
res=resource.dict_to_struct({}),
|
||||
typ="Ready",
|
||||
want=resource.Condition(typ="Ready", status="Unknown"),
|
||||
),
|
||||
TestCase(
|
||||
reason="Return an unknown condition if the resource has no conditions.",
|
||||
res=resource.dict_to_struct({"status": {}}),
|
||||
typ="Ready",
|
||||
want=resource.Condition(typ="Ready", status="Unknown"),
|
||||
),
|
||||
TestCase(
|
||||
reason="Return an unknown condition if the resource does not have the "
|
||||
"requested type of condition.",
|
||||
res=resource.dict_to_struct(
|
||||
{
|
||||
"status": {
|
||||
"conditions": [
|
||||
{
|
||||
"type": "Cool",
|
||||
"status": "True",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
),
|
||||
typ="Ready",
|
||||
want=resource.Condition(typ="Ready", status="Unknown"),
|
||||
),
|
||||
TestCase(
|
||||
reason="Return a minimal condition if it exists.",
|
||||
res=resource.dict_to_struct(
|
||||
{
|
||||
"status": {
|
||||
"conditions": [
|
||||
{
|
||||
"type": "Ready",
|
||||
"status": "True",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
),
|
||||
typ="Ready",
|
||||
want=resource.Condition(typ="Ready", status="True"),
|
||||
),
|
||||
TestCase(
|
||||
reason="Return a full condition if it exists.",
|
||||
res=resource.dict_to_struct(
|
||||
{
|
||||
"status": {
|
||||
"conditions": [
|
||||
{
|
||||
"type": "Ready",
|
||||
"status": "True",
|
||||
"reason": "Cool",
|
||||
"message": "This condition is very cool",
|
||||
"lastTransitionTime": "2023-10-02T16:30:00Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
),
|
||||
typ="Ready",
|
||||
want=resource.Condition(
|
||||
typ="Ready",
|
||||
status="True",
|
||||
reason="Cool",
|
||||
message="This condition is very cool",
|
||||
last_transition_time=datetime.datetime(
|
||||
year=2023,
|
||||
month=10,
|
||||
day=2,
|
||||
hour=16,
|
||||
minute=30,
|
||||
tzinfo=datetime.UTC,
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
for case in cases:
|
||||
got = resource.get_condition(case.res, case.typ)
|
||||
self.assertEqual(
|
||||
dataclasses.asdict(case.want), dataclasses.asdict(got), "-want, +got"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
|
@ -0,0 +1,61 @@
|
|||
import dataclasses
|
||||
import datetime
|
||||
import unittest
|
||||
|
||||
from google.protobuf import duration_pb2 as durationpb
|
||||
from google.protobuf import json_format
|
||||
|
||||
from crossplane.function import logging, resource, response
|
||||
from crossplane.function.proto.v1beta1 import run_function_pb2 as fnv1beta1
|
||||
|
||||
|
||||
class TestResponse(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
logging.configure(level=logging.Level.DISABLED)
|
||||
|
||||
def test_to(self) -> None:
|
||||
@dataclasses.dataclass
|
||||
class TestCase:
|
||||
reason: str
|
||||
req: fnv1beta1.RunFunctionRequest
|
||||
ttl: datetime.timedelta
|
||||
want: fnv1beta1.RunFunctionResponse
|
||||
|
||||
cases = [
|
||||
TestCase(
|
||||
reason="Tag, desired, and context should be copied.",
|
||||
req=fnv1beta1.RunFunctionRequest(
|
||||
meta=fnv1beta1.RequestMeta(tag="hi"),
|
||||
desired=fnv1beta1.State(
|
||||
resources={
|
||||
"ready-composed-resource": fnv1beta1.Resource(),
|
||||
}
|
||||
),
|
||||
context=resource.dict_to_struct({"cool-key": "cool-value"}),
|
||||
),
|
||||
ttl=datetime.timedelta(minutes=10),
|
||||
want=fnv1beta1.RunFunctionResponse(
|
||||
meta=fnv1beta1.ResponseMeta(
|
||||
tag="hi", ttl=durationpb.Duration(seconds=60 * 10)
|
||||
),
|
||||
desired=fnv1beta1.State(
|
||||
resources={
|
||||
"ready-composed-resource": fnv1beta1.Resource(),
|
||||
}
|
||||
),
|
||||
context=resource.dict_to_struct({"cool-key": "cool-value"}),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
for case in cases:
|
||||
got = response.to(case.req, case.ttl)
|
||||
self.assertEqual(
|
||||
json_format.MessageToJson(case.want),
|
||||
json_format.MessageToJson(got),
|
||||
"-want, +got",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
Loading…
Reference in New Issue