crossplane-function-pythonic 0.5.0__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,2 @@
1
1
  # This is set at build time, using "hatch version"
2
- __version__ = "0.5.0"
2
+ __version__ = "0.6.0"
@@ -104,11 +104,13 @@ class BaseComposite:
104
104
  )
105
105
  self.response = protobuf.Message(None, 'response', response.DESCRIPTOR, response)
106
106
  self.logger = logger
107
+ self.capabilities = Capabilities(self.request.meta.capabilities)
107
108
  self.parameters = self.request.input.parameters
108
109
  self.credentials = Credentials(self.request)
109
110
  self.context = self.response.context
110
111
  self.environment = self.context['apiextensions.crossplane.io/environment']
111
112
  self.requireds = Requireds(self)
113
+ self.schemas = Schemas(self)
112
114
  self.resources = Resources(self)
113
115
  self.autoReady = True
114
116
  self.usages = False
@@ -123,6 +125,7 @@ class BaseComposite:
123
125
  self.metadata = self.observed.metadata
124
126
  self.spec = self.observed.spec
125
127
  self.status = self.desired.status
128
+ self.output = self.response.output
126
129
  self.conditions = Conditions(observed, self.response)
127
130
  self.results = Results(self.response)
128
131
  self.events = Results(self.response) # Deprecated, use self.results
@@ -136,6 +139,30 @@ class BaseComposite:
136
139
  raise NotImplementedError()
137
140
 
138
141
 
142
+ class Capabilities:
143
+ def __init__(self, capabilities):
144
+ self._capabilities = capabilities
145
+
146
+ def __bool__(self):
147
+ return fnv1.CAPABILITY_CAPABILITIES in self._capabilities
148
+
149
+ @property
150
+ def requireds(self):
151
+ return fnv1.CAPABILITY_REQUIRED_RESOURCES in self._capabilities if self else None
152
+
153
+ @property
154
+ def credentials(self):
155
+ return fnv1.CAPABILITY_CREDENTIALS in self._capabilities if self else None
156
+
157
+ @property
158
+ def conditions(self):
159
+ return fnv1.CAPABILITY_CONDITIONS in self._capabilities if self else None
160
+
161
+ @property
162
+ def schemas(self):
163
+ return fnv1.CAPABILITY_REQUIRED_SCHEMAS in self._capabilities if self else None
164
+
165
+
139
166
  class Credentials:
140
167
  def __init__(self, request):
141
168
  self.__dict__['_request'] = request
@@ -558,6 +585,131 @@ class RequiredResource:
558
585
  return bool(self.observed)
559
586
 
560
587
 
588
+ class Schemas:
589
+ def __init__(self, composite):
590
+ self._composite = composite
591
+ self._cache = {}
592
+
593
+ def __getattr__(self, key):
594
+ return self[key]
595
+
596
+ def __getitem__(self, key):
597
+ schema = self._cache.get(key)
598
+ if not schema:
599
+ schema = Schema(self._composite, key)
600
+ self._cache[key] = schema
601
+ return schema
602
+
603
+ def __bool__(self):
604
+ return bool(len(self))
605
+
606
+ def __len__(self):
607
+ names = set()
608
+ for name, schema in self._composite.request.required_schemas:
609
+ names.add(name)
610
+ for name, selector in self._composite.response.requirements.schemas:
611
+ names.add(name)
612
+ return len(names)
613
+
614
+ def __contains__(self, key):
615
+ if key in self._composite.request.required_schemas:
616
+ return True
617
+ if key in self._composite.response.requirements.schemas:
618
+ return True
619
+ return False
620
+
621
+ def __iter__(self):
622
+ names = set()
623
+ for name, schema in self._composite.request.required_schemas:
624
+ names.add(name)
625
+ for name, selector in self._composite.response.requirements.schemas:
626
+ names.add(name)
627
+ for name in sorted(names):
628
+ yield name, self[name]
629
+
630
+
631
+ class Schema:
632
+ def __init__(self, composite, name):
633
+ self.name = name
634
+ self._selector = composite.response.requirements.schemas[name]
635
+ self._schema = composite.request.required_schemas[name].openapi_v3
636
+
637
+ def __call__(self, kind=_notset, apiVersion=_notset):
638
+ self._selector()
639
+ if kind != _notset:
640
+ # Allow for apiVersion in the first arg and kind in the second arg
641
+ if '/' in kind or kind == 'v1':
642
+ if apiVersion != _notset:
643
+ self.kind = apiVersion
644
+ apiVersion = kind
645
+ else:
646
+ self.kind = kind
647
+ if apiVersion != _notset:
648
+ self.apiVersion = apiVersion
649
+ return self
650
+
651
+ @property
652
+ def apiVersion(self):
653
+ return self._selector.api_version
654
+
655
+ @apiVersion.setter
656
+ def apiVersion(self, apiVersion):
657
+ self._selector.api_version = apiVersion
658
+
659
+ @property
660
+ def kind(self):
661
+ return self._selector.kind
662
+
663
+ @kind.setter
664
+ def kind(self, kind):
665
+ self._selector.kind = kind
666
+
667
+ def __enter__(self):
668
+ return self
669
+
670
+ def __exit__(self, exc_type, exc_value, traceback):
671
+ pass
672
+
673
+ def __aenter__(self):
674
+ return self
675
+
676
+ def __aexit__(self, exc_type, exc_value, traceback):
677
+ pass
678
+
679
+ def __getattr__(self, key):
680
+ return self[key]
681
+
682
+ def __getitem__(self, key):
683
+ return self._schema[key]
684
+
685
+ def __bool__(self):
686
+ return bool(self._schema)
687
+
688
+ def __len__(self):
689
+ return len(self._schema)
690
+
691
+ def __contains__(self, item):
692
+ return item in self._schema
693
+
694
+ def __iter__(self):
695
+ for key, value in self._schema:
696
+ yield key, value
697
+
698
+ def __hash__(self):
699
+ return hash(self._schema)
700
+
701
+ def __eq__(self, other):
702
+ if instance(other, Schema):
703
+ other = other._schema
704
+ return self._schema == other
705
+
706
+ def __str__(self):
707
+ return str(self._schema)
708
+
709
+ def __format__(self, spec='yaml'):
710
+ return format(self,_schema, spec)
711
+
712
+
561
713
  class Conditions:
562
714
  def __init__(self, observed, response=None):
563
715
  self._observed = observed
@@ -121,8 +121,13 @@ class FunctionRunner(grpcv1.FunctionRunnerService):
121
121
  except Exception as e:
122
122
  return self.fatal(request, logger, 'Compose', e)
123
123
 
124
- if requireds := self.get_requireds(step, composite):
125
- logger.debug(f"Requireds requested: {','.join(requireds)}")
124
+ schemas = self.get_schemas(step, composite)
125
+ requireds = self.get_requireds(step, composite)
126
+ if schemas or requireds:
127
+ if schemas:
128
+ logger.debug(f"Required schemas: {','.join(schemas)}")
129
+ if requireds:
130
+ logger.debug(f"Required resources: {','.join(requireds)}")
126
131
  else:
127
132
  self.process_usages(composite)
128
133
  self.process_unknowns(composite)
@@ -155,11 +160,21 @@ class FunctionRunner(grpcv1.FunctionRunnerService):
155
160
  ]
156
161
  )
157
162
 
163
+ def get_schemas(self, step, composite):
164
+ schemas = []
165
+ for name, schema in composite.schemas:
166
+ if len(schema.kind) and len(schema.apiVersion):
167
+ s = pythonic.Map(kind=schema.kind, apiVersion=schema.apiVersion)
168
+ if s != step.schemas[name]:
169
+ step.schemas[name] = s
170
+ schemas.append(name)
171
+ return schemas
172
+
158
173
  def get_requireds(self, step, composite):
159
174
  requireds = []
160
175
  for name, required in composite.requireds:
161
- if len(required.apiVersion) and len(required.kind):
162
- r = pythonic.Map(apiVersion=required.apiVersion, kind=required.kind)
176
+ if len(required.kind) and len(required.apiVersion):
177
+ r = pythonic.Map(kind=required.kind, apiVersion=required.apiVersion)
163
178
  if len(required.namespace):
164
179
  r.namespace = required.namespace
165
180
  if len(required.matchName):
@@ -45,6 +45,12 @@ class Command(command.Command):
45
45
  parser.add_argument(
46
46
  '--packages',
47
47
  action='store_true',
48
+ dest='packages_configmaps',
49
+ help='Discover python packages from function-pythonic ConfigMaps, deprecated use --packages-configmaps'
50
+ )
51
+ parser.add_argument(
52
+ '--packages-configmaps',
53
+ action='store_true',
48
54
  help='Discover python packages from function-pythonic ConfigMaps.'
49
55
  )
50
56
  parser.add_argument(
@@ -57,7 +63,17 @@ class Command(command.Command):
57
63
  action='append',
58
64
  default=[],
59
65
  metavar='NAMESPACE',
60
- help='Namespaces to discover function-pythonic ConfigMaps in, default is cluster wide.',
66
+ help='Namespaces to discover function-pythonic ConfigMaps and Secrets in, default is cluster wide.',
67
+ )
68
+ parser.add_argument(
69
+ '--packages-environmentconfigs',
70
+ action='store_true',
71
+ help='Also Discover python packages from function-pythonic EnvironmentConfigs.'
72
+ )
73
+ parser.add_argument(
74
+ '--packages-compositions',
75
+ action='store_true',
76
+ help='Also Discover python packages from function-pythonic Compositions.'
61
77
  )
62
78
  parser.add_argument(
63
79
  '--packages-dir',
@@ -75,7 +91,9 @@ class Command(command.Command):
75
91
  if not self.args.tls_certs_dir and not self.args.insecure:
76
92
  print('Either --tls-certs-dir or --insecure must be specified', file=sys.stderr)
77
93
  sys.exit(1)
78
-
94
+ if (self.args.packages_environmentconfigs or self.args.packages_compositions) and self.args.packages_namespace:
95
+ print('--packages-namespace cannot be used with --packages-environment-configs or --packages-compositions', file=sys.stderr)
96
+ sys.exit(1)
79
97
  if self.args.pip_install:
80
98
  import pip._internal.cli.main
81
99
  pip._internal.cli.main.main(['install', '--user', *shlex.split(self.args.pip_install)])
@@ -108,15 +126,18 @@ class Command(command.Command):
108
126
  )
109
127
  await grpc_server.start()
110
128
 
111
- if self.args.packages:
129
+ if self.args.packages_configmaps or self.args.packages_secrets or self.args.packages_environmentconfigs or self.args.packages_compositions:
112
130
  from . import packages
113
131
  async with asyncio.TaskGroup() as tasks:
114
132
  tasks.create_task(grpc_server.wait_for_termination())
115
133
  tasks.create_task(packages.operator(
116
134
  grpc_server,
117
135
  grpc_runner,
136
+ self.args.packages_configmaps,
118
137
  self.args.packages_secrets,
119
138
  self.args.packages_namespace,
139
+ self.args.packages_environmentconfigs,
140
+ self.args.packages_compositions,
120
141
  self.args.packages_dir,
121
142
  ))
122
143
  else:
@@ -10,27 +10,38 @@ import kopf
10
10
  GRPC_SERVER = None
11
11
  GRPC_RUNNER = None
12
12
  PACKAGES_DIR = None
13
- PACKAGE_LABEL = {'function-pythonic.package': kopf.PRESENT}
13
+ PACKAGE_LABEL = 'function-pythonic.package'
14
+ PACKAGE_LABELS = {PACKAGE_LABEL: kopf.PRESENT}
14
15
 
15
16
 
16
- def operator(grpc_server, grpc_runner, packages_secrets, packages_namespaces, packages_dir):
17
+ def operator(grpc_server, grpc_runner, packages_configmaps, packages_secrets, packages_namespaces, packages_environmentconfigs, packages_compositions, packages_dir):
17
18
  logging.getLogger('kopf.objects').setLevel(logging.INFO)
18
19
  global GRPC_SERVER, GRPC_RUNNER, PACKAGES_DIR
19
20
  GRPC_SERVER = grpc_server
20
21
  GRPC_RUNNER = grpc_runner
21
22
  PACKAGES_DIR = pathlib.Path(packages_dir).expanduser().resolve()
22
23
  sys.path.insert(0, str(PACKAGES_DIR))
24
+ if packages_configmaps:
25
+ on_resource('', 'v1', 'configmaps')
23
26
  if packages_secrets:
24
- kopf.on.create('', 'v1', 'secrets', labels=PACKAGE_LABEL)(create)
25
- kopf.on.resume('', 'v1', 'secrets', labels=PACKAGE_LABEL)(create)
26
- kopf.on.update('', 'v1', 'secrets', labels=PACKAGE_LABEL)(update)
27
- kopf.on.delete('', 'v1', 'secrets', labels=PACKAGE_LABEL)(delete)
27
+ on_resource('', 'v1', 'secrets')
28
+ if not packages_namespaces:
29
+ if packages_environmentconfigs:
30
+ on_resource('apiextensions.crossplane.io', 'v1beta1', 'environmentconfigs')
31
+ if packages_compositions:
32
+ on_resource('apiextensions.crossplane.io', 'v1', 'compositions')
28
33
  return kopf.operator(
29
34
  standalone=True,
30
35
  clusterwide=not packages_namespaces,
31
36
  namespaces=packages_namespaces,
32
37
  )
33
38
 
39
+ def on_resource(group, version, plural):
40
+ kopf.on.create(group, version, plural, labels=PACKAGE_LABELS)(create)
41
+ kopf.on.resume(group, version, plural, labels=PACKAGE_LABELS)(create)
42
+ kopf.on.update(group, version, plural, labels=PACKAGE_LABELS)(update)
43
+ kopf.on.delete(group, version, plural, labels=PACKAGE_LABELS)(delete)
44
+
34
45
 
35
46
  @kopf.on.startup()
36
47
  async def startup(settings, **_):
@@ -42,107 +53,128 @@ async def cleanup(**_):
42
53
  await GRPC_SERVER.stop(5)
43
54
 
44
55
 
45
- @kopf.on.create('', 'v1', 'configmaps', labels=PACKAGE_LABEL)
46
- @kopf.on.resume('', 'v1', 'configmaps', labels=PACKAGE_LABEL)
47
- async def create(body, logger, **_):
48
- package_dir = get_package_dir(body, logger)
49
- if package_dir:
50
- secret = body['kind'] == 'Secret'
51
- for name, text in body.get('data', {}).items():
52
- package_file_write(package_dir, name, secret, text, 'Created', logger)
53
-
54
-
55
- @kopf.on.update('', 'v1', 'configmaps', labels=PACKAGE_LABEL)
56
- async def update(body, old, logger, **_):
57
- old_package_dir = get_package_dir(old)
58
- if old_package_dir:
59
- old_data = old.get('data', {})
60
- else:
61
- old_data = {}
62
- old_names = set(old_data.keys())
63
- package_dir = get_package_dir(body, logger)
64
- if package_dir:
65
- secret = body['kind'] == 'Secret'
66
- for name, text in body.get('data', {}).items():
67
- if package_dir == old_package_dir and text == old_data.get(name, None):
68
- action = 'Unchanged'
69
- else:
70
- action = 'Updated' if package_dir == old_package_dir and name in old_names else 'Created'
71
- package_file_write(package_dir, name, secret, text, action, logger)
72
- if package_dir == old_package_dir:
73
- old_names.discard(name)
74
- if old_package_dir:
75
- for name in old_names:
76
- package_file_unlink(old_package_dir, name, 'Removed', logger)
77
-
78
-
79
- @kopf.on.delete('', 'v1', 'configmaps', labels=PACKAGE_LABEL)
80
- async def delete(old, logger, **_):
81
- package_dir = get_package_dir(old)
82
- if package_dir:
83
- for name in old.get('data', {}).keys():
84
- package_file_unlink(package_dir, name, 'Deleted', logger)
85
-
86
-
87
- def get_package_dir(body, logger=None):
88
- package = body.get('metadata', {}).get('labels', {}).get('function-pythonic.package', None)
56
+ async def create(resource, labels, body, logger, **_):
57
+ resource_create(resource, labels, 'Created', body, logger)
58
+
59
+
60
+ async def update(resource, labels, body, old, logger, **_):
61
+ resource_delete(resource, labels, 'Removed', old, logger)
62
+ resource_create(resource, labels, 'Added', body, logger)
63
+
64
+
65
+ async def delete(resource, labels, body, logger, **_):
66
+ resource_delete(resource, labels, 'Deleted', body, logger)
67
+
68
+
69
+ def resource_create(resource, labels, action, body, logger):
70
+ package_dir = resource_package_dir(resource, labels, logger)
71
+ if not package_dir:
72
+ return
73
+ if resource.plural in ('configmaps', 'secrets', 'environmentconfigs'):
74
+ package_create(resource, action, package_dir, body.get('data', {}), logger)
75
+ elif resource.plural == 'compositions':
76
+ for step in body.get('spec', {}).get('pipeline', []):
77
+ input = step.get('input')
78
+ if input and input.get('apiVersion') == 'pythonic.fn.crossplane.io/v1alpha1':
79
+ package_create(resource, action, package_dir, input.get('packages', {}), logger)
80
+
81
+
82
+ def resource_delete(resource, labels, action, body, logger):
83
+ package_dir = resource_package_dir(resource, labels, logger)
84
+ if not package_dir:
85
+ return
86
+ if resource.plural in ('configmaps', 'secrets', 'environmentconfigs'):
87
+ package_delete(action, package_dir, body.get('data', {}), logger)
88
+ elif resource.plural == 'compositions':
89
+ for step in body.get('spec', {}).get('pipeline', []):
90
+ input = step.get('input')
91
+ if input and input.get('apiVersion') == 'pythonic.fn.crossplane.io/v1alpha1':
92
+ package_delete(action, package_dir, step.get('input', {}).get('packages', {}), logger)
93
+
94
+
95
+ def resource_package_dir(resource, labels, logger):
96
+ package = labels.get(PACKAGE_LABEL)
89
97
  if package is None:
90
98
  if logger:
91
- logger.error('function-pythonic.package label is missing')
99
+ logger.error(f"{PACKAGE_LABEL} label is missing")
92
100
  return None
93
101
  package_dir = PACKAGES_DIR
94
- if package:
102
+ if resource.plural in ('configmaps', 'secrets') and package:
95
103
  for segment in package.split('.'):
96
104
  if not segment.isidentifier():
97
- if logger:
98
- logger.error('Package has invalid package name: %s', package)
105
+ logger.error('Package has invalid package name: %s', package)
99
106
  return None
100
107
  package_dir = package_dir / segment
101
108
  return package_dir
102
109
 
103
110
 
104
- def package_file_write(package_dir, name, secret, text, action, logger):
105
- package_file = package_dir / name
106
- if action != 'Unchanged':
107
- package_file.parent.mkdir(parents=True, exist_ok=True)
108
- if secret:
109
- package_file.write_bytes(base64.b64decode(text.encode('utf-8')))
110
- else:
111
- package_file.write_text(text)
112
- module, name = package_file_name(package_file)
113
- if module:
114
- if action != 'Unchanged':
115
- GRPC_RUNNER.invalidate_module(name)
116
- logger.info(f"{action} module: {name}")
117
- else:
118
- logger.info(f"{action} file: {name}")
119
-
120
-
121
- def package_file_unlink(package_dir, name, action, logger):
122
- package_file = package_dir / name
123
- package_file.unlink(missing_ok=True)
124
- module, name = package_file_name(package_file)
125
- if module:
126
- GRPC_RUNNER.invalidate_module(name)
127
- logger.info(f"{action} module: {name}")
128
- else:
129
- logger.info(f"{action} file: {name}")
130
- package_dir = package_file.parent
131
- while (
132
- package_dir.is_relative_to(PACKAGES_DIR)
133
- and package_dir.is_dir()
134
- and not list(package_dir.iterdir())
135
- ):
136
- package_dir.rmdir()
137
- module = str(package_dir.relative_to(PACKAGES_DIR)).replace('/', '.')
138
- if module != '.':
139
- GRPC_RUNNER.invalidate_module(module)
140
- logger.info(f"{action} package: {module}")
141
- package_dir = package_dir.parent
142
-
143
-
144
- def package_file_name(package_file):
145
- name = str(package_file.relative_to(PACKAGES_DIR))
111
+ def package_create(resource, action, package_dir, package, logger):
112
+ for name, value in package.items():
113
+ if validate_entry(name, value, logger):
114
+ package_name = package_dir / name
115
+ if isinstance(value, str):
116
+ package_name.parent.mkdir(parents=True, exist_ok=True)
117
+ if resource.plural == 'secrets':
118
+ package_name.write_bytes(base64.b64decode(value.encode('utf-8')))
119
+ else:
120
+ package_name.write_text(value)
121
+ module, name = package_file_name(package_name)
122
+ if module:
123
+ GRPC_RUNNER.invalidate_module(name)
124
+ logger.info(f"{action} module: {name}")
125
+ else:
126
+ logger.info(f"{action} file: {name}")
127
+ elif isinstance(value, dict):
128
+ package_create(resource, action, package_name, value, logger)
129
+
130
+
131
+ def package_delete(action, package_dir, package, logger):
132
+ for name, value in package.items():
133
+ if validate_entry(name, value, logger):
134
+ package_name = package_dir / name
135
+ if isinstance(value, str):
136
+ package_name.unlink(missing_ok=True)
137
+ module, name = package_file_name(package_name)
138
+ if module:
139
+ GRPC_RUNNER.invalidate_module(name)
140
+ logger.info(f"{action} module: {name}")
141
+ else:
142
+ logger.info(f"{action} file: {name}")
143
+ parent = package_name.parent
144
+ while (
145
+ parent.is_relative_to(PACKAGES_DIR)
146
+ and parent.is_dir()
147
+ and not list(parent.iterdir())
148
+ ):
149
+ parent.rmdir()
150
+ module = str(parent.relative_to(PACKAGES_DIR)).replace('/', '.')
151
+ if module != '.':
152
+ GRPC_RUNNER.invalidate_module(module)
153
+ logger.info(f"{action} package: {module}")
154
+ parent = parent.parent
155
+ elif isinstance(value, dict):
156
+ package_delete(action, package_name, value, logger)
157
+
158
+
159
+ def validate_entry(name, value, logger):
160
+ if isinstance(value, str):
161
+ if not name.endswith('.py'):
162
+ if '.' in name or '/' in name:
163
+ logger.error(f"Python package file name is not valid: {name}")
164
+ return False
165
+ return True
166
+ name = name[:-3]
167
+ elif not isinstance(value, dict):
168
+ logger.error(f"Python package \"{name}\" value is not a valid type: {value.__class__}")
169
+ return False
170
+ if name.isidentifier():
171
+ return True
172
+ logger.error(f"Python package name is not an identifier: {name}")
173
+ return False
174
+
175
+
176
+ def package_file_name(package_name):
177
+ name = str(package_name.relative_to(PACKAGES_DIR))
146
178
  if name.endswith('.py'):
147
179
  return True, name[:-3].replace('/', '.')
148
180
  return False, name
@@ -986,6 +986,8 @@ class Value:
986
986
  elif len(args):
987
987
  for key in range(len(args)):
988
988
  self[key] = args[key]
989
+ else:
990
+ self._ensure_map()
989
991
  return self
990
992
 
991
993
  def __setattr__(self, key, value):
@@ -3,6 +3,7 @@ import asyncio
3
3
  import importlib
4
4
  import inflect
5
5
  import inspect
6
+ import json
6
7
  import kr8s
7
8
  import logging
8
9
  import pathlib
@@ -12,10 +13,10 @@ from crossplane.function.proto.v1 import run_function_pb2 as fnv1
12
13
 
13
14
  from . import (
14
15
  command,
15
- composite,
16
16
  function,
17
17
  protobuf,
18
18
  )
19
+ from .composite import BaseComposite
19
20
 
20
21
  INFLECT = inflect.engine()
21
22
  INFLECT.classical(all=False)
@@ -58,7 +59,7 @@ class Command(command.Command):
58
59
  action='append',
59
60
  default=[],
60
61
  metavar='KEY=VALUE',
61
- help='Context key-value pairs to pass to the Function pipeline. Values must be sYAML/JSON. Keys take precedence over --context-files.',
62
+ help='Context key-value pairs to pass to the Function pipeline. Values must be YAML/JSON. Keys take precedence over --context-files.',
62
63
  )
63
64
  parser.add_argument(
64
65
  '--observed-resources', '-o',
@@ -77,12 +78,12 @@ class Command(command.Command):
77
78
  help='A YAML file or directory of YAML files specifying required resources to pass to the Function pipeline.',
78
79
  )
79
80
  parser.add_argument(
80
- '--secret-store', '-s',
81
+ '--required-schemas', '-s',
81
82
  action='append',
82
83
  type=pathlib.Path,
83
84
  default=[],
84
85
  metavar='PATH',
85
- help='A YAML file or directory of YAML files specifying Secrets to use to resolve connections and credentials.',
86
+ help='A JSON file or directory of JSON files specifying required schemas to pass to the Function pipeline.',
86
87
  )
87
88
  parser.add_argument(
88
89
  '--include-full-xr', '-x',
@@ -119,11 +120,10 @@ class Command(command.Command):
119
120
  observed = self.collect_resources(self.args.observed_resources)
120
121
  composition = await self.setup_composition(composite, api)
121
122
  resources = self.collect_resources(self.args.required_resources)
122
- resources += self.collect_resources(self.args.secret_store)
123
- resources.sort(key=lambda resource: str(resource.metadata.name))
123
+ schemas = self.collect_schemas(self.args.required_schemas)
124
124
  context = self.setup_context()
125
125
 
126
- render = await self.render(composite, observed, composition, resources, context, api, self.args.render_unknowns, self.args.crossplane_v1)
126
+ render = await self.render(composite, observed, composition, resources, schemas, context, api, self.args.render_unknowns, self.args.crossplane_v1)
127
127
  if not render:
128
128
  sys.exit(1)
129
129
 
@@ -212,7 +212,7 @@ class Command(command.Command):
212
212
  if not inspect.isclass(clazz):
213
213
  print(f"Composition class {self.args.composition} is not a class", file=sys.stderr)
214
214
  sys.exit(1)
215
- if not issubclass(clazz, composite.BaseComposite):
215
+ if not issubclass(clazz, BaseComposite):
216
216
  print(f"Composition class {self.args.composition} is not a subclass of BaseComposite", file=sys.stderr)
217
217
  sys.exit(1)
218
218
  return self.create_composition(composite, self.args.composition)
@@ -246,8 +246,25 @@ class Command(command.Command):
246
246
  else:
247
247
  print(f"Specified resource is not a file or a directory: {entry}", file=sys.stderr)
248
248
  sys.exit(1)
249
+ resources.sort(key=lambda resource: str(resource.metadata.name))
249
250
  return resources
250
251
 
252
+ def collect_schemas(self, entries):
253
+ schemas = []
254
+ for entry in entries:
255
+ if entry.is_file():
256
+ document = json.loads(entry.read_text())
257
+ schemas.append(protobuf.Value(None, None, document))
258
+ elif entry.is_dir():
259
+ for file in entry.iterdir():
260
+ if file.suffix == '.json':
261
+ document = json.loads(file.read_text())
262
+ schemas.append(protobuf.Value(None, None, document))
263
+ else:
264
+ print(f"Specified resource is not a file or a directory: {entry}", file=sys.stderr)
265
+ sys.exit(1)
266
+ return schemas
267
+
251
268
  def setup_context(self):
252
269
  # Load the request context with any specified command line options.
253
270
  context = protobuf.Map()
@@ -269,7 +286,7 @@ class Command(command.Command):
269
286
  context[key_value[0]] = protobuf.Yaml(key_value[1])
270
287
  return context
271
288
 
272
- async def render(self, composite, observed=[], composition=None, resources=[], context=None, api=None, render_unknowns=False, crossplane_v1=False, composite_observeds=True):
289
+ async def render(self, composite, observed=[], composition=None, resources=[], schemas=[], context=None, api=None, render_unknowns=False, crossplane_v1=False, composite_observeds=True):
273
290
  # Create the request used when running Composition steps.
274
291
  request = protobuf.Message(None, 'request', fnv1.RunFunctionRequest.DESCRIPTOR, fnv1.RunFunctionRequest())
275
292
  if context is not None:
@@ -362,6 +379,10 @@ class Command(command.Command):
362
379
  request.extra_resources()
363
380
  for name, selector in requirements.extra_resources:
364
381
  await self.set_required(name, selector, request.extra_resources, resources, api)
382
+ # Fetch the schemas requested.
383
+ request.required_schemas()
384
+ for name, selector in requirements.schemas:
385
+ await self.set_schema(name, selector, request.required_schemas, schemas, api)
365
386
  # Run the step using the function-pythonic function runner.
366
387
  response = protobuf.Message(
367
388
  None,
@@ -546,6 +567,81 @@ class Command(command.Command):
546
567
  for key, value in connection.data:
547
568
  destination.connection_details[key] = protobuf.B64Decode(value)
548
569
 
570
+ async def set_schema(self, name, selector, schemas, documents=[], api=None):
571
+ if not name:
572
+ return
573
+ name = str(name)
574
+ schema = schemas[name].openapi_v3
575
+ schema() # Force this to get created
576
+ gvk = protobuf.Map(kind=selector.kind)
577
+ version = str(selector.api_version)
578
+ if '/' in version:
579
+ gvk.group, gvk.version = version.split('/', 1)
580
+ else:
581
+ gvk.group = ''
582
+ gvk.version = version
583
+ for document in documents:
584
+ if self.find_schema(gvk, document, schema):
585
+ return
586
+ if api:
587
+ if gvk.group == '':
588
+ url = f"api/{gvk.version}"
589
+ else:
590
+ url = f"apis/{gvk.group}/{gvk.version}"
591
+ try:
592
+ async with api.call_api(base='/openapi/v3', version='', url=url) as response:
593
+ document = protobuf.Value(None, None, response.json())
594
+ except kr8s.NotFoundError:
595
+ return
596
+ self.find_schema(gvk, document, schema)
597
+
598
+ def find_schema(self, gvk, document, schema):
599
+ for name, s in document.components.schemas:
600
+ gvks = s['x-kubernetes-group-version-kind']
601
+ if len(gvks) == 1 and gvks[0] == gvk:
602
+ self.resolve_ref(document, set(), f"#/components/schemas/{name}", schema)
603
+ return True
604
+ return False
605
+
606
+ def resolve_ref(self, document, visiting, ref, schema):
607
+ if not ref:
608
+ return
609
+ ref = str(ref)
610
+ if ref in visiting:
611
+ return
612
+ d = None
613
+ for segment in ref.split('/'):
614
+ if segment == '#':
615
+ d = document
616
+ else:
617
+ d = d[segment]
618
+ if not d:
619
+ return
620
+ visiting.add(ref)
621
+ try:
622
+ for name, value in d:
623
+ self.copy_schema(document, visiting, name, value, schema)
624
+ finally:
625
+ visiting.remove(ref)
626
+
627
+ def copy_schema(self, document, visiting, key, value, schema):
628
+ if key == '$ref':
629
+ self.resolve_ref(document, visiting, value, schema)
630
+ elif key == 'allOf':
631
+ if value._isList and len(value) == 1:
632
+ self.resolve_ref(document, visiting, value[0]['$ref'], schema)
633
+ else:
634
+ if value._isMap:
635
+ s = schema[key]
636
+ for n, v in value:
637
+ self.copy_schema(document, visiting, n, v, s)
638
+ elif value._isList:
639
+ s = schema[key]
640
+ for ix, v in enumerate(value):
641
+ self.copy_schema(document, visiting, ix, v, s)
642
+ else:
643
+ schema[key] = value
644
+
549
645
  def copy_resource(self, source, destination):
550
646
  destination.resource = source.resource
551
647
  destination.connection_details()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crossplane-function-pythonic
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: A Python centric Crossplane Function
5
5
  Project-URL: Documentation, https://github.com/crossplane-contrib/function-pythonic#readme
6
6
  Project-URL: Issues, https://github.com/crossplane-contrib/function-pythonic/issues
@@ -19,7 +19,7 @@ Requires-Dist: inflect==7.5.0
19
19
  Requires-Dist: kr8s==0.20.15
20
20
  Requires-Dist: pyyaml==6.0.3
21
21
  Provides-Extra: packages
22
- Requires-Dist: kopf==1.43.0; extra == 'packages'
22
+ Requires-Dist: kopf==1.44.4; extra == 'packages'
23
23
  Provides-Extra: pip-install
24
24
  Requires-Dist: pip==26.0.1; extra == 'pip-install'
25
25
  Description-Content-Type: text/markdown
@@ -83,7 +83,7 @@ kind: Function
83
83
  metadata:
84
84
  name: function-pythonic
85
85
  spec:
86
- package: xpkg.upbound.io/crossplane-contrib/function-pythonic:v0.4.2
86
+ package: xpkg.crossplane.io/crossplane-contrib/function-pythonic:v0.6.0
87
87
  ```
88
88
 
89
89
  ### Crossplane V1
@@ -95,7 +95,7 @@ kind: Function
95
95
  metadata:
96
96
  name: function-pythonic
97
97
  spec:
98
- package: xpkg.upbound.io/crossplane-contrib/function-pythonic:v0.4.2
98
+ package: xpkg.crossplane.io/crossplane-contrib/function-pythonic:v0.6.0
99
99
  runtimeConfigRef:
100
100
  name: function-pythonic
101
101
  --
@@ -292,6 +292,7 @@ The BaseComposite class provides the following fields for manipulating the Compo
292
292
  | self.metadata | Map | The composite observed metadata |
293
293
  | self.spec | Map | The composite observed spec |
294
294
  | self.status | Map | The composite desired and observed status, read from observed if not in desired |
295
+ | self.output | Map | The step output, only used during Operations |
295
296
  | self.conditions | Conditions | The composite desired and observed conditions, read from observed if not in desired |
296
297
  | self.results | Results | Returned results applied to the Composite and optionally on the Claim |
297
298
  | self.connectionSecret | Map | The name, namespace, and resourceName to use when generating the connection secret in Crossplane v2 |
@@ -306,17 +307,31 @@ The BaseComposite also provides access to the following Crossplane Function leve
306
307
  | self.request | Message | Low level direct access to the RunFunctionRequest message |
307
308
  | self.response | Message | Low level direct access to the RunFunctionResponse message |
308
309
  | self.logger | Logger | Python logger to log messages to the running function stdout |
310
+ | self.capabilities | Capabilities | This Crossplane version's Capabilities |
309
311
  | self.parameters | Map | The configured step parameters |
310
312
  | self.ttl | Integer | Get or set the response TTL, in seconds |
311
313
  | self.credentials | Credentials | The request credentials |
312
314
  | self.context | Map | The response context, initialized from the request context |
313
315
  | self.environment | Map | The response environment, initialized from the request context environment |
314
316
  | self.requireds | Requireds | Request and read additional local Kubernetes resources |
317
+ | self.schemas | Schemas | Request and read CustomResourceDefinition schemas |
315
318
  | self.resources | Resources | Define and process composed resources |
316
319
  | self.usages| Boolean | Generate Crossplane Usages for resource dependencies, default False |
317
320
  | self.autoReady | Boolean | Perform auto ready processing on all composed resources, default True |
318
321
  | self.unknownsFatal | Boolean | Terminate the composition if already created resources are assigned unknown values, default False |
319
322
 
323
+ ### Capabiities
324
+
325
+ The Capabilities of the Crossplane version calling function-pythonic.
326
+
327
+ | Field | Type | Description |
328
+ | ----- | ---- | ----------- |
329
+ | bool(Capabilities) | Boolean | Whether or not the Crossplane version supports Capabilities |
330
+ | Capabiities.requireds | Boolean | Functions can return required resources and Crossplane will fetch the required resources |
331
+ | Capabiities.credentials | Boolean | Functions can receive credentials from secrets specified in the Composition |
332
+ | Capabiities.conditions | Boolean | Functions can return status conditions to be applied to the XR and optionally its claim |
333
+ | Capabiities.schemas | Boolean | Functions can request OpenAPI schemas and Crossplane will return them |
334
+
320
335
  ### Composed Resources
321
336
 
322
337
  Creating and accessing composed resources is performed using the `BaseComposite.resources` field.
@@ -350,7 +365,7 @@ Resource class:
350
365
 
351
366
  Creating and accessing required resources is performed using the `BaseComposite.requireds` field.
352
367
  `BaseComposite.requireds` is a dictionary of the required resources whose key is the required
353
- resource name. The value returned when getting a required resource from BaseComposite is the
368
+ schema name. The value returned when getting a required resource from BaseComposite is the
354
369
  following RequiredResources class:
355
370
 
356
371
  | Field | Type | Description |
@@ -363,9 +378,6 @@ following RequiredResources class:
363
378
  | RequiredResources.matchName | String | The names to match when returning the required resources |
364
379
  | RequiredResources.matchLabels | Map | The labels to match when returning the required resources |
365
380
 
366
- The current version of crossplane-sdk-python used by function-pythonic does not support namespace
367
- selection. For now, use matchLabels and filter the results if required.
368
-
369
381
  RequiredResources acts like a Python list to provide access to the found required resources.
370
382
  Each resource in the list is the following RequiredResource class:
371
383
 
@@ -382,6 +394,22 @@ Each resource in the list is the following RequiredResource class:
382
394
  | RequiredResource.conditions | Map | The required resource conditions |
383
395
  | RequiredResource.connection | Map | The required resource connection details |
384
396
 
397
+ ### Required Schemas
398
+
399
+ Creating and accessing required schemas is performed using the `BaseComposite.schemas` field.
400
+ `BaseComposite.schemas` is a dictionary of the required schema whose key is the required
401
+ resource name. The value returned when getting a required resource from BaseComposite is the
402
+ following Schema class:
403
+
404
+ | Field | Type | Description |
405
+ | ----- | ---- | ----------- |
406
+ | Schema(apiVersion,kind) | Schema | Reset the required schema and set the optional parameters |
407
+ | Schema.name | String | The required schema name |
408
+ | Schema.apiVersion | String | The required schema selector apiVersion |
409
+ | Schema.kind | String | The required schema selector kind |
410
+ | Schema.\_\_getitem\_\_ | Map | The required schema openAPIV3Schema |
411
+ | Schema.\_\_getattr\_\_ | Map | The required schema openAPIV3Schema |
412
+
385
413
  ### Conditions
386
414
 
387
415
  The `BaseComposite.conditions`, `Resource.conditions`, and `RequiredResource.conditions` fields
@@ -500,7 +528,7 @@ $ function-pythonic render --help
500
528
  usage: Crossplane Function Pythonic render [-h] [--debug] [--log-name-width WIDTH] [--logger-level LOGGER=LEVEL] [--python-path DIRECTORY]
501
529
  [--render-unknowns] [--allow-oversize-protos] [--crossplane-v1] [--kube-context CONTEXT]
502
530
  [--context-files KEY=PATH] [--context-values KEY=VALUE] [--observed-resources PATH]
503
- [--required-resources PATH] [--secret-store PATH] [--include-full-xr] [--include-connection-xr]
531
+ [--required-resources PATH] [--required-schemas PATH] [--include-full-xr] [--include-connection-xr]
504
532
  [--include-function-results] [--include-context]
505
533
  COMPOSITE [COMPOSITION]
506
534
 
@@ -532,8 +560,8 @@ options:
532
560
  A YAML file or directory of YAML files specifying the observed state of composed resources.
533
561
  --required-resources, -e PATH
534
562
  A YAML file or directory of YAML files specifying required resources to pass to the Function pipeline.
535
- --secret-store, -s PATH
536
- A YAML file or directory of YAML files specifying Secrets to use to resolve connections and credentials.
563
+ --required-schemas, -s PATH
564
+ A JSON file or directory of JSON files specifying required schemas to pass to the Function pipeline.
537
565
  --include-full-xr, -x
538
566
  Include a direct copy of the input XR's spedc and metadata fields in the rendered output.
539
567
  --include-connection-xr
@@ -602,9 +630,15 @@ status:
602
630
  Most of the examples contain a `render.sh` command which uses `function-pythonic render` to
603
631
  render the example.
604
632
 
605
- ## ConfigMap Packages
633
+ ## Shared Python Packages
634
+
635
+ Python packages and modules can be added to the function-pythonic runtime
636
+ by including the python code in any of the following resources: ConfigMap,
637
+ Secret, EnvironmentConfig, or Composition
638
+
639
+ ### ConfigMap Packages
606
640
 
607
- ConfigMap based python packages are enable using the `--packages` and
641
+ ConfigMap based python packages are enable using the `--packages-configmaps` and
608
642
  `--packages-namespace` command line options. ConfigMaps with the label
609
643
  `function-pythonic.package` will be incorporated in the python path at
610
644
  the location configured in the label value. For example, the following
@@ -666,7 +700,7 @@ data:
666
700
  composite: example.pythonic.features.FeatureOneComposite
667
701
  ...
668
702
  ```
669
- This requires enabling the the packages support using the `--packages` command
703
+ This requires enabling the the packages support using the `--packages-configmaps` command
670
704
  line option in the DeploymentRuntimeConfig and configuring the required
671
705
  Kubernetes RBAC permissions. For example:
672
706
  ```yaml
@@ -675,7 +709,7 @@ kind: Function
675
709
  metadata:
676
710
  name: function-pythonic
677
711
  spec:
678
- package: xpkg.upbound.io/crossplane-contrib/function-pythonic:v0.4.2
712
+ package: xpkg.crossplane.io/crossplane-contrib/function-pythonic:v0.6.0
679
713
  runtimeConfigRef:
680
714
  name: function-pythonic
681
715
  ---
@@ -737,9 +771,71 @@ ClusterRole permissions. The `--packages-namespace` command line option will res
737
771
  to only using the supplied namespace. This option can be invoked multiple times.
738
772
  The above RBAC permission can then be per namespace RBAC Role permissions.
739
773
 
774
+ ### Secret Packages
775
+
740
776
  Secrets can also be used in an identical manner as ConfigMaps by enabling the
741
777
  `--packages-secrets` command line option. Secrets permissions need to be
742
- added to the above RBAC configuration.
778
+ added to the above RBAC configuration. Secret based python packages also enable
779
+ provisioning files with binary data.
780
+
781
+ ### EnvironmentConfig Packages
782
+
783
+ EnvironmentConfig based provisioning enable an entire package and module
784
+ directory structure. Use the `--packages-environmentconfigs` command line option
785
+ and configure the ClusterRole RBAC access.
786
+ ```yaml
787
+ apiVersion: apiextensions.crossplane.io/v1beta1
788
+ kind: EnvironmentConfig
789
+ metadata:
790
+ name: test
791
+ labels:
792
+ function-pythonic.package: 'true'
793
+ data:
794
+ arootpackage:
795
+ asubpackage:
796
+ bmodule.py: |
797
+ def hello(where):
798
+ return f"Hello, {where}!"
799
+ amodule.py: |
800
+ def goodby(where):
801
+ return f"Goodby, {where}!"
802
+ ```
803
+ ### Composition Packages
804
+
805
+ Composition based provisioning works just like EnvironmentConfig where a
806
+ directory structure is created. Use the `--packages-compositions` command line option
807
+ and configure the ClusterRole RBAC access. The main reason to use Composition
808
+ based provision is because Compositions can be included in a Crossplane
809
+ Configuration Package.
810
+ ```yaml
811
+ apiVersion: apiextensions.crossplane.io/v1
812
+ kind: Composition
813
+ metadata:
814
+ labels:
815
+ function-pythonic.package: 'true'
816
+ name: test
817
+ spec:
818
+ compositeTypeRef:
819
+ apiVersion: code.pythoni.com/v1alpha1
820
+ kind: Code
821
+ mode: Pipeline
822
+ pipeline:
823
+ - step: render
824
+ functionRef:
825
+ name: function-pythonic
826
+ input:
827
+ apiVersion: pythonic.fn.crossplane.io/v1alpha1
828
+ kind: Composite
829
+ packages:
830
+ arootpackage:
831
+ asubpackage:
832
+ bmodule.py: |
833
+ def hello(where):
834
+ return f"Hello, {where}!"
835
+ amodule.py: |
836
+ def goodby(where):
837
+ return f"Goodby, {where}!"
838
+ ```
743
839
 
744
840
  ## Step Parameters
745
841
 
@@ -0,0 +1,18 @@
1
+ crossplane/pythonic/__about__.py,sha256=v3R2QlL6bK_t2oKW7b96iHro1p7Iag8djZQbp6xQLPE,73
2
+ crossplane/pythonic/__init__.py,sha256=cRk12kc18RLhc9s5dW6sQqZSCEp80fooRO5zxXqc1oA,292
3
+ crossplane/pythonic/__main__.py,sha256=6vYRlYDJtqFgLyiTamnl3htiNOtz8QlDl5WlIP98I8o,31
4
+ crossplane/pythonic/auto_ready.py,sha256=sPetUuJRhwZbg9muaDmbdqmtTIIUDmY4qoadoJA0EtQ,7201
5
+ crossplane/pythonic/command.py,sha256=aT58WBrhU_scaOGeqmsBfofIDnXyW1CQOpCktVGBj5s,4211
6
+ crossplane/pythonic/composite.py,sha256=gQ7DzY0kn8J2lUWH9hYVLZB6NYDBo0IJJwUDBooTNsc,34459
7
+ crossplane/pythonic/function.py,sha256=2f8-J5sgDrtn3z6Fxu2JODnr6VsjBESIVSJ5Hd4LssY,18573
8
+ crossplane/pythonic/grpc.py,sha256=WFyxkIh-nNtOswQhtN1V3e-8Qr79fDcNht8OpvihHYM,5694
9
+ crossplane/pythonic/main.py,sha256=ujUa_FYElQSGqnhZ-0NJrD3kSyYjfRbIp79FV2Yl7hs,599
10
+ crossplane/pythonic/packages.py,sha256=cOlF-wo2CzknwFPAfuwgCiwdT5vDE4n7oBMfQErVzbA,7023
11
+ crossplane/pythonic/protobuf.py,sha256=-HFHlGV1zMi5DmTOxe9qcBMJ0bZKH2hEkJH1CF7nvp4,53128
12
+ crossplane/pythonic/render.py,sha256=qIcUsE6iCka6VwUlhpN1BZhHvbl3GL6X3dPepmdlDV8,33822
13
+ crossplane/pythonic/version.py,sha256=-RiB0p146ayqJj0SXfYxTNv49u9Fx9pPgm59Ji2blhc,214
14
+ crossplane_function_pythonic-0.6.0.dist-info/METADATA,sha256=kaKnXE14jueu5X-kf8k2H904NIg9qV-o4WGOJOaUqqs,36678
15
+ crossplane_function_pythonic-0.6.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
16
+ crossplane_function_pythonic-0.6.0.dist-info/entry_points.txt,sha256=jJ4baywFDviB9WyAhyhNYF2VOCb6XtbRSjKf7bnBwhg,68
17
+ crossplane_function_pythonic-0.6.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
18
+ crossplane_function_pythonic-0.6.0.dist-info/RECORD,,
@@ -1,18 +0,0 @@
1
- crossplane/pythonic/__about__.py,sha256=S3lIpABQ5LwPMuO5TLfDrT_eJOQtFzsph4TXcfnx4OY,73
2
- crossplane/pythonic/__init__.py,sha256=cRk12kc18RLhc9s5dW6sQqZSCEp80fooRO5zxXqc1oA,292
3
- crossplane/pythonic/__main__.py,sha256=6vYRlYDJtqFgLyiTamnl3htiNOtz8QlDl5WlIP98I8o,31
4
- crossplane/pythonic/auto_ready.py,sha256=sPetUuJRhwZbg9muaDmbdqmtTIIUDmY4qoadoJA0EtQ,7201
5
- crossplane/pythonic/command.py,sha256=aT58WBrhU_scaOGeqmsBfofIDnXyW1CQOpCktVGBj5s,4211
6
- crossplane/pythonic/composite.py,sha256=niq-JSVZ8NB53Q7khkMqH9vQTJPb6yB-13O-wa2Is1U,30311
7
- crossplane/pythonic/function.py,sha256=CL2j_Br0eYWbn_8r8md9O9ErfHizz78H1KR8l2oV1IA,17964
8
- crossplane/pythonic/grpc.py,sha256=9ZQceboDju37NB6AhcUSWpBx_hZQ5W7uo7CZF6ynhfI,4451
9
- crossplane/pythonic/main.py,sha256=ujUa_FYElQSGqnhZ-0NJrD3kSyYjfRbIp79FV2Yl7hs,599
10
- crossplane/pythonic/packages.py,sha256=4TxyT6V79R0m4tJbC8R1gwU_vgHGLXKSBzeTTKd8xGo,5120
11
- crossplane/pythonic/protobuf.py,sha256=nmVf-Xn_-ER8BEfEbqd8uQo2gdhmNYyQh9QlhcaYebs,53083
12
- crossplane/pythonic/render.py,sha256=Y1-fdjQxvPBSkGjfJnNwCOOg6I-bX7Ys9X4jXkqIZp4,30140
13
- crossplane/pythonic/version.py,sha256=-RiB0p146ayqJj0SXfYxTNv49u9Fx9pPgm59Ji2blhc,214
14
- crossplane_function_pythonic-0.5.0.dist-info/METADATA,sha256=yMVu_bg-pjIrhuHDjeTAmKEa082V2pq5NroKIeo9poI,33133
15
- crossplane_function_pythonic-0.5.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
16
- crossplane_function_pythonic-0.5.0.dist-info/entry_points.txt,sha256=jJ4baywFDviB9WyAhyhNYF2VOCb6XtbRSjKf7bnBwhg,68
17
- crossplane_function_pythonic-0.5.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
18
- crossplane_function_pythonic-0.5.0.dist-info/RECORD,,