crossplane-function-pythonic 0.4.2__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.4.2"
2
+ __version__ = "0.6.0"
@@ -89,7 +89,7 @@ class Ready:
89
89
 
90
90
 
91
91
  class BaseComposite:
92
- def __init__(self, crossplane_v1, request, single_use, logger):
92
+ def __init__(self, crossplane_v1, request, logger):
93
93
  self.crossplane_v1 = crossplane_v1
94
94
  self.request = protobuf.Message(None, 'request', request.DESCRIPTOR, request, 'Function Request')
95
95
  response = fnv1.RunFunctionResponse(
@@ -104,14 +104,13 @@ class BaseComposite:
104
104
  )
105
105
  self.response = protobuf.Message(None, 'response', response.DESCRIPTOR, response)
106
106
  self.logger = logger
107
- if single_use:
108
- self.parameters = self.request.observed.composite.resource.spec.parameters
109
- else:
110
- self.parameters = self.request.input.parameters
107
+ self.capabilities = Capabilities(self.request.meta.capabilities)
108
+ self.parameters = self.request.input.parameters
111
109
  self.credentials = Credentials(self.request)
112
110
  self.context = self.response.context
113
111
  self.environment = self.context['apiextensions.crossplane.io/environment']
114
112
  self.requireds = Requireds(self)
113
+ self.schemas = Schemas(self)
115
114
  self.resources = Resources(self)
116
115
  self.autoReady = True
117
116
  self.usages = False
@@ -126,6 +125,7 @@ class BaseComposite:
126
125
  self.metadata = self.observed.metadata
127
126
  self.spec = self.observed.spec
128
127
  self.status = self.desired.status
128
+ self.output = self.response.output
129
129
  self.conditions = Conditions(observed, self.response)
130
130
  self.results = Results(self.response)
131
131
  self.events = Results(self.response) # Deprecated, use self.results
@@ -139,6 +139,30 @@ class BaseComposite:
139
139
  raise NotImplementedError()
140
140
 
141
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
+
142
166
  class Credentials:
143
167
  def __init__(self, request):
144
168
  self.__dict__['_request'] = request
@@ -561,6 +585,131 @@ class RequiredResource:
561
585
  return bool(self.observed)
562
586
 
563
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
+
564
713
  class Conditions:
565
714
  def __init__(self, observed, response=None):
566
715
  self._observed = observed
@@ -48,15 +48,13 @@ class FunctionRunner(grpcv1.FunctionRunnerService):
48
48
  name.append(composite['metadata']['name'])
49
49
  logger = logging.getLogger('.'.join(name))
50
50
 
51
- if composite['apiVersion'] in ('pythonic.crossplane.io/v1alpha1', 'pythonic.fortra.com/v1alpha1') and composite['kind'] == 'Composite':
52
- if 'spec' not in composite or 'composite' not in composite['spec']:
53
- return self.fatal(request, logger, 'Missing spec "composite"')
54
- single_use = True
55
- composite = composite['spec']['composite']
51
+ if 'inlined' in request.input and request.input['inlined']:
52
+ if 'spec' not in composite or request.input['inlined'] not in composite['spec']:
53
+ return self.fatal(request, logger, f"Missing inlined spec.{request.input['inlined']}")
54
+ composite = composite['spec'][request.input['inlined']]
56
55
  else:
57
56
  if 'composite' not in request.input:
58
57
  return self.fatal(request, logger, 'Missing input "composite"')
59
- single_use = False
60
58
  composite = request.input['composite']
61
59
 
62
60
  # Ideally this is something the Function API provides
@@ -100,7 +98,7 @@ class FunctionRunner(grpcv1.FunctionRunnerService):
100
98
  self.clazzes[composite] = clazz
101
99
 
102
100
  try:
103
- composite = clazz(self.crossplane_v1, request, single_use, logger)
101
+ composite = clazz(self.crossplane_v1, request, logger)
104
102
  except Exception as e:
105
103
  return self.fatal(request, logger, 'Instantiate', e)
106
104
 
@@ -123,8 +121,13 @@ class FunctionRunner(grpcv1.FunctionRunnerService):
123
121
  except Exception as e:
124
122
  return self.fatal(request, logger, 'Compose', e)
125
123
 
126
- if requireds := self.get_requireds(step, composite):
127
- 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)}")
128
131
  else:
129
132
  self.process_usages(composite)
130
133
  self.process_unknowns(composite)
@@ -157,11 +160,21 @@ class FunctionRunner(grpcv1.FunctionRunnerService):
157
160
  ]
158
161
  )
159
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
+
160
173
  def get_requireds(self, step, composite):
161
174
  requireds = []
162
175
  for name, required in composite.requireds:
163
- if len(required.apiVersion) and len(required.kind):
164
- 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)
165
178
  if len(required.namespace):
166
179
  r.namespace = required.namespace
167
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
@@ -796,8 +796,10 @@ class Value:
796
796
  def __contains__(self, item):
797
797
  match self._kind:
798
798
  case 'struct_value':
799
+ item = self._validate_key(item)
799
800
  return item in self._value.struct_value.fields or item in self._unknowns
800
801
  case 'Struct':
802
+ item = self._validate_key(item)
801
803
  return item in self._value.fields or item in self._unknowns
802
804
  case 'list_value' | 'ListValue':
803
805
  for value in self:
@@ -984,6 +986,8 @@ class Value:
984
986
  elif len(args):
985
987
  for key in range(len(args)):
986
988
  self[key] = args[key]
989
+ else:
990
+ self._ensure_map()
987
991
  return self
988
992
 
989
993
  def __setattr__(self, key, value):