ob-metaflow-extensions 1.1.175rc4__py2.py3-none-any.whl → 1.2.0__py2.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.

Potentially problematic release.


This version of ob-metaflow-extensions might be problematic. Click here for more details.

@@ -744,14 +744,6 @@ def list(ctx, project, branch, name, tags, format, auth_type):
744
744
  print_table(table_data, headers)
745
745
 
746
746
 
747
- @app.command()
748
- @auto_cli_options()
749
- @click.pass_context
750
- @click.argument("command", nargs=-1, type=click.UNPROCESSED, required=False)
751
- def deploy2(ctx, **kwargs):
752
- pass
753
-
754
-
755
747
  @app.command(help="Delete an app/apps from the Outerbounds Platform.")
756
748
  @click.option("--name", type=str, help="Filter app to delete by name")
757
749
  @click.option("--id", "cap_id", type=str, help="Filter app to delete by id")
@@ -267,10 +267,13 @@ class ConfigField:
267
267
  self._qual_name_stack = []
268
268
 
269
269
  # This function allows config fields to be made aware of the
270
- # owner instance's names. Its via in the ConfigMeta classes'
271
- # _set_owner_instance function. But the _set_owner_instance gets
272
- # called within the ConfigField's __set__ function
273
- # (when the actual instance of the value is being set)
270
+ # owner instance's names. It's called from the `commit_owner_names_across_tree`
271
+ # Decorator. Its called once the config instance is completely ready and
272
+ # it wil not have any further runtime instance modifications done to it.
273
+ # The core intent is to ensure that the full config lineage tree is captured and
274
+ # we have a full trace of where the config is coming from so that we can showcase it
275
+ # to users when they make configurational errors. It also allows us to reference those
276
+ # config values in the error messages across different types of errors.
274
277
  def _set_owner_name(self, owner_name: str):
275
278
  self._qual_name_stack.append(owner_name)
276
279
 
@@ -288,7 +291,6 @@ class ConfigField:
288
291
  return instance.__dict__.get(self.name)
289
292
 
290
293
  def __set__(self, instance, value):
291
-
292
294
  if self.parsing_fn:
293
295
  value = self.parsing_fn(value)
294
296
 
@@ -299,12 +301,6 @@ class ConfigField:
299
301
  f"Value {value} is not of type {self.field_type} for the field {self.name}"
300
302
  )
301
303
 
302
- # We set the owner instance in the ConfigMeta based classes so they
303
- # propagate it down to the ConfigField based classes.
304
- if ConfigMeta.is_instance(value):
305
- for x in self._qual_name_stack + [self.name]:
306
- value._set_owner_instance(x)
307
-
308
304
  instance.__dict__[self.name] = value
309
305
 
310
306
  def __str__(self) -> str:
@@ -316,6 +312,55 @@ class ConfigField:
316
312
  return f"<ConfigField name='{self.name}' type={type_name} default={self.default!r}>"
317
313
 
318
314
 
315
+ # Add this decorator function before the ConfigMeta class
316
+ # One of the core utilities of the ConfigMeta class
317
+ # is that we can track the tree of elements in the Config
318
+ # class that allow us to make those visible at runtime when
319
+ # the user has some configurational error. it also allows us to
320
+ # Figure out what the user is trying to extactly configure and where
321
+ # that configuration is coming from.
322
+ # Hence this decorator is set on what ever configMeta class based function
323
+ # so that when it gets called, the full call tree is properly set.
324
+ def commit_owner_names_across_tree(func):
325
+ """
326
+ Decorator that commits owner names across the configuration tree before executing the decorated function.
327
+
328
+ This decorator ensures that all ConfigField instances in the configuration tree are aware of their
329
+ fully qualified names by traversing the tree and calling _set_owner_name on each field.
330
+ """
331
+
332
+ def wrapper(self, *args, **kwargs):
333
+ def _commit_owner_names_recursive(instance, field_name_stack=None):
334
+ if field_name_stack is None:
335
+ field_name_stack = []
336
+
337
+ if not ConfigMeta.is_instance(instance):
338
+ return
339
+
340
+ fields = instance._fields # type: ignore
341
+ # fields is a dictionary of field_name: ConfigField
342
+ for field_name, field_info in fields.items():
343
+ if ConfigMeta.is_instance(field_info.field_type):
344
+ # extract the actual instance of the ConfigMeta class
345
+ _instance = instance.__dict__[field_name]
346
+ # The instance should hold the _commit_owner_names_across_tree
347
+ _commit_owner_names_recursive(
348
+ _instance, field_name_stack + [field_name]
349
+ )
350
+ else:
351
+ if len(field_name_stack) > 0:
352
+ for x in field_name_stack:
353
+ field_info._set_owner_name(x) # type: ignore
354
+
355
+ # Commit owner names before executing the original function
356
+ _commit_owner_names_recursive(self)
357
+
358
+ # Execute the original function
359
+ return func(self, *args, **kwargs)
360
+
361
+ return wrapper
362
+
363
+
319
364
  class ConfigMeta(type):
320
365
  """Metaclass implementing the configuration system's class transformation layer.
321
366
 
@@ -395,10 +440,6 @@ class ConfigMeta(type):
395
440
  if isinstance(value, ConfigField):
396
441
  fields[key] = value
397
442
 
398
- def _set_owner_to_instance(self, instance_name: str):
399
- for field_name, field_info in fields.items(): # field_info is a ConfigField
400
- field_info._set_owner_name(instance_name)
401
-
402
443
  # Store fields metadata on the class
403
444
  namespace["_fields"] = fields
404
445
 
@@ -407,7 +448,6 @@ class ConfigMeta(type):
407
448
  return fields[field_name]
408
449
 
409
450
  namespace["_get_field"] = get_field
410
- namespace["_set_owner_instance"] = _set_owner_to_instance
411
451
 
412
452
  # Auto-generate __init__ method;
413
453
  # Override it for all classes.
@@ -5,7 +5,7 @@ This module provides a mechanism to dynamically generate explicit typed classes
5
5
  from ConfigMeta classes that IDEs can understand and provide autocomplete for.
6
6
  """
7
7
 
8
- from typing import Any, Dict, List, Optional, Union, Type
8
+ from typing import Any, Dict, List, Optional, Union, Type, Set
9
9
 
10
10
  from .config_utils import ConfigMeta
11
11
 
@@ -37,6 +37,45 @@ else:
37
37
  """
38
38
 
39
39
 
40
+ def collect_nested_configs_recursive(
41
+ config_class: Type, visited: Optional[Set[str]] = None
42
+ ) -> Dict[str, Type]:
43
+ """
44
+ Recursively collect all nested ConfigMeta classes from a config class.
45
+
46
+ Args:
47
+ config_class: A class that inherits from ConfigMeta
48
+ visited: Set of already visited class names to avoid infinite recursion
49
+
50
+ Returns:
51
+ Dictionary mapping class names to ConfigMeta classes
52
+ """
53
+ if visited is None:
54
+ visited = set()
55
+
56
+ nested_configs = {}
57
+
58
+ # Avoid infinite recursion by tracking visited classes
59
+ if config_class.__name__ in visited:
60
+ return nested_configs
61
+
62
+ visited.add(config_class.__name__)
63
+
64
+ # First pass: collect immediate nested configs
65
+ for field_name, field_info in config_class._fields.items():
66
+ if ConfigMeta.is_instance(field_info.field_type):
67
+ nested_class = field_info.field_type
68
+ nested_configs[nested_class.__name__] = nested_class
69
+
70
+ # Recursively collect nested configs from this nested class
71
+ deeper_nested = collect_nested_configs_recursive(
72
+ nested_class, visited.copy()
73
+ )
74
+ nested_configs.update(deeper_nested)
75
+
76
+ return nested_configs
77
+
78
+
40
79
  def generate_typed_class_code(config_class: Type) -> str:
41
80
  """
42
81
  Generate the actual Python code for a typed class that IDEs can understand.
@@ -52,22 +91,19 @@ def generate_typed_class_code(config_class: Type) -> str:
52
91
 
53
92
  class_name = f"Typed{config_class.__name__}"
54
93
 
55
- # Generate TypedDict for nested configs
94
+ # Generate TypedDict for nested configs - now recursive
56
95
  nested_typeddict_code = []
57
96
 
58
- # First pass: collect all nested configs
59
- nested_configs = {}
60
- for field_name, field_info in config_class._fields.items():
61
- if ConfigMeta.is_instance(field_info.field_type):
62
- nested_configs[field_info.field_type.__name__] = field_info.field_type
97
+ # Recursively collect all nested configs
98
+ nested_configs = collect_nested_configs_recursive(config_class)
63
99
 
64
- # Generate TypedDict classes for nested configs
100
+ # Generate TypedDict classes for all nested configs
65
101
  for nested_name, nested_class in nested_configs.items():
66
102
  dict_name = f"{nested_name}Dict"
67
103
  fields = []
68
104
 
69
105
  for field_name, field_info in nested_class._fields.items():
70
- field_type = _get_type_string(field_info.field_type)
106
+ field_type = _get_type_string(field_info.field_type, quote_config_meta=True)
71
107
  if not field_info.required:
72
108
  field_type = f"Optional[{field_type}]"
73
109
  fields.append(f" {field_name}: {field_type}")
@@ -142,8 +178,13 @@ def generate_typed_class_code(config_class: Type) -> str:
142
178
  return (newline + newline).join(full_code)
143
179
 
144
180
 
145
- def _get_type_string(field_type: Type) -> str:
146
- """Convert a type to its string representation for code generation."""
181
+ def _get_type_string(field_type: Type, quote_config_meta: bool = False) -> str:
182
+ """Convert a type to its string representation for code generation.
183
+
184
+ Args:
185
+ field_type: The type to convert
186
+ quote_config_meta: Whether to quote ConfigMeta type references for forward declarations
187
+ """
147
188
  if field_type == str:
148
189
  return "str"
149
190
  elif field_type == int:
@@ -152,6 +193,10 @@ def _get_type_string(field_type: Type) -> str:
152
193
  return "float"
153
194
  elif field_type == bool:
154
195
  return "bool"
196
+ elif ConfigMeta.is_instance(field_type):
197
+ # Handle ConfigMeta classes by referencing their Dict type
198
+ dict_type = f"{field_type.__name__}Dict"
199
+ return f'"{dict_type}"' if quote_config_meta else dict_type
155
200
  elif hasattr(field_type, "__origin__"):
156
201
  # Handle generic types like List[str], Dict[str, str], etc.
157
202
  origin = field_type.__origin__
@@ -159,18 +204,18 @@ def _get_type_string(field_type: Type) -> str:
159
204
 
160
205
  if origin == list:
161
206
  if args:
162
- return f"List[{_get_type_string(args[0])}]"
207
+ return f"List[{_get_type_string(args[0], quote_config_meta)}]"
163
208
  return "List[Any]"
164
209
  elif origin == dict:
165
210
  if len(args) == 2:
166
- return f"Dict[{_get_type_string(args[0])}, {_get_type_string(args[1])}]"
211
+ return f"Dict[{_get_type_string(args[0], quote_config_meta)}, {_get_type_string(args[1], quote_config_meta)}]"
167
212
  return "Dict[str, Any]"
168
213
  elif origin == Union:
169
214
  # Handle Optional types
170
215
  if len(args) == 2 and type(None) in args:
171
216
  non_none_type = args[0] if args[1] is type(None) else args[1]
172
- return f"Optional[{_get_type_string(non_none_type)}]"
173
- return f"Union[{', '.join(_get_type_string(arg) for arg in args)}]"
217
+ return f"Optional[{_get_type_string(non_none_type, quote_config_meta)}]"
218
+ return f"Union[{', '.join(_get_type_string(arg, quote_config_meta) for arg in args)}]"
174
219
 
175
220
  # Default case - use the type name
176
221
  return getattr(field_type, "__name__", str(field_type))
@@ -251,7 +296,7 @@ def create_typed_init_class_dynamic(config_class: Type) -> Type:
251
296
  def create_init_method():
252
297
  # Build the signature dynamically
253
298
  sig_params = []
254
- annotations = {"return": None}
299
+ annotations: Dict[str, Any] = {"return": type(None)}
255
300
 
256
301
  for field_name, field_info in config_class._fields.items():
257
302
  field_type = field_info.field_type
@@ -295,7 +340,7 @@ def create_typed_init_class_dynamic(config_class: Type) -> Type:
295
340
  __init__.__annotations__ = annotations
296
341
  return __init__
297
342
 
298
- def create_config(self) -> config_class:
343
+ def create_config(self):
299
344
  """Create and return the ConfigMeta class instance."""
300
345
  return config_class.from_dict(self._kwargs)
301
346
 
@@ -326,7 +371,7 @@ def create_typed_init_class_dynamic(config_class: Type) -> Type:
326
371
 
327
372
 
328
373
  # Auto-generate and write typed classes to a file
329
- def generate_typed_classes_file(output_file: str = None):
374
+ def generate_typed_classes_file(output_file: Optional[str] = None):
330
375
  """
331
376
  Generate typed classes and write them to a file for IDE support.
332
377
 
@@ -29,6 +29,7 @@ from .config_utils import (
29
29
  validate_config_meta,
30
30
  validate_required_fields,
31
31
  ConfigValidationFailedException,
32
+ commit_owner_names_across_tree,
32
33
  )
33
34
 
34
35
 
@@ -49,26 +50,26 @@ class UnitParser:
49
50
  "default_unit": "Mi",
50
51
  "requires_unit": True, # if a Unit free value is provided then we will add the default unit to it.
51
52
  # Regex to match values with units (e.g., "512Mi", "4Gi", "1024Ki")
52
- "unit_regex": r"^\d+(\.\d+)?(Ki|Mi|Gi|Ti|Pi|Ei)$",
53
+ "correct_unit_regex": r"^\d+(\.\d+)?(Ki|Mi|Gi|Ti|Pi|Ei)$",
53
54
  },
54
55
  "cpu": {
55
56
  "default_unit": None,
56
57
  "requires_unit": False, # if a Unit free value is provided then we will not add the default unit to it.
57
58
  # Accepts values like 400m, 4, 0.4, 1000n, etc.
58
59
  # Regex to match values with units (e.g., "400m", "1000n", "2", "0.5")
59
- "unit_regex": r"^(\d+(\.\d+)?(m|n)?|\d+(\.\d+)?)$",
60
+ "correct_unit_regex": r"^(\d+(\.\d+)?(m|n)?|\d+(\.\d+)?)$",
60
61
  },
61
62
  "disk": {
62
63
  "default_unit": "Mi",
63
64
  "requires_unit": True, # if a Unit free value is provided then we will add the default unit to it.
64
65
  # Regex to match values with units (e.g., "100Mi", "1Gi", "500Ki")
65
- "unit_regex": r"^\d+(\.\d+)?(Ki|Mi|Gi|Ti|Pi|Ei)$",
66
+ "correct_unit_regex": r"^\d+(\.\d+)?(Ki|Mi|Gi|Ti|Pi|Ei)$",
66
67
  },
67
68
  "gpu": {
68
69
  "default_unit": None,
69
70
  "requires_unit": False,
70
71
  # Regex to match values with units (usually just integer count, e.g., "1", "2")
71
- "unit_regex": r"^\d+$",
72
+ "correct_unit_regex": r"^\d+$",
72
73
  },
73
74
  }
74
75
 
@@ -76,10 +77,9 @@ class UnitParser:
76
77
  self.metric_name = metric_name
77
78
 
78
79
  def validate(self, value: str):
79
- if self.metrics[self.metric_name]["requires_unit"]:
80
- if not re.match(self.metrics[self.metric_name]["unit_regex"], value):
81
- return False
82
- return True
80
+ if re.match(self.metrics[self.metric_name]["correct_unit_regex"], value):
81
+ return True
82
+ return False
83
83
 
84
84
  def process(self, value: str):
85
85
  value = str(value)
@@ -92,12 +92,9 @@ class UnitParser:
92
92
  )
93
93
  return value
94
94
 
95
- if re.match(self.metrics[self.metric_name]["unit_regex"], value):
96
- return value
97
-
98
95
  return value
99
96
 
100
- def parse(self, value: str):
97
+ def parse(self, value: Union[str, None]):
101
98
  if value is None:
102
99
  return None
103
100
  return self.process(value)
@@ -117,7 +114,7 @@ class UnitParser:
117
114
  field_name=metric_name,
118
115
  field_info=field_info,
119
116
  current_value=value,
120
- message=f"Invalid value for `{metric_name}`. Must be of the format {parser.metrics[metric_name]['unit_regex']}.",
117
+ message=f"Invalid value for `{metric_name}`. Must be of the format {parser.metrics[metric_name]['correct_unit_regex']}.",
121
118
  )
122
119
  return validation
123
120
 
@@ -138,7 +135,7 @@ class BasicValidations:
138
135
  field_name=self.field_name,
139
136
  field_info=self._get_field(),
140
137
  current_value=current_value,
141
- message=f"Configuration field {self.field_name} has invalid value {current_value}. Value must be one of: {' '.join(enums)}",
138
+ message=f"Configuration field {self.field_name} has invalid value {current_value}. Value must be one of: {'/'.join(enums)}",
142
139
  )
143
140
  return True
144
141
 
@@ -934,6 +931,7 @@ How to read this schema:
934
931
  def validate(self):
935
932
  validate_config_meta(self)
936
933
 
934
+ @commit_owner_names_across_tree
937
935
  def commit(self):
938
936
  self.validate()
939
937
  validate_required_fields(self)
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ob-metaflow-extensions
3
- Version: 1.1.175rc4
3
+ Version: 1.2.0
4
4
  Summary: Outerbounds Platform Extensions for Metaflow
5
5
  Author: Outerbounds, Inc.
6
6
  License: Commercial
7
7
  Description-Content-Type: text/markdown
8
8
  Requires-Dist: boto3
9
9
  Requires-Dist: kubernetes
10
- Requires-Dist: ob-metaflow (==2.15.18.1)
10
+ Requires-Dist: ob-metaflow (==2.15.21.1)
11
11
 
12
12
  # Outerbounds platform package
13
13
 
@@ -13,7 +13,7 @@ metaflow_extensions/outerbounds/plugins/apps/deploy_decorator.py,sha256=VkmiMdNY
13
13
  metaflow_extensions/outerbounds/plugins/apps/supervisord_utils.py,sha256=GQoN2gyPClcpR9cLldJmbCfqXnoAHxp8xUnY7vzaYtY,9026
14
14
  metaflow_extensions/outerbounds/plugins/apps/core/__init__.py,sha256=c6uCgKlgEkTmM9BVdAO-m3vZvUpK2KW_AZZ2236now4,237
15
15
  metaflow_extensions/outerbounds/plugins/apps/core/_state_machine.py,sha256=b0WI7jVIReWo52AtWXFlaoET2u3nOVH9oITnVlWFIBk,19881
16
- metaflow_extensions/outerbounds/plugins/apps/core/app_cli.py,sha256=HR4nQXyxHSe6NbUF0mutsOQR_rIT-_U6C-mdSQ1_8Os,42326
16
+ metaflow_extensions/outerbounds/plugins/apps/core/app_cli.py,sha256=9YyvOQzPNlpxA2K9AZ4jYpfDWpLSp66u_NotGGE5DHg,42155
17
17
  metaflow_extensions/outerbounds/plugins/apps/core/app_config.py,sha256=PHt-HdNfTHIuhY-eB5vkRMp1RKQNWJ4DKdgZWyYgUuc,4167
18
18
  metaflow_extensions/outerbounds/plugins/apps/core/artifacts.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
19
  metaflow_extensions/outerbounds/plugins/apps/core/capsule.py,sha256=fRcJ_GgC4FxGscRS4M4mcMNpjoHA6Zdc1LIgdfDkJkI,33935
@@ -33,11 +33,11 @@ metaflow_extensions/outerbounds/plugins/apps/core/code_package/code_packager.py,
33
33
  metaflow_extensions/outerbounds/plugins/apps/core/code_package/examples.py,sha256=aF8qKIJxCVv_ugcShQjqUsXKKKMsm1oMkQIl8w3QKuw,4016
34
34
  metaflow_extensions/outerbounds/plugins/apps/core/config/__init__.py,sha256=fSFBjC5ujTpBlNJGyVsaoWl5VZ_8mXbEIPiFvzTrgKA,382
35
35
  metaflow_extensions/outerbounds/plugins/apps/core/config/cli_generator.py,sha256=0R0-wy7RxAMR9doVRvuluRYxAYgyjZXlTIkOeYGyz7M,5350
36
- metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py,sha256=zBZSe-1CsHj5MxrKNuAGLy11yXD4qF_IJJkdORfCW6A,32089
36
+ metaflow_extensions/outerbounds/plugins/apps/core/config/config_utils.py,sha256=bozzUR8rbfOnb5M532RZxB5QNvVgEC1gnVjfCvQ82Yk,34053
37
37
  metaflow_extensions/outerbounds/plugins/apps/core/config/schema_export.py,sha256=tigPtb0we-urwbmctG1GbaQ9NKRKZn4KBbJKmaEntCg,9501
38
38
  metaflow_extensions/outerbounds/plugins/apps/core/config/typed_configs.py,sha256=bAC2lV1xWtcw0r2LPlqDrggeXPLOyrtZha2KDpm_Vx0,4454
39
- metaflow_extensions/outerbounds/plugins/apps/core/config/typed_init_generator.py,sha256=bdWS5THMeae08wHvTrMq-cJP52r4sTMP1dsABHX0cK0,11766
40
- metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py,sha256=bUL-W91BvJ5O94wegoxPpBGOdyZYMzPEMeFF96UpYPI,35595
39
+ metaflow_extensions/outerbounds/plugins/apps/core/config/typed_init_generator.py,sha256=KiJ1eiwtBR5eWdBzWqvO6KlqJ2qzjJvl3w4c1uJ3g0Y,13419
40
+ metaflow_extensions/outerbounds/plugins/apps/core/config/unified_config.py,sha256=HzLFqMHuo-2o3KR4hJlTtMkl978yvCnGu2lm_PnhEp0,35548
41
41
  metaflow_extensions/outerbounds/plugins/apps/core/experimental/__init__.py,sha256=rd4qGTkHndKYfJmoAKZWiY0KK4j5BK6RBrtle-it1Mg,2746
42
42
  metaflow_extensions/outerbounds/plugins/aws/__init__.py,sha256=VBGdjNKeFLXGZuqh4jVk8cFtO1AWof73a6k_cnbAOYA,145
43
43
  metaflow_extensions/outerbounds/plugins/aws/assume_role.py,sha256=mBewNlnSYsR2rFXFkX-DUH6ku01h2yOcMcLHoCL7eyI,161
@@ -115,7 +115,7 @@ metaflow_extensions/outerbounds/toplevel/plugins/ollama/__init__.py,sha256=GRSz2
115
115
  metaflow_extensions/outerbounds/toplevel/plugins/snowflake/__init__.py,sha256=LptpH-ziXHrednMYUjIaosS1SXD3sOtF_9_eRqd8SJw,50
116
116
  metaflow_extensions/outerbounds/toplevel/plugins/torchtune/__init__.py,sha256=uTVkdSk3xZ7hEKYfdlyVteWj5KeDwaM1hU9WT-_YKfI,50
117
117
  metaflow_extensions/outerbounds/toplevel/plugins/vllm/__init__.py,sha256=ekcgD3KVydf-a0xMI60P4uy6ePkSEoFHiGnDq1JM940,45
118
- ob_metaflow_extensions-1.1.175rc4.dist-info/METADATA,sha256=QKrSlPcSdpTnsJIJS6c4xl5B04HoNnpVeBldTegc3BQ,524
119
- ob_metaflow_extensions-1.1.175rc4.dist-info/WHEEL,sha256=bb2Ot9scclHKMOLDEHY6B2sicWOgugjFKaJsT7vwMQo,110
120
- ob_metaflow_extensions-1.1.175rc4.dist-info/top_level.txt,sha256=NwG0ukwjygtanDETyp_BUdtYtqIA_lOjzFFh1TsnxvI,20
121
- ob_metaflow_extensions-1.1.175rc4.dist-info/RECORD,,
118
+ ob_metaflow_extensions-1.2.0.dist-info/METADATA,sha256=ZlwS67W8SEHHEGzsdvxWg6TODhFy-N-o9GpflqBkMR4,519
119
+ ob_metaflow_extensions-1.2.0.dist-info/WHEEL,sha256=bb2Ot9scclHKMOLDEHY6B2sicWOgugjFKaJsT7vwMQo,110
120
+ ob_metaflow_extensions-1.2.0.dist-info/top_level.txt,sha256=NwG0ukwjygtanDETyp_BUdtYtqIA_lOjzFFh1TsnxvI,20
121
+ ob_metaflow_extensions-1.2.0.dist-info/RECORD,,