crossplane-function-pythonic 0.0.9.post0__py3-none-any.whl → 0.0.11__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,5 +1,6 @@
1
1
 
2
2
  import datetime
3
+ from google.protobuf.duration_pb2 import Duration
3
4
  from crossplane.function.proto.v1 import run_function_pb2 as fnv1
4
5
 
5
6
  from . import protobuf
@@ -9,8 +10,18 @@ _notset = object()
9
10
 
10
11
 
11
12
  class BaseComposite:
12
- def __init__(self, request, response, logger):
13
+ def __init__(self, request, logger):
13
14
  self.request = protobuf.Message(None, 'request', request.DESCRIPTOR, request, 'Function Request')
15
+ response = fnv1.RunFunctionResponse(
16
+ meta=fnv1.ResponseMeta(
17
+ tag=request.meta.tag,
18
+ ttl=Duration(
19
+ seconds=60,
20
+ ),
21
+ ),
22
+ desired=request.desired,
23
+ context=request.context,
24
+ )
14
25
  self.response = protobuf.Message(None, 'response', response.DESCRIPTOR, response)
15
26
  self.logger = logger
16
27
  self.credentials = Credentials(self.request)
@@ -36,11 +47,23 @@ class BaseComposite:
36
47
 
37
48
  @property
38
49
  def ttl(self):
50
+ if self.response.meta.ttl.nanos:
51
+ return float(self.response.meta.ttl.seconds) + (float(self.response.meta.ttl.nanos) / 1000000000.0)
39
52
  return self.response.meta.ttl.seconds
40
53
 
41
54
  @ttl.setter
42
55
  def ttl(self, ttl):
43
- self.response.meta.ttl.seconds = ttl
56
+ if isinstance(ttl, int):
57
+ self.response.meta.ttl.seconds = ttl
58
+ self.response.meta.ttl.nanos = 0
59
+ elif isinstance(ttl, float):
60
+ self.response.meta.ttl.seconds = int(ttl)
61
+ if ttl.is_integer():
62
+ self.response.meta.ttl.nanos = 0
63
+ else:
64
+ self.response.meta.ttl.nanos = int((ttl - self.response.meta.ttl.seconds) * 1000000000)
65
+ else:
66
+ raise ValueError('ttl must be an int or float')
44
67
 
45
68
  @property
46
69
  def ready(self):
@@ -73,22 +96,46 @@ class Credentials:
73
96
  return self[key]
74
97
 
75
98
  def __getitem__(self, key):
76
- return self._request.credentials[key].credentials_data.data
99
+ return Credential(self._request.credentials[key])
77
100
 
78
101
  def __bool__(self):
79
- return bool(_request.credentials)
102
+ return bool(self._request.credentials)
80
103
 
81
104
  def __len__(self):
82
105
  return len(self._request.credentials)
83
106
 
84
107
  def __contains__(self, key):
85
- return key in _request.credentials
108
+ return key in self._request.credentials
86
109
 
87
110
  def __iter__(self):
88
111
  for key, resource in self._request.credentials:
89
112
  yield key, self[key]
90
113
 
91
114
 
115
+ class Credential:
116
+ def __init__(self, credential):
117
+ self.__dict__['_credential'] = credential
118
+
119
+ def __getattr__(self, key):
120
+ return self[key]
121
+
122
+ def __getitem__(self, key):
123
+ return self._credential.credential_data.data[key]
124
+
125
+ def __bool__(self):
126
+ return bool(self._credential.credential_data.data)
127
+
128
+ def __len__(self):
129
+ return len(self._credential.credential_data.data)
130
+
131
+ def __contains__(self, key):
132
+ return key in self._credential.credential_data.data
133
+
134
+ def __iter__(self):
135
+ for key, resource in self._credential.credential_data.data:
136
+ yield key, self[key]
137
+
138
+
92
139
  class Resources:
93
140
  def __init__(self, composite):
94
141
  self.__dict__['_composite'] = composite
@@ -587,7 +634,7 @@ class Events:
587
634
  def __getitem__(self, key):
588
635
  if key >= len(self._results):
589
636
  return Event()
590
- return Event(self._results[ix])
637
+ return Event(self._results[key])
591
638
 
592
639
  def __iter__(self):
593
640
  for ix in range(len(self._results)):
@@ -1,38 +1,26 @@
1
1
  """A Crossplane composition function."""
2
2
 
3
3
  import asyncio
4
- import base64
5
- import builtins
6
4
  import importlib
7
5
  import inspect
8
6
  import logging
9
7
  import sys
10
8
 
11
9
  import grpc
12
- import crossplane.function.response
13
10
  from crossplane.function.proto.v1 import run_function_pb2 as fnv1
14
11
  from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1
15
12
  from .. import pythonic
16
13
 
17
- builtins.BaseComposite = pythonic.BaseComposite
18
- builtins.append = pythonic.append
19
- builtins.Map = pythonic.Map
20
- builtins.List = pythonic.List
21
- builtins.Unknown = pythonic.Unknown
22
- builtins.Yaml = pythonic.Yaml
23
- builtins.Json = pythonic.Json
24
- builtins.B64Encode = pythonic.B64Encode
25
- builtins.B64Decode = pythonic.B64Decode
26
-
27
14
  logger = logging.getLogger(__name__)
28
15
 
29
16
 
30
17
  class FunctionRunner(grpcv1.FunctionRunnerService):
31
18
  """A FunctionRunner handles gRPC RunFunctionRequests."""
32
19
 
33
- def __init__(self, debug=False):
20
+ def __init__(self, debug=False, renderUnknowns=False):
34
21
  """Create a new FunctionRunner."""
35
22
  self.debug = debug
23
+ self.renderUnknowns = renderUnknowns
36
24
  self.clazzes = {}
37
25
 
38
26
  def invalidate_module(self, module):
@@ -46,9 +34,8 @@ class FunctionRunner(grpcv1.FunctionRunnerService):
46
34
  ) -> fnv1.RunFunctionResponse:
47
35
  try:
48
36
  return await self.run_function(request)
49
- except:
50
- logger.exception('Exception thrown in run fuction')
51
- raise
37
+ except Exception as e:
38
+ return self.fatal(request, logger, 'RunFunction', e)
52
39
 
53
40
  async def run_function(self, request):
54
41
  composite = request.observed.composite.resource
@@ -56,27 +43,22 @@ class FunctionRunner(grpcv1.FunctionRunnerService):
56
43
  name.append(composite['kind'])
57
44
  name.append(composite['metadata']['name'])
58
45
  logger = logging.getLogger('.'.join(name))
59
- if 'iteration' in request.context:
60
- request.context['iteration'] = request.context['iteration'] + 1
61
- else:
62
- request.context['iteration'] = 1
63
- logger.debug(f"Starting compose, {ordinal(request.context['iteration'])} pass")
64
-
65
- response = crossplane.function.response.to(request)
66
46
 
67
47
  if composite['apiVersion'] == 'pythonic.fortra.com/v1alpha1' and composite['kind'] == 'Composite':
68
- if 'composite' not in composite['spec']:
69
- logger.error('Missing spec "composite"')
70
- crossplane.function.response.fatal(response, 'Missing spec "composite"')
71
- return response
48
+ if 'spec' not in composite or 'composite' not in composite['spec']:
49
+ return self.fatal(request, logger, 'Missing spec "composite"')
72
50
  composite = composite['spec']['composite']
73
51
  else:
74
52
  if 'composite' not in request.input:
75
- logger.error('Missing input "composite"')
76
- crossplane.function.response.fatal(response, 'Missing input "composite"')
77
- return response
53
+ return self.fatal(request, logger, 'Missing input "composite"')
78
54
  composite = request.input['composite']
79
55
 
56
+ # Ideally this is something the Function API provides
57
+ if 'step' in request.input:
58
+ step = request.input['step']
59
+ else:
60
+ step = str(hash(composite))
61
+
80
62
  clazz = self.clazzes.get(composite)
81
63
  if not clazz:
82
64
  if '\n' in composite:
@@ -84,81 +66,67 @@ class FunctionRunner(grpcv1.FunctionRunnerService):
84
66
  try:
85
67
  exec(composite, module.__dict__)
86
68
  except Exception as e:
87
- logger.exception('Exec exception')
88
- crossplane.function.response.fatal(response, f"Exec exception: {e}")
89
- return response
69
+ return self.fatal(request, logger, 'Exec', e)
90
70
  for field in dir(module):
91
71
  value = getattr(module, field)
92
- if inspect.isclass(value) and issubclass(value, BaseComposite):
72
+ if inspect.isclass(value) and issubclass(value, pythonic.BaseComposite) and value != pythonic.BaseComposite:
93
73
  if clazz:
94
- logger.error('Composite script has multiple BaseComposite classes')
95
- crossplane.function.response.fatal(response, 'Composite script has multiple BaseComposite classes')
96
- return response
74
+ return self.fatal(request, logger, 'Composite script has multiple BaseComposite classes')
97
75
  clazz = value
98
76
  if not clazz:
99
- logger.error('Composite script does not have have a BaseComposite class')
100
- crossplane.function.response.fatal(response, 'Composite script does have have a BaseComposite class')
101
- return response
77
+ return self.fatal(request, logger, 'Composite script does not have a BaseComposite class')
102
78
  else:
103
79
  composite = composite.rsplit('.', 1)
104
80
  if len(composite) == 1:
105
- logger.error(f"Composite class name does not include module: {composite[0]}")
106
- crossplane.function.response.fatal(response, f"Composite class name does not include module: {composite[0]}")
107
- return response
81
+ return self.fatal(request, logger, f"Composite class name does not include module: {composite[0]}")
108
82
  try:
109
83
  module = importlib.import_module(composite[0])
110
84
  except Exception as e:
111
- logger.error(str(e))
112
- crossplane.function.response.fatal(response, f"Import module exception: {e}")
113
- return response
85
+ return self.fatal(request, logger, 'Import module', e)
114
86
  clazz = getattr(module, composite[1], None)
115
87
  if not clazz:
116
- logger.error(f"{composite[0]} did not define: {composite[1]}")
117
- crossplane.function.response.fatal(response, f"{composite[0]} did not define: {composite[1]}")
118
- return response
88
+ return self.fatal(request, logger, f"{composite[0]} does not define: {composite[1]}")
119
89
  composite = '.'.join(composite)
120
90
  if not inspect.isclass(clazz):
121
- logger.error(f"{composite} is not a class")
122
- crossplane.function.response.fatal(response, f"{composite} is not a class")
123
- return response
124
- if not issubclass(clazz, BaseComposite):
125
- logger.error(f"{composite} is not a subclass of BaseComposite")
126
- crossplane.function.response.fatal(response, f"{composite} is not a subclass of BaseComposite")
127
- return response
91
+ return self.fatal(request, logger, f"{composite} is not a class")
92
+ if not issubclass(clazz, pythonic.BaseComposite):
93
+ return self.fatal(request, logger, f"{composite} is not a subclass of BaseComposite")
128
94
  self.clazzes[composite] = clazz
129
95
 
130
96
  try:
131
- composite = clazz(request, response, logger)
97
+ composite = clazz(request, logger)
132
98
  except Exception as e:
133
- logger.exception('Instatiate exception')
134
- crossplane.function.response.fatal(response, f"Instatiate exception: {e}")
135
- return response
99
+ return self.fatal(request, logger, 'Instantiate', e)
100
+
101
+ step = composite.context._pythonic[step]
102
+ iteration = (step.iteration or 0) + 1
103
+ step.iteration = iteration
104
+ composite.context.iteration = iteration
105
+ logger.debug(f"Starting compose, {ordinal(len(composite.context._pythonic))} step, {ordinal(iteration)} pass")
136
106
 
137
107
  try:
138
108
  result = composite.compose()
139
109
  if asyncio.iscoroutine(result):
140
110
  await result
141
111
  except Exception as e:
142
- logger.exception('Compose exception')
143
- crossplane.function.response.fatal(response, f"Compose exception: {e}")
144
- return response
112
+ return self.fatal(request, logger, 'Compose', e)
145
113
 
146
114
  requested = []
147
115
  for name, required in composite.requireds:
148
116
  if required.apiVersion and required.kind:
149
- r = Map(apiVersion=required.apiVersion, kind=required.kind)
117
+ r = pythonic.Map(apiVersion=required.apiVersion, kind=required.kind)
150
118
  if required.namespace:
151
119
  r.namespace = required.namespace
152
120
  if required.matchName:
153
121
  r.matchName = required.matchName
154
122
  for key, value in required.matchLabels:
155
123
  r.matchLabels[key] = value
156
- if r != composite.context._requireds[name]:
157
- composite.context._requireds[name] = r
124
+ if r != step.requireds[name]:
125
+ step.requireds[name] = r
158
126
  requested.append(name)
159
127
  if requested:
160
128
  logger.info(f"Requireds requested: {','.join(requested)}")
161
- return response
129
+ return composite.response._message
162
130
 
163
131
  unknownResources = []
164
132
  warningResources = []
@@ -187,6 +155,8 @@ class FunctionRunner(grpcv1.FunctionRunnerService):
187
155
  logger.debug(f'Desired unknown: {destination} = {source}')
188
156
  if resource.observed:
189
157
  resource.desired._patchUnknowns(resource.observed)
158
+ elif self.renderUnknowns:
159
+ resource.desired._renderUnknowns(self.trimFullName)
190
160
  else:
191
161
  del composite.resources[name]
192
162
 
@@ -227,7 +197,29 @@ class FunctionRunner(grpcv1.FunctionRunnerService):
227
197
  resource.ready = True
228
198
 
229
199
  logger.info('Completed compose')
230
- return response
200
+ return composite.response._message
201
+
202
+ def fatal(self, request, logger, message, exception=None):
203
+ if exception:
204
+ message += ' exceptiion'
205
+ logger.exception(message)
206
+ m = str(exception)
207
+ if not m:
208
+ m = exception.__class__.__name__
209
+ message += ': ' + m
210
+ else:
211
+ logger.error(message)
212
+ return fnv1.RunFunctionResponse(
213
+ meta=fnv1.ResponseMeta(
214
+ tag=request.meta.tag,
215
+ ),
216
+ results=[
217
+ fnv1.Result(
218
+ severity=fnv1.SEVERITY_FATAL,
219
+ message=message,
220
+ )
221
+ ]
222
+ )
231
223
 
232
224
  def trimFullName(self, name):
233
225
  name = name.split('.')
@@ -236,10 +228,15 @@ class FunctionRunner(grpcv1.FunctionRunnerService):
236
228
  ('request', 'extra_resources', None, 'items', 'resource'),
237
229
  ('response', 'desired', 'resources', None, 'resource'),
238
230
  ):
239
- if len(values) <= len(name):
240
- for ix, value in enumerate(values):
241
- if value and value != name[ix] and not name[ix].startswith(f"{value}["):
242
- break
231
+ if len(values) < len(name):
232
+ ix = 0
233
+ for iv, value in enumerate(values):
234
+ if value:
235
+ if value != name[ix]:
236
+ if not name[ix].startswith(f"{values[iv]}[") or iv+1 >= len(values) or values[iv+1]:
237
+ break
238
+ continue
239
+ ix += 1
243
240
  else:
244
241
  ix = 0
245
242
  for value in values:
@@ -251,7 +248,6 @@ class FunctionRunner(grpcv1.FunctionRunnerService):
251
248
  del name[ix]
252
249
  else:
253
250
  name[ix] = name[ix][len(value):]
254
- ix += 1
255
251
  else:
256
252
  ix += 1
257
253
  break
@@ -268,4 +264,13 @@ def ordinal(ix):
268
264
 
269
265
 
270
266
  class Module:
271
- pass
267
+ def __init__(self):
268
+ self.BaseComposite = pythonic.BaseComposite
269
+ self.append = pythonic.append
270
+ self.Map = pythonic.Map
271
+ self.List = pythonic.List
272
+ self.Unknown = pythonic.Unknown
273
+ self.Yaml = pythonic.Yaml
274
+ self.Json = pythonic.Json
275
+ self.B64Encode = pythonic.B64Encode
276
+ self.B64Decode = pythonic.B64Decode
@@ -92,6 +92,11 @@ class Main:
92
92
  action='store_true',
93
93
  help='Allow oversized protobuf messages'
94
94
  )
95
+ parser.add_argument(
96
+ '--render-unknowns',
97
+ action='store_true',
98
+ help='Render resources with unknowns, useful during local develomment'
99
+ )
95
100
  args = parser.parse_args()
96
101
  if not args.tls_certs_dir and not args.insecure:
97
102
  print('Either --tls-certs-dir or --insecure must be specified', file=sys.stderr)
@@ -117,7 +122,7 @@ class Main:
117
122
  api_implementation._c_module.SetAllowOversizeProtos(True)
118
123
 
119
124
  grpc.aio.init_grpc_aio()
120
- grpc_runner = function.FunctionRunner(args.debug)
125
+ grpc_runner = function.FunctionRunner(args.debug, args.render_unknowns)
121
126
  grpc_server = grpc.aio.server()
122
127
  grpcv1.add_FunctionRunnerServiceServicer_to_server(grpc_runner, grpc_server)
123
128
  if args.insecure:
@@ -1,6 +1,5 @@
1
1
 
2
2
  import base64
3
- import importlib
4
3
  import logging
5
4
  import pathlib
6
5
  import sys
@@ -10,8 +9,8 @@ import kopf
10
9
 
11
10
  GRPC_SERVER = None
12
11
  GRPC_RUNNER = None
13
- PACKAGE_LABEL = {'function-pythonic.package': kopf.PRESENT}
14
12
  PACKAGES_DIR = None
13
+ PACKAGE_LABEL = {'function-pythonic.package': kopf.PRESENT}
15
14
 
16
15
 
17
16
  def operator(grpc_server, grpc_runner, packages_secrets, packages_namespaces, packages_dir):
@@ -46,95 +45,43 @@ async def cleanup(**_):
46
45
  @kopf.on.create('', 'v1', 'configmaps', labels=PACKAGE_LABEL)
47
46
  @kopf.on.resume('', 'v1', 'configmaps', labels=PACKAGE_LABEL)
48
47
  async def create(body, logger, **_):
49
- package_dir, package = get_package_dir(body)
48
+ package_dir = get_package_dir(body, logger)
50
49
  if package_dir:
51
- package_dir.mkdir(parents=True, exist_ok=True)
52
50
  secret = body['kind'] == 'Secret'
53
- invalidate = False
54
51
  for name, text in body.get('data', {}).items():
55
- package_file = package_dir / name
56
- if secret:
57
- package_file.write_bytes(base64.b64decode(text.encode('utf-8')))
58
- else:
59
- package_file.write_text(text)
60
- if package_file.suffixes == ['.py']:
61
- module = '.'.join(package + [package_file.stem])
62
- GRPC_RUNNER.invalidate_module(module)
63
- logger.info(f"Created module: {module}")
64
- else:
65
- logger.info(f"Created file: {'/'.join(package + [name])}")
52
+ package_file_write(package_dir, name, secret, text, 'Created', logger)
66
53
 
67
54
 
68
55
  @kopf.on.update('', 'v1', 'configmaps', labels=PACKAGE_LABEL)
69
56
  async def update(body, old, logger, **_):
70
- old_package_dir, old_package = get_package_dir(old)
57
+ old_package_dir = get_package_dir(old)
71
58
  if old_package_dir:
72
59
  old_data = old.get('data', {})
73
60
  else:
74
61
  old_data = {}
75
62
  old_names = set(old_data.keys())
76
- package_dir, package = get_package_dir(body, logger)
63
+ package_dir = get_package_dir(body, logger)
77
64
  if package_dir:
78
- package_dir.mkdir(parents=True, exist_ok=True)
79
65
  secret = body['kind'] == 'Secret'
80
66
  for name, text in body.get('data', {}).items():
81
- package_file = package_dir / name
82
67
  if package_dir == old_package_dir and text == old_data.get(name, None):
83
68
  action = 'Unchanged'
84
69
  else:
85
- if secret:
86
- package_file.write_bytes(base64.b64decode(text.encode('utf-8')))
87
- else:
88
- package_file.write_text(text)
89
70
  action = 'Updated' if package_dir == old_package_dir and name in old_names else 'Created'
90
- if package_file.suffixes == ['.py']:
91
- module = '.'.join(package + [package_file.stem])
92
- if action != 'Unchanged':
93
- GRPC_RUNNER.invalidate_module(module)
94
- logger.info(f"{action} module: {module}")
95
- else:
96
- logger.info(f"{action} file: {'/'.join(package + [name])}")
71
+ package_file_write(package_dir, name, secret, text, action, logger)
97
72
  if package_dir == old_package_dir:
98
73
  old_names.discard(name)
99
74
  if old_package_dir:
100
75
  for name in old_names:
101
- package_file = old_package_dir / name
102
- package_file.unlink(missing_ok=True)
103
- if package_file.suffixes == ['.py']:
104
- module = '.'.join(old_package + [package_file.stem])
105
- GRPC_RUNNER.invalidate_module(module)
106
- logger.info(f"Removed module: {module}")
107
- else:
108
- logger.info(f"Removed file: {'/'.join(old_package + [name])}")
109
- while old_package and old_package_dir.is_dir() and not list(old_package_dir.iterdir()):
110
- old_package_dir.rmdir()
111
- module = '.'.join(old_package)
112
- GRPC_RUNNER.invalidate_module(module)
113
- logger.info(f"Removed package: {module}")
114
- old_package_dir = old_package_dir.parent
115
- old_package.pop()
76
+ package_file_unlink(old_package_dir, name, 'Removed', logger)
116
77
 
117
78
 
118
79
  @kopf.on.delete('', 'v1', 'configmaps', labels=PACKAGE_LABEL)
119
80
  async def delete(old, logger, **_):
120
- package_dir, package = get_package_dir(old)
81
+ package_dir = get_package_dir(old)
121
82
  if package_dir:
122
83
  for name in old.get('data', {}).keys():
123
- package_file = package_dir / name
124
- package_file.unlink(missing_ok=True)
125
- if package_file.suffixes == ['.py']:
126
- module = '.'.join(package + [package_file.stem])
127
- GRPC_RUNNER.invalidate_module(module)
128
- logger.info(f"Deleted module: {module}")
129
- else:
130
- logger.info(f"Deleted file: {'/'.join(package + [name])}")
131
- while package and package_dir.is_dir() and not list(package_dir.iterdir()):
132
- package_dir.rmdir()
133
- module = '.'.join(package)
134
- GRPC_RUNNER.invalidate_module(module)
135
- logger.info(f"Deleted package: {module}")
136
- package_dir = package_dir.parent
137
- package.pop()
84
+ package_file_unlink(package_dir, name, 'Deleted', logger)
138
85
 
139
86
 
140
87
  def get_package_dir(body, logger=None):
@@ -142,16 +89,60 @@ def get_package_dir(body, logger=None):
142
89
  if package is None:
143
90
  if logger:
144
91
  logger.error('function-pythonic.package label is missing')
145
- return None, None
92
+ return None
146
93
  package_dir = PACKAGES_DIR
147
- if package == '':
148
- package = []
149
- else:
150
- package = package.split('.')
151
- for segment in package:
94
+ if package:
95
+ for segment in package.split('.'):
152
96
  if not segment.isidentifier():
153
97
  if logger:
154
98
  logger.error('Package has invalid package name: %s', package)
155
- return None, None
99
+ return None
156
100
  package_dir = package_dir / segment
157
- return package_dir, package
101
+ return package_dir
102
+
103
+
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))
146
+ if name.endswith('.py'):
147
+ return True, name[:-3].replace('/', '.')
148
+ return False, name
@@ -83,16 +83,16 @@ class Message:
83
83
  value = None
84
84
  if value is None and field.has_default_value:
85
85
  value = field.default_value
86
- if field.type == field.TYPE_MESSAGE:
86
+ if field.label == field.LABEL_REPEATED:
87
+ if field.type == field.TYPE_MESSAGE and field.message_type.GetOptions().map_entry:
88
+ value = MapMessage(self, key, field.message_type.fields_by_name['value'], value, self._readOnly)
89
+ else:
90
+ value = RepeatedMessage(self, key, field, value, self._readOnly)
91
+ elif field.type == field.TYPE_MESSAGE:
87
92
  if field.message_type.name == 'Struct':
88
93
  value = Values(self, key, value, Values.Type.MAP, self._readOnly)
89
94
  elif field.message_type.name == 'ListValue':
90
95
  value = Values(self, key, value, Values.Type.LIST, self._readOnly)
91
- elif field.label == field.LABEL_REPEATED:
92
- if field.message_type.GetOptions().map_entry:
93
- value = MapMessage(self, key, field.message_type, value, self._readOnly)
94
- else:
95
- value = RepeatedMessage(self, key, field.message_type, value, self._readOnly)
96
96
  else:
97
97
  value = Message(self, key, field.message_type, value, self._readOnly)
98
98
  self._cache[key] = value
@@ -147,10 +147,10 @@ class Message:
147
147
  else:
148
148
  name = str(self._key)
149
149
  if key is not None:
150
- if key.isidentifier():
151
- name += f".{key}"
150
+ if '.' in key:
151
+ name += f"[{key}]"
152
152
  else:
153
- name += f"['{key}']"
153
+ name += f".{key}"
154
154
  return name
155
155
  if key is not None:
156
156
  return str(key)
@@ -202,15 +202,15 @@ class Message:
202
202
  if key not in self._descriptor.fields_by_name:
203
203
  raise AttributeError(obj=self, name=key)
204
204
  if self._message is not None:
205
- del self._message[key]
205
+ self._message.ClearField(key)
206
206
  self._cache.pop(key, None)
207
207
 
208
208
 
209
209
  class MapMessage:
210
- def __init__(self, parent, key, descriptor, messages, readOnly=False):
210
+ def __init__(self, parent, key, field, messages, readOnly=False):
211
211
  self.__dict__['_parent'] = parent
212
212
  self.__dict__['_key'] = key
213
- self.__dict__['_field'] = descriptor.fields_by_name['value']
213
+ self.__dict__['_field'] = field
214
214
  self.__dict__['_messages'] = messages
215
215
  self.__dict__['_readOnly'] = readOnly
216
216
  self.__dict__['_cache'] = {}
@@ -232,11 +232,6 @@ class MapMessage:
232
232
  value = Values(self, key, value, Values.Type.MAP, self._readOnly)
233
233
  elif self._field.message_type.name == 'ListValue':
234
234
  value = Values(self, key, value, Values.Type.LIST, self._readOnly)
235
- elif self._field.label == self._field.LABEL_REPEATED:
236
- if self._field.message_type.GetOptions().map_entry:
237
- value = MapMessage(self, key, self._field.message_type, value, self._readOnly)
238
- else:
239
- value = RepeatedMessage(self, key, self._field.message_type, value, self._readOnly)
240
235
  else:
241
236
  value = Message(self, key, self._field.message_type, value, self._readOnly)
242
237
  elif self._field.type == self._field.TYPE_BYTES and isinstance(value, bytes):
@@ -266,8 +261,11 @@ class MapMessage:
266
261
  def __eq__(self, other):
267
262
  if not isinstance(other, MapMessage):
268
263
  return False
269
- if self._descriptor.full_name != other._descriptor.full_name:
264
+ if self._field.type != other._field.type:
270
265
  return False
266
+ if self._field.type == self._field.TYPE_MESSAGE:
267
+ if self._field.message_type.full_name != other._field.message_type.full_name:
268
+ return False
271
269
  if self._messages is None:
272
270
  return other._messages is None
273
271
  elif other._messages is None:
@@ -294,10 +292,10 @@ class MapMessage:
294
292
  else:
295
293
  name = str(self._key)
296
294
  if key is not None:
297
- if key.isidentifier():
298
- name += f".{key}"
295
+ if '.' in key:
296
+ name += f"[{key}]"
299
297
  else:
300
- name += f"['{key}']"
298
+ name += f".{key}"
301
299
  return name
302
300
  if key is not None:
303
301
  return str(key)
@@ -349,10 +347,10 @@ class MapMessage:
349
347
 
350
348
 
351
349
  class RepeatedMessage:
352
- def __init__(self, parent, key, descriptor, messages, readOnly=False):
350
+ def __init__(self, parent, key, field, messages, readOnly=False):
353
351
  self._parent = parent
354
352
  self._key = key
355
- self._descriptor = descriptor
353
+ self._field = field
356
354
  self._messages = messages
357
355
  self._readOnly = readOnly
358
356
  self._cache = {}
@@ -361,10 +359,20 @@ class RepeatedMessage:
361
359
  if key in self._cache:
362
360
  return self._cache[key]
363
361
  if self._messages is None or key >= len(self._messages):
364
- message = None
362
+ value = None
365
363
  else:
366
- message = self._messages[key]
367
- value = Message(self, key, self._descriptor, message, self._readOnly)
364
+ value = self._messages[key]
365
+ if value is None and self._field.has_default_value:
366
+ value = self._field.default_value
367
+ if self._field.type == self._field.TYPE_MESSAGE:
368
+ if self._field.message_type.name == 'Struct':
369
+ value = Values(self, key, value, Values.Type.MAP, self._readOnly)
370
+ elif self._field.message_type.name == 'ListValue':
371
+ value = Values(self, key, value, Values.Type.LIST, self._readOnly)
372
+ else:
373
+ value = Message(self, key, self._field.message_type, value, self._readOnly)
374
+ elif self._field.type == self._field.TYPE_BYTES and isinstance(value, bytes):
375
+ value = value.decode('utf-8')
368
376
  self._cache[key] = value
369
377
  return value
370
378
 
@@ -394,8 +402,11 @@ class RepeatedMessage:
394
402
  def __eq__(self, other):
395
403
  if not isinstance(other, RepeatedMessage):
396
404
  return False
397
- if self._descriptor.full_name != other._descriptor.full_name:
405
+ if self._field.type != other._field.type:
398
406
  return False
407
+ if self._field.type == self._field.TYPE_MESSAGE:
408
+ if self._field.message_type.full_name != other._field.message_type.full_name:
409
+ return False
399
410
  if self._messages is None:
400
411
  return other._messages is None
401
412
  elif other._messages is None:
@@ -440,7 +451,7 @@ class RepeatedMessage:
440
451
  raise ValueError(f"{self._readOnly} is read only")
441
452
  if self._messages is None:
442
453
  self.__dict__['_messages'] = self._parent._create_child(self._key)
443
- self._messages.Clear()
454
+ self._messages.clear()
444
455
  self._cache.clear()
445
456
  for arg in args:
446
457
  self.append(arg)
@@ -451,22 +462,23 @@ class RepeatedMessage:
451
462
  raise ValueError(f"{self._readOnly} is read only")
452
463
  if self._messages is None:
453
464
  self._messages = self._parent._create_child(self._key)
454
- if key == append:
455
- key = len(self._messages)
456
- elif key < 0:
465
+ if key < 0:
457
466
  key = len(self._messages) + key
458
- while key >= len(self._messages):
459
- self._messages.add()
460
467
  if isinstance(message, Message):
461
468
  message = message._message
462
- self._messages[key] = message
469
+ if self._field.type == self._field.TYPE_BYTES and isinstance(message, str):
470
+ message = message.encode('utf-8')
471
+ if key >= len(self._messages):
472
+ self._messages.append(message)
473
+ else:
474
+ self._messages[key] = message
463
475
  self._cache.pop(key, None)
464
476
 
465
477
  def __delitem__(self, key):
466
478
  if self._readOnly:
467
479
  raise ValueError(f"{self._readOnly} is read only")
468
- if self._values is not None:
469
- del self._values[key]
480
+ if self._messages is not None:
481
+ del self._messages[key]
470
482
  self._cache.pop(key, None)
471
483
 
472
484
  def append(self, message=None):
@@ -513,7 +525,7 @@ class Values:
513
525
  if isinstance(key, str):
514
526
  if not self._isMap:
515
527
  if not self._isUnknown:
516
- raise ValueError(f"Invalid key, must be a str for maps: {key}")
528
+ raise ValueError(f"Invalid key, must be a int for lists: {key}")
517
529
  self.__dict__['_type'] = self.Type.MAP
518
530
  if self._values is None or key not in self._values:
519
531
  struct_value = None
@@ -522,7 +534,7 @@ class Values:
522
534
  elif isinstance(key, int):
523
535
  if not self._isList:
524
536
  if not self._isUnknown:
525
- raise ValueError(f"Invalid key, must be an int for lists: {key}")
537
+ raise ValueError(f"Invalid key, must be a str for maps: {key}")
526
538
  self.__dict__['_type'] = self.Type.LIST
527
539
  if self._values is None or key >= len(self._values):
528
540
  struct_value = None
@@ -638,10 +650,10 @@ class Values:
638
650
  if isinstance(key, int):
639
651
  name += f"[{key}]"
640
652
  else:
641
- if key.isidentifier():
642
- name += f".{key}"
653
+ if '.' in key:
654
+ name += f"[{key}]"
643
655
  else:
644
- name += f"['{key}']"
656
+ name += f".{key}"
645
657
  return name
646
658
  if key is not None:
647
659
  return str(key)
@@ -849,7 +861,7 @@ class Values:
849
861
  return unknowns
850
862
 
851
863
  def _patchUnknowns(self, patches):
852
- for key in [key for key in self._unknowns.keys()]:
864
+ for key in list(self._unknowns.keys()):
853
865
  self[key] = patches[key]
854
866
  if self._isMap:
855
867
  for key, value in self:
@@ -864,6 +876,18 @@ class Values:
864
876
  if isinstance(patch, Values) and patch._type == value._type and len(patch):
865
877
  value._patchUnknowns(patch)
866
878
 
879
+ def _renderUnknowns(self, trimFullName):
880
+ for key, unknown in list(self._unknowns.items()):
881
+ self[key] = f"UNKNOWN:{trimFullName(unknown._fullName())}"
882
+ if self._isMap:
883
+ for key, value in self:
884
+ if isinstance(value, Values) and len(value):
885
+ value._renderUnknowns(trimFullName)
886
+ elif self._isList:
887
+ for ix, value in enumerate(self):
888
+ if isinstance(value, Values) and len(value):
889
+ value._renderUnknowns(trimFullName)
890
+
867
891
 
868
892
  def _formatObject(object, spec):
869
893
  if spec == 'json':
@@ -901,7 +925,7 @@ class _JSONEncoder(json.JSONEncoder):
901
925
  return '<<UNEXPECTED>>'
902
926
  if isinstance(object, datetime.datetime):
903
927
  return object.isoformat()
904
- return super(JSONEncoder, self).default(object)
928
+ return super(_JSONEncoder, self).default(object)
905
929
 
906
930
 
907
931
  class _Dumper(yaml.SafeDumper):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crossplane-function-pythonic
3
- Version: 0.0.9.post0
3
+ Version: 0.0.11
4
4
  Summary: A Python centric Crossplane Function
5
5
  Project-URL: Documentation, https://github.com/fortra/function-pythonic#readme
6
6
  Project-URL: Issues, https://github.com/fortra/function-pythonic/issues
@@ -81,7 +81,7 @@ kind: Function
81
81
  metadata:
82
82
  name: function-pythonic
83
83
  spec:
84
- package: ghcr.io/fortra/function-pythonic:v0.0.9
84
+ package: ghcr.io/fortra/function-pythonic:v0.0.10
85
85
  ```
86
86
  ## Composed Resource Dependencies
87
87
 
@@ -386,11 +386,11 @@ metadata:
386
386
  annotations:
387
387
  render.crossplane.io/runtime: Development
388
388
  spec:
389
- package: ghcr.io/fortra/function-pythonic:v0.0.9
389
+ package: ghcr.io/fortra/function-pythonic:v0.0.10
390
390
  ```
391
391
  In one terminal session, run function-pythonic:
392
392
  ```shell
393
- $ function-pythonic --insecure --debug
393
+ $ function-pythonic --insecure --debug --render-unknowns
394
394
  [2025-08-21 15:32:37.966] grpc._cython.cygrpc [DEBUG ] Using AsyncIOEngine.POLLER as I/O engine
395
395
  ```
396
396
  In another terminal session, render the Composite:
@@ -488,7 +488,7 @@ kind: Function
488
488
  metadata:
489
489
  name: function-pythonic
490
490
  spec:
491
- package: ghcr.io/fortra/function-pythonic:v0.0.9
491
+ package: ghcr.io/fortra/function-pythonic:v0.0.10
492
492
  runtimeConfigRef:
493
493
  name: function-pythonic
494
494
  ---
@@ -0,0 +1,11 @@
1
+ crossplane/pythonic/__init__.py,sha256=9Oz3mvFO-8GXb75iEfybSHgVr3p8INdqA201tCusuSo,408
2
+ crossplane/pythonic/composite.py,sha256=a_TVaz4ABRvsyhEEnMpZKFtSrH1mYWmqAn8IXGUtgrc,21519
3
+ crossplane/pythonic/function.py,sha256=8B-chQmuBSSbX3ptOIuE2Tz5KAZ7M5U4-q6wf0Qlwf0,11139
4
+ crossplane/pythonic/main.py,sha256=HILfW6WP-QvOiyfLLu41bAN_2hbkxuw-3DD8rEUMTPQ,6893
5
+ crossplane/pythonic/packages.py,sha256=4TxyT6V79R0m4tJbC8R1gwU_vgHGLXKSBzeTTKd8xGo,5120
6
+ crossplane/pythonic/protobuf.py,sha256=qkzhrXWsaqWHnciKiPavJNcq-qSLcFEoKw-tEJj2Ou4,35037
7
+ crossplane_function_pythonic-0.0.11.dist-info/METADATA,sha256=OKe1qRnvorxI0qVHWxmIJxnEHTPMxW9tK7mZ7H8Z6DA,23468
8
+ crossplane_function_pythonic-0.0.11.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ crossplane_function_pythonic-0.0.11.dist-info/entry_points.txt,sha256=jJ4baywFDviB9WyAhyhNYF2VOCb6XtbRSjKf7bnBwhg,68
10
+ crossplane_function_pythonic-0.0.11.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
11
+ crossplane_function_pythonic-0.0.11.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- crossplane/pythonic/__init__.py,sha256=9Oz3mvFO-8GXb75iEfybSHgVr3p8INdqA201tCusuSo,408
2
- crossplane/pythonic/composite.py,sha256=TxloK31jx3xDmLnalntPXCHNnxuudevdeqPzKcSBO_I,19937
3
- crossplane/pythonic/function.py,sha256=95asJ_6dFy-KSXiMIkJkxqI264kSqBs4Vh61RAojqSY,11336
4
- crossplane/pythonic/main.py,sha256=kcpoR4F84IhxLzaPSWWdIoaXmrUyjXofvwQuenVPHSE,6683
5
- crossplane/pythonic/packages.py,sha256=quxAkmioIGJr9g4uRHsqPwhzyu2f2_UyNHHQmZjSJ8A,6108
6
- crossplane/pythonic/protobuf.py,sha256=ULcaqeyeqCaz0SSSZXNpeUPh1EQLdAV09Dwj3ltIx7k,33899
7
- crossplane_function_pythonic-0.0.9.post0.dist-info/METADATA,sha256=FhRLdxg9QMegatxFTBkSNROk93V-G-B1EfWksEBxf3w,23452
8
- crossplane_function_pythonic-0.0.9.post0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
- crossplane_function_pythonic-0.0.9.post0.dist-info/entry_points.txt,sha256=jJ4baywFDviB9WyAhyhNYF2VOCb6XtbRSjKf7bnBwhg,68
10
- crossplane_function_pythonic-0.0.9.post0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
11
- crossplane_function_pythonic-0.0.9.post0.dist-info/RECORD,,