tinyfan 0.2.9__tar.gz → 0.4.0__tar.gz

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.
Files changed (60) hide show
  1. {tinyfan-0.2.9 → tinyfan-0.4.0}/.gitignore +4 -0
  2. {tinyfan-0.2.9 → tinyfan-0.4.0}/PKG-INFO +15 -5
  3. {tinyfan-0.2.9 → tinyfan-0.4.0}/README.md +12 -0
  4. {tinyfan-0.2.9 → tinyfan-0.4.0}/pyproject.toml +3 -5
  5. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/argo_typing.py +11 -1
  6. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/cli.py +6 -2
  7. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/codegen.py +173 -44
  8. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/flow.py +11 -2
  9. tinyfan-0.4.0/src/tinyfan/stores/gcs_pickle.py +149 -0
  10. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/utils/embed.py +2 -2
  11. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/test_codegen.py +100 -0
  12. tinyfan-0.4.0/tests/test_gcs_pickle_store.py +71 -0
  13. {tinyfan-0.2.9 → tinyfan-0.4.0}/uv.lock +86 -23
  14. tinyfan-0.2.9/src/tinyfan/stores/gcs_pickle.py +0 -0
  15. {tinyfan-0.2.9 → tinyfan-0.4.0}/.github/workflows/publish.yml +0 -0
  16. {tinyfan-0.2.9 → tinyfan-0.4.0}/.python-version +0 -0
  17. {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/README.md +0 -0
  18. {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/configmap.yaml +0 -0
  19. {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/configs.py +0 -0
  20. {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/getting-started.py +0 -0
  21. {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/packaged/.python-version +0 -0
  22. {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/packaged/Dockerfile +0 -0
  23. {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/packaged/README.md +0 -0
  24. {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/packaged/pyproject.toml +0 -0
  25. {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/packaged/src/tinyfan_examples_packaged/__init__.py +0 -0
  26. {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/packaged/src/tinyfan_examples_packaged/asset.py +0 -0
  27. {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/resource.py +0 -0
  28. {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/secret.yaml +0 -0
  29. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/__init__.py +0 -0
  30. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/config.py +0 -0
  31. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/dagviz.py +0 -0
  32. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/flowrundata.py +0 -0
  33. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/py.typed +0 -0
  34. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/resources/__init__.py +0 -0
  35. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/resources/base.py +0 -0
  36. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/stores/__init__.py +0 -0
  37. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/stores/base.py +0 -0
  38. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/stores/bigquery.py +0 -0
  39. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/stores/naive.py +0 -0
  40. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/utils/__init__.py +0 -0
  41. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/utils/exjson.py +0 -0
  42. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/utils/merge.py +0 -0
  43. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/utils/typing.py +0 -0
  44. {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/utils/yaml.py +0 -0
  45. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/__init__.py +0 -0
  46. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/codegen_samples/directory/__init__.py +0 -0
  47. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/codegen_samples/directory/asset.py +0 -0
  48. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/codegen_samples/directory/asset2.py +0 -0
  49. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/codegen_samples/singlefile.py +0 -0
  50. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/conftest.py +0 -0
  51. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/helpers.py +0 -0
  52. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/openapi2jsonschema.py +0 -0
  53. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/test_config.py +0 -0
  54. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/test_decorators.py +0 -0
  55. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/utils/__init__.py +0 -0
  56. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/utils/embed_samples/directory/__init__.py +0 -0
  57. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/utils/embed_samples/directory/dep.py +0 -0
  58. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/utils/embed_samples/singlefile.py +0 -0
  59. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/utils/test_embed.py +0 -0
  60. {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/utils/test_exjson.py +0 -0
@@ -8,3 +8,7 @@ wheels/
8
8
 
9
9
  # Virtual environments
10
10
  .venv
11
+
12
+ .mypy_cache/
13
+ .pytest_cache/
14
+ .ruff_cache/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinyfan
3
- Version: 0.2.9
3
+ Version: 0.4.0
4
4
  Summary: Tiny Argo Workflows python devkit
5
5
  Project-URL: Documentation, https://github.com/eunchuldev/tinyfan#readme
6
6
  Project-URL: Issues, https://github.com/eunchuldev/tinyfan/issues
@@ -9,14 +9,12 @@ Author-email: eunchuldev <eunchulsong@gmail.com>
9
9
  Keywords: argo-workflow,pipeline,tinyfan,workflow
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
- Classifier: Programming Language :: Python :: 3.10
13
- Classifier: Programming Language :: Python :: 3.11
14
12
  Classifier: Programming Language :: Python :: 3.12
15
13
  Classifier: Typing :: Typed
16
14
  Requires-Python: >=3.12
17
- Requires-Dist: croniter>=6.0.0
15
+ Requires-Dist: croniter>=6
18
16
  Requires-Dist: pyyaml>=6.0.3
19
- Requires-Dist: typer-slim>=0.20.0
17
+ Requires-Dist: typer-slim>=0.24.0
20
18
  Provides-Extra: all
21
19
  Requires-Dist: google-cloud-bigquery[pandas]; extra == 'all'
22
20
  Requires-Dist: google-cloud-storage; extra == 'all'
@@ -103,6 +101,18 @@ def greeting(world: str):
103
101
  tinyfan main.py | kubectl apply -f -
104
102
  ```
105
103
 
104
+ For a package, pass its directory or import name. Tinyfan statically discovers
105
+ modules containing `@asset`, so unrelated submodules are not executed during
106
+ manifest generation. Modules imported by an asset module (for example, a shared
107
+ `Flow` definition) are loaded normally.
108
+
109
+ ```shell
110
+ tinyfan src/my_package
111
+ ```
112
+
113
+ If a project registers assets dynamically or wraps `@asset` in another decorator,
114
+ use `--import-all` to opt into the previous eager-import behavior.
115
+
106
116
  # Real World Example (still tiny though!)
107
117
 
108
118
  Comming soon
@@ -50,6 +50,18 @@ def greeting(world: str):
50
50
  tinyfan main.py | kubectl apply -f -
51
51
  ```
52
52
 
53
+ For a package, pass its directory or import name. Tinyfan statically discovers
54
+ modules containing `@asset`, so unrelated submodules are not executed during
55
+ manifest generation. Modules imported by an asset module (for example, a shared
56
+ `Flow` definition) are loaded normally.
57
+
58
+ ```shell
59
+ tinyfan src/my_package
60
+ ```
61
+
62
+ If a project registers assets dynamically or wraps `@asset` in another decorator,
63
+ use `--import-all` to opt into the previous eager-import behavior.
64
+
53
65
  # Real World Example (still tiny though!)
54
66
 
55
67
  Comming soon
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tinyfan"
3
- version = "0.2.9"
3
+ version = "0.4.0"
4
4
  description = 'Tiny Argo Workflows python devkit'
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -9,12 +9,10 @@ authors = [{ name = "eunchuldev", email = "eunchulsong@gmail.com" }]
9
9
  classifiers = [
10
10
  "Operating System :: OS Independent",
11
11
  "License :: OSI Approved :: MIT License",
12
- "Programming Language :: Python :: 3.10",
13
- "Programming Language :: Python :: 3.11",
14
12
  "Programming Language :: Python :: 3.12",
15
13
  "Typing :: Typed",
16
14
  ]
17
- dependencies = ["croniter>=6.0.0", "pyyaml>=6.0.3", "typer-slim>=0.20.0"]
15
+ dependencies = ["croniter>=6", "pyyaml>=6.0.3", "typer-slim>=0.24.0"]
18
16
 
19
17
  [project.scripts]
20
18
  tinyfan = "tinyfan.cli:main"
@@ -54,7 +52,7 @@ addopts = ["--import-mode=importlib"]
54
52
  exclude = ['build']
55
53
 
56
54
  [[tool.mypy.overrides]]
57
- module = ["lark.*", "jsonpatch.*"]
55
+ module = ["lark.*", "jsonpatch.*", "google.*"]
58
56
  ignore_missing_imports = true
59
57
 
60
58
  [tool.ruff]
@@ -1,4 +1,4 @@
1
- from typing import TypedDict, NotRequired
1
+ from typing import Any, TypedDict, NotRequired
2
2
 
3
3
 
4
4
  class ExecAction(TypedDict):
@@ -194,6 +194,16 @@ class VolumeMount(TypedDict, total=False):
194
194
  subPathExpr: str
195
195
 
196
196
 
197
+ class TemplateInputs(TypedDict, total=False):
198
+ artifacts: list[dict[str, Any]]
199
+ parameters: list[dict[str, Any]]
200
+
201
+
202
+ class TemplateOutputs(TypedDict, total=False):
203
+ artifacts: list[dict[str, Any]]
204
+ parameters: list[dict[str, Any]]
205
+
206
+
197
207
  # Ref: https://argo-workflows.readthedocs.io/en/latest/fields/#scripttemplate
198
208
  class ScriptTemplate(TypedDict, total=False):
199
209
  args: list[str]
@@ -9,6 +9,10 @@ def tinyfan(
9
9
  location: str,
10
10
  embedded: bool | None = None,
11
11
  arg: Annotated[list[str] | None, typer.Option(metavar="KEY=VALUE")] = None,
12
+ import_all: Annotated[
13
+ bool,
14
+ typer.Option(help="Import every submodule instead of discovering modules with @asset statically."),
15
+ ] = False,
12
16
  ):
13
17
  """
14
18
  Generate argocd workflow resource as yaml from tinyfan definitions
@@ -16,9 +20,9 @@ def tinyfan(
16
20
  if arg is not None:
17
21
  CLI_CONFIG_STATE.cli_args.update({k: v for (k, v) in (val.split("=") for val in arg)})
18
22
  if location.endswith(".py"):
19
- print(codegen(location=location, embedded=embedded or True))
23
+ print(codegen(location=location, embedded=embedded or True, import_all=import_all))
20
24
  else:
21
- print(codegen(location=location, embedded=embedded or False))
25
+ print(codegen(location=location, embedded=embedded or False, import_all=import_all))
22
26
 
23
27
 
24
28
  def main():
@@ -1,4 +1,6 @@
1
1
  from tinyfan.flow import Flow, Asset, FLOW_REGISTER, DEFAULT_IMAGE
2
+ import ast
3
+ import copy
2
4
  import importlib.util
3
5
  from typing import Self
4
6
  from .utils.yaml import dump as yaml_dump, dump_all as yaml_dump_all
@@ -8,10 +10,11 @@ import sys
8
10
  import re
9
11
  import json
10
12
  import os
11
- import pkgutil
13
+ from pathlib import Path
14
+ import tokenize
12
15
  import datetime
13
16
  import croniter
14
- from .argo_typing import ScriptTemplate
17
+ from .argo_typing import ScriptTemplate, TemplateInputs, TemplateOutputs
15
18
  from .utils.embed import embed
16
19
  from .utils.merge import deepmerge, dropnone
17
20
  from .config import CLI_CONFIG_STATE, Config, ConfigValue, ConfigMapKeyRef, ConfigMapRef, SecretKeyRef, SecretRef
@@ -23,6 +26,36 @@ VALID_DEPENDS_REGEXP = r"^(?!.*\.[A-Za-z0-9_]+\.[A-Za-z0-9_]+)(?!.*\.(?!Succeede
23
26
  EXTRACT_DEPENDS_REGEXP = r"(?<!\.)\b([A-Za-z0-9_]+)\b"
24
27
 
25
28
  RUNDATA_FILE_PATH = "/tmp/tinyfan/rundata.json"
29
+ INTERNAL_INPUT_PARAMETERS = {"rundata", "asset_name", "moduledata"}
30
+ INTERNAL_OUTPUT_PARAMETERS = {"rundata"}
31
+
32
+
33
+ def merge_template_io(
34
+ template_io: dict, user_io: TemplateInputs | TemplateOutputs | None, reserved_parameter_names: set[str]
35
+ ) -> dict:
36
+ result = copy.deepcopy(template_io)
37
+ if user_io is None:
38
+ return result
39
+ for key, value in user_io.items():
40
+ if key in ("parameters", "artifacts"):
41
+ if not isinstance(value, list):
42
+ raise TypeError(f"asset {key} must be a list")
43
+ if key == "parameters":
44
+ conflicts = [
45
+ param.get("name")
46
+ for param in value
47
+ if isinstance(param, dict) and param.get("name") in reserved_parameter_names
48
+ ]
49
+ if conflicts:
50
+ names = ", ".join(f"`{name}`" for name in conflicts)
51
+ raise ValueError(f"asset parameters cannot redefine tinyfan internal parameter(s): {names}")
52
+ result.setdefault(key, [])
53
+ result[key].extend(copy.deepcopy(value))
54
+ elif isinstance(result.get(key), dict) and isinstance(value, dict):
55
+ result[key] = deepmerge(result[key], copy.deepcopy(value))
56
+ else:
57
+ result[key] = copy.deepcopy(value)
58
+ return result
26
59
 
27
60
 
28
61
  def brackets_balanced(code: str) -> bool:
@@ -46,7 +79,85 @@ def encode_schedule_to_k8sname(tz: str, cron: str) -> str:
46
79
  return f"{tz}|{cron}".lower().translate(str.maketrans("* ,-/_+|", "a-cds-p-", "@"))[:58]
47
80
 
48
81
 
49
- def import_all_submodules(location: str):
82
+ def _defines_tinyfan_asset(source_path: Path) -> bool:
83
+ """Return whether a module has a statically recognizable Tinyfan asset."""
84
+ with tokenize.open(source_path) as source_file:
85
+ tree = ast.parse(source_file.read(), filename=str(source_path))
86
+
87
+ asset_names: set[str] = set()
88
+ tinyfan_namespaces: set[str] = set()
89
+
90
+ for node in ast.walk(tree):
91
+ if isinstance(node, ast.Import):
92
+ for imported in node.names:
93
+ if imported.name == "tinyfan":
94
+ tinyfan_namespaces.add(imported.asname or "tinyfan")
95
+ elif imported.name == "tinyfan.flow":
96
+ tinyfan_namespaces.add(imported.asname or "tinyfan")
97
+ elif isinstance(node, ast.ImportFrom) and node.module in {"tinyfan", "tinyfan.flow"}:
98
+ for imported in node.names:
99
+ if imported.name == "asset":
100
+ asset_names.add(imported.asname or imported.name)
101
+ elif imported.name == "*":
102
+ asset_names.add("asset")
103
+
104
+ def is_asset_decorator(decorator: ast.expr) -> bool:
105
+ while isinstance(decorator, ast.Call):
106
+ decorator = decorator.func
107
+ if isinstance(decorator, ast.Name):
108
+ return decorator.id in asset_names
109
+
110
+ names: list[str] = []
111
+ while isinstance(decorator, ast.Attribute):
112
+ names.append(decorator.attr)
113
+ decorator = decorator.value
114
+ if isinstance(decorator, ast.Name):
115
+ names.append(decorator.id)
116
+ names.reverse()
117
+ return len(names) >= 2 and names[0] in tinyfan_namespaces and names[-1] == "asset"
118
+
119
+ class AssetVisitor(ast.NodeVisitor):
120
+ found = False
121
+
122
+ def _visit_definition(self, node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
123
+ if any(is_asset_decorator(decorator) for decorator in node.decorator_list):
124
+ self.found = True
125
+
126
+ def visit_FunctionDef(self, node: ast.FunctionDef):
127
+ self._visit_definition(node)
128
+
129
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
130
+ self._visit_definition(node)
131
+
132
+ def visit_ClassDef(self, node: ast.ClassDef):
133
+ self._visit_definition(node)
134
+ if not self.found:
135
+ self.generic_visit(node)
136
+
137
+ visitor = AssetVisitor()
138
+ visitor.visit(tree)
139
+ return visitor.found
140
+
141
+
142
+ def _package_source_modules(package) -> list[tuple[str, Path]]:
143
+ modules: list[tuple[str, Path]] = []
144
+ for package_path in package.__path__:
145
+ root = Path(package_path)
146
+ for source_path in root.rglob("*.py"):
147
+ relative_path = source_path.relative_to(root)
148
+ if source_path.name == "__init__.py":
149
+ module_parts = relative_path.parts[:-1]
150
+ else:
151
+ module_parts = relative_path.with_suffix("").parts
152
+ if not module_parts:
153
+ # Importing the root package has already executed its __init__.py.
154
+ continue
155
+ modules.append((".".join((package.__name__, *module_parts)), source_path))
156
+ return modules
157
+
158
+
159
+ def import_asset_modules(location: str, import_all: bool = False):
160
+ """Import asset-bearing modules from a module name, package directory, or file."""
50
161
  try:
51
162
  package = importlib.import_module(location)
52
163
  except ModuleNotFoundError:
@@ -61,9 +172,16 @@ def import_all_submodules(location: str):
61
172
  package = importlib.util.module_from_spec(spec)
62
173
  sys.modules[spec.name] = package
63
174
  spec.loader.exec_module(package)
64
- if package.__name__ != "__tinyfan_repo__":
65
- for _, name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + "."):
66
- importlib.import_module(name)
175
+ if package.__name__ == "__tinyfan_repo__" or not hasattr(package, "__path__"):
176
+ return
177
+ for module_name, source_path in _package_source_modules(package):
178
+ if import_all or _defines_tinyfan_asset(source_path):
179
+ importlib.import_module(module_name)
180
+
181
+
182
+ def import_all_submodules(location: str):
183
+ """Import every submodule, preserving the eager discovery API used before 0.3.0."""
184
+ import_asset_modules(location, import_all=True)
67
185
 
68
186
 
69
187
  def assetname2template(name: str):
@@ -364,32 +482,40 @@ class AssetTree:
364
482
  "synchronization": {
365
483
  "mutexes": [{"name": f"{self.flow.name}-{node.asset.name.replace('_', '-')}"}]
366
484
  },
367
- "inputs": {
368
- "parameters": [
369
- {
370
- "name": "rundata",
371
- "value": "{{= nil }}",
372
- },
373
- {
374
- "name": "asset_name",
375
- "value": "{{= nil }}",
376
- },
377
- {
378
- "name": "moduledata",
379
- "value": "{{= nil }}",
380
- },
381
- ]
382
- },
383
- "outputs": {
384
- "parameters": [
385
- {
386
- "name": "rundata",
387
- "valueFrom": {
388
- "path": RUNDATA_FILE_PATH,
485
+ "inputs": merge_template_io(
486
+ {
487
+ "parameters": [
488
+ {
489
+ "name": "rundata",
490
+ "value": "{{= nil }}",
389
491
  },
390
- }
391
- ],
392
- },
492
+ {
493
+ "name": "asset_name",
494
+ "value": "{{= nil }}",
495
+ },
496
+ {
497
+ "name": "moduledata",
498
+ "value": "{{= nil }}",
499
+ },
500
+ ]
501
+ },
502
+ node.asset.inputs,
503
+ INTERNAL_INPUT_PARAMETERS,
504
+ ),
505
+ "outputs": merge_template_io(
506
+ {
507
+ "parameters": [
508
+ {
509
+ "name": "rundata",
510
+ "valueFrom": {
511
+ "path": RUNDATA_FILE_PATH,
512
+ },
513
+ }
514
+ ],
515
+ },
516
+ node.asset.outputs,
517
+ INTERNAL_OUTPUT_PARAMETERS,
518
+ ),
393
519
  }
394
520
  for node in relatives
395
521
  ]
@@ -459,19 +585,22 @@ def codegen(
459
585
  embedded: bool = False,
460
586
  container: ScriptTemplate = {},
461
587
  serviceAccountName: str | None = None,
588
+ import_all: bool = False,
462
589
  ) -> str:
463
590
  """Generate argocd workflow resource as yaml from tinyfan definitions"""
464
- CLI_CONFIG_STATE.codegen = True
465
- if flow:
466
- result = AssetTree(flow).compile(embedded, container, serviceAccountName)
467
- elif location:
468
- import_all_submodules(location)
469
- result = "\n---\n".join(
470
- AssetTree(flow).compile(embedded, container, serviceAccountName) for flow in FLOW_REGISTER.values()
471
- )
472
- else:
473
- result = "\n---\n".join(
474
- AssetTree(flow).compile(embedded, container, serviceAccountName) for flow in FLOW_REGISTER.values()
475
- )
476
- CLI_CONFIG_STATE.codegen = False
591
+ try:
592
+ CLI_CONFIG_STATE.codegen = True
593
+ if flow:
594
+ result = AssetTree(flow).compile(embedded, container, serviceAccountName)
595
+ elif location:
596
+ import_asset_modules(location, import_all=import_all)
597
+ result = "\n---\n".join(
598
+ AssetTree(flow).compile(embedded, container, serviceAccountName) for flow in FLOW_REGISTER.values()
599
+ )
600
+ else:
601
+ result = "\n---\n".join(
602
+ AssetTree(flow).compile(embedded, container, serviceAccountName) for flow in FLOW_REGISTER.values()
603
+ )
604
+ finally:
605
+ CLI_CONFIG_STATE.codegen = False
477
606
  return result
@@ -6,7 +6,7 @@ from .flowrundata import FlowRunData, StoreIdx, UMeta
6
6
  from dataclasses import dataclass, field
7
7
  from typing import Callable, Any, Generic, TypeVar, Awaitable, Coroutine, cast
8
8
  from .resources.base import ResourceBase
9
- from .argo_typing import ScriptTemplate
9
+ from .argo_typing import ScriptTemplate, TemplateInputs, TemplateOutputs
10
10
  from .config import Config, ConfigValue
11
11
 
12
12
  FLOW_REGISTER = {}
@@ -51,6 +51,8 @@ class Asset(Generic[Ret, UMeta, StoreIdx]):
51
51
  name: str = field(init=False)
52
52
  container: ScriptTemplate | None = None
53
53
  serviceAccountName: str | None = None
54
+ inputs: TemplateInputs | None = None
55
+ outputs: TemplateOutputs | None = None
54
56
 
55
57
  def __post_init__(self):
56
58
  self.name = self.func.__name__
@@ -81,7 +83,10 @@ class Asset(Generic[Ret, UMeta, StoreIdx]):
81
83
  for name, prundata in parent_flowrundatas.items():
82
84
  parent = self.flow.assets[name]
83
85
  index = prundata.get("store_entry_idx", None)
84
- data = index and parent.store.retrieve(index, source_rundata=prundata, target_rundata=rundata)
86
+ if index is not None:
87
+ data = parent.store.retrieve(index, source_rundata=prundata, target_rundata=rundata)
88
+ else:
89
+ data = None
85
90
  if name in func_param_names:
86
91
  params[name] = data
87
92
  ret = self.func(**params)
@@ -118,6 +123,8 @@ def asset(
118
123
  metadata: UMeta | None = None,
119
124
  container: ScriptTemplate | None = None,
120
125
  serviceAccountName: str | None = None,
126
+ inputs: TemplateInputs | None = None,
127
+ outputs: TemplateOutputs | None = None,
121
128
  ) -> Callable[..., AssetFunc[Ret, UMeta, StoreIdx]]:
122
129
  def wrapper(func: Callable[..., Ret]) -> AssetFunc[Ret, UMeta, StoreIdx]:
123
130
  asset = Asset[Ret, UMeta, StoreIdx](
@@ -130,6 +137,8 @@ def asset(
130
137
  metadata=metadata,
131
138
  container=container,
132
139
  serviceAccountName=serviceAccountName,
140
+ inputs=inputs,
141
+ outputs=outputs,
133
142
  )
134
143
  return AssetFunc(func, asset)
135
144
 
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import pickle
4
+ import re
5
+ import uuid
6
+ from collections.abc import Mapping
7
+ from typing import Any, NotRequired, TypedDict
8
+
9
+ from ..config import ConfigValue
10
+ from .base import FlowRunData, StoreBase
11
+
12
+
13
+ class GcsPickleStoreIndex(TypedDict):
14
+ bucket: str
15
+ name: str
16
+ generation: NotRequired[int]
17
+
18
+
19
+ class GcsPickleStoreMetadata(TypedDict, total=False):
20
+ blob_name: str
21
+ prefix: str
22
+
23
+
24
+ ConfigurableString = str | ConfigValue
25
+
26
+
27
+ _GCS_EXTRA_ERROR = "GCS extra not installed. Please install with `pip install tinyfan[gcs]`."
28
+
29
+
30
+ def _new_storage_client(client_kwargs: Mapping[str, Any]) -> Any:
31
+ try:
32
+ from google.cloud import storage
33
+ except ImportError as exc:
34
+ raise ImportError(_GCS_EXTRA_ERROR) from exc
35
+
36
+ return storage.Client(**client_kwargs)
37
+
38
+
39
+ def _resolve(value: ConfigurableString) -> str:
40
+ if isinstance(value, ConfigValue):
41
+ return value.get_value()
42
+ return value
43
+
44
+
45
+ def _path_part(value: object, fallback: str) -> str:
46
+ text = str(value if value is not None else fallback).strip()
47
+ text = re.sub(r"[^A-Za-z0-9._=-]+", "-", text)
48
+ return text.strip(".-") or fallback
49
+
50
+
51
+ def _join_blob_name(*parts: str) -> str:
52
+ return "/".join(part.strip("/") for part in parts if part and part.strip("/"))
53
+
54
+
55
+ def _run_part(rundata: FlowRunData[Any, Any]) -> str:
56
+ for key in ("data_interval_start", "ts", "ds"):
57
+ value = rundata.get(key)
58
+ if value is None:
59
+ continue
60
+ if hasattr(value, "isoformat"):
61
+ value = value.isoformat()
62
+ return _path_part(value, "manual")
63
+ return "manual"
64
+
65
+
66
+ class GcsPickleStore(StoreBase[object, GcsPickleStoreMetadata, GcsPickleStoreIndex]):
67
+ bucket: ConfigurableString
68
+ prefix: ConfigurableString
69
+
70
+ def __init__(
71
+ self,
72
+ bucket: ConfigurableString,
73
+ prefix: ConfigurableString = "tinyfan",
74
+ *,
75
+ client: Any | None = None,
76
+ client_kwargs: Mapping[str, Any] | None = None,
77
+ pickle_protocol: int = pickle.HIGHEST_PROTOCOL,
78
+ ) -> None:
79
+ self.bucket = bucket
80
+ self.prefix = prefix
81
+ self._client = client
82
+ self._client_kwargs = dict(client_kwargs or {})
83
+ self.pickle_protocol = pickle_protocol
84
+
85
+ @staticmethod
86
+ def id() -> str:
87
+ return "tinyfan.gcs_pickle"
88
+
89
+ def _get_client(self) -> Any:
90
+ if self._client is None:
91
+ self._client = _new_storage_client(self._client_kwargs)
92
+ return self._client
93
+
94
+ def _bucket_name(self) -> str:
95
+ bucket = _resolve(self.bucket).strip()
96
+ if not bucket:
97
+ raise ValueError("GCS bucket name must not be empty.")
98
+ return bucket
99
+
100
+ def _blob_name(self, rundata: FlowRunData[GcsPickleStoreMetadata, GcsPickleStoreIndex]) -> str:
101
+ metadata = rundata.get("metadata")
102
+ if isinstance(metadata, Mapping):
103
+ blob_name = metadata.get("blob_name")
104
+ if blob_name is not None:
105
+ blob_name = str(blob_name).strip("/")
106
+ if not blob_name:
107
+ raise ValueError("GCS blob_name metadata must not be empty.")
108
+ return blob_name
109
+
110
+ prefix = str(metadata.get("prefix") or _resolve(self.prefix))
111
+ else:
112
+ prefix = _resolve(self.prefix)
113
+
114
+ flow_name = _path_part(rundata.get("flow_name"), "flow")
115
+ asset_name = _path_part(rundata.get("asset_name"), "asset")
116
+ run_id = _run_part(rundata)
117
+ filename = f"{uuid.uuid4().hex}.pickle"
118
+ return _join_blob_name(prefix, flow_name, asset_name, run_id, filename)
119
+
120
+ def store(
121
+ self,
122
+ value: object,
123
+ rundata: FlowRunData[GcsPickleStoreMetadata, GcsPickleStoreIndex],
124
+ ) -> GcsPickleStoreIndex:
125
+ bucket_name = self._bucket_name()
126
+ blob = self._get_client().bucket(bucket_name).blob(self._blob_name(rundata))
127
+ data = pickle.dumps(value, protocol=self.pickle_protocol)
128
+ blob.upload_from_string(data, content_type="application/octet-stream")
129
+
130
+ index: GcsPickleStoreIndex = {
131
+ "bucket": bucket_name,
132
+ "name": blob.name,
133
+ }
134
+ generation = getattr(blob, "generation", None)
135
+ if generation is not None:
136
+ index["generation"] = int(generation)
137
+ return index
138
+
139
+ def retrieve(
140
+ self,
141
+ index: GcsPickleStoreIndex,
142
+ source_rundata: FlowRunData[GcsPickleStoreMetadata, GcsPickleStoreIndex],
143
+ target_rundata: FlowRunData,
144
+ ) -> object:
145
+ del source_rundata, target_rundata
146
+
147
+ bucket = self._get_client().bucket(index["bucket"])
148
+ blob = bucket.blob(index["name"], generation=index.get("generation"))
149
+ return pickle.loads(blob.download_as_bytes())
@@ -54,7 +54,7 @@ def zip_directory(
54
54
  files -= set().union(*map(path.glob, excludes))
55
55
  for file_path in map(str, files):
56
56
  arcname = os.path.relpath(file_path, start=parent_dir)
57
- with open(file_path, "r") as f:
57
+ with open(file_path, "r", encoding="utf-8") as f:
58
58
  try:
59
59
  source = f.read()
60
60
  except Exception as e:
@@ -67,7 +67,7 @@ def zip_directory(
67
67
 
68
68
 
69
69
  def embed_singlefile(path: str) -> str:
70
- with open(path, "r") as f:
70
+ with open(path, "r", encoding="utf-8") as f:
71
71
  sourcecode = f.read()
72
72
  return sourcecode
73
73
 
@@ -4,7 +4,9 @@ from tinyfan.codegen import codegen, AssetTree
4
4
  from jsonschema.exceptions import ValidationError
5
5
  from typing import Generator
6
6
  import os
7
+ import sys
7
8
  import pytest
9
+ import yaml
8
10
 
9
11
 
10
12
  # type: ignore
@@ -103,6 +105,36 @@ def test_codegen_flow(validate_crds, sample_flow):
103
105
  assert_code_is_running(actual)
104
106
 
105
107
 
108
+ def test_codegen_asset_inputs_outputs(validate_crds):
109
+ flow: Flow = Flow("test-io")
110
+
111
+ @asset(
112
+ flow=flow,
113
+ schedule="@daily",
114
+ inputs={"parameters": [{"name": "message", "value": "hello"}]},
115
+ outputs={"parameters": [{"name": "summary", "valueFrom": {"path": "/tmp/summary.txt"}}]},
116
+ )
117
+ def asset1(): # type: ignore
118
+ return "hello"
119
+
120
+ try:
121
+ actual = codegen(flow=flow)
122
+ validate_crds(actual)
123
+ manifest = next(yaml.load_all(actual, yaml.Loader))
124
+ template = next(
125
+ template for template in manifest["spec"]["workflowSpec"]["templates"] if template["name"] == "asset1"
126
+ )
127
+ input_params = {param["name"]: param for param in template["inputs"]["parameters"]}
128
+ output_params = {param["name"]: param for param in template["outputs"]["parameters"]}
129
+
130
+ assert input_params["message"] == {"name": "message", "value": "hello"}
131
+ assert {"rundata", "asset_name", "moduledata"}.issubset(input_params.keys())
132
+ assert output_params["summary"] == {"name": "summary", "valueFrom": {"path": "/tmp/summary.txt"}}
133
+ assert output_params["rundata"] == {"name": "rundata", "valueFrom": {"path": "/tmp/tinyfan/rundata.json"}}
134
+ finally:
135
+ FLOW_REGISTER.clear()
136
+
137
+
106
138
  def test_codegen_implicit(validate_crds, sample_flow):
107
139
  actual = codegen()
108
140
  validate_crds(actual)
@@ -137,6 +169,74 @@ def test_codegen_directory(validate_crds):
137
169
  assert_code_is_running(actual)
138
170
 
139
171
 
172
+ def test_codegen_directory_only_imports_asset_modules(tmp_path, monkeypatch, validate_crds):
173
+ FLOW_REGISTER.clear()
174
+ package_name = "selective_asset_package"
175
+ package_path = tmp_path / package_name
176
+ package_path.mkdir()
177
+ (package_path / "__init__.py").write_text("", encoding="utf-8")
178
+ (package_path / "flow.py").write_text(
179
+ 'from tinyfan import Flow\nflow = Flow("selective-discovery")\n',
180
+ encoding="utf-8",
181
+ )
182
+ (package_path / "assets.py").write_text(
183
+ "import tinyfan as tf\n"
184
+ "from .flow import flow\n\n"
185
+ '@tf.asset(flow=flow, schedule="@daily")\n'
186
+ "def discovered_asset():\n"
187
+ ' return "hello"\n',
188
+ encoding="utf-8",
189
+ )
190
+ (package_path / "unrelated.py").write_text(
191
+ 'raise RuntimeError("unrelated module was imported")\n',
192
+ encoding="utf-8",
193
+ )
194
+ monkeypatch.syspath_prepend(str(tmp_path))
195
+
196
+ try:
197
+ actual = codegen(location=package_name)
198
+
199
+ validate_crds(actual)
200
+ assert f"{package_name}.assets" in sys.modules
201
+ assert f"{package_name}.flow" in sys.modules
202
+ assert f"{package_name}.unrelated" not in sys.modules
203
+ finally:
204
+ for module_name in list(sys.modules):
205
+ if module_name == package_name or module_name.startswith(f"{package_name}."):
206
+ sys.modules.pop(module_name)
207
+ FLOW_REGISTER.clear()
208
+
209
+
210
+ def test_codegen_import_all_supports_dynamic_asset_decorators(tmp_path, monkeypatch, validate_crds):
211
+ FLOW_REGISTER.clear()
212
+ package_name = "dynamic_asset_package"
213
+ package_path = tmp_path / package_name
214
+ package_path.mkdir()
215
+ (package_path / "__init__.py").write_text("", encoding="utf-8")
216
+ (package_path / "assets.py").write_text(
217
+ "from tinyfan import asset\n\n"
218
+ 'scheduled_asset = asset(schedule="@daily")\n\n'
219
+ "@scheduled_asset\n"
220
+ "def dynamic_asset():\n"
221
+ ' return "hello"\n',
222
+ encoding="utf-8",
223
+ )
224
+ monkeypatch.syspath_prepend(str(tmp_path))
225
+
226
+ try:
227
+ codegen(location=package_name)
228
+ assert f"{package_name}.assets" not in sys.modules
229
+
230
+ actual = codegen(location=package_name, import_all=True)
231
+ validate_crds(actual)
232
+ assert f"{package_name}.assets" in sys.modules
233
+ finally:
234
+ for module_name in list(sys.modules):
235
+ if module_name == package_name or module_name.startswith(f"{package_name}."):
236
+ sys.modules.pop(module_name)
237
+ FLOW_REGISTER.clear()
238
+
239
+
140
240
  def test_rundata_data_interval(sample_flow):
141
241
  tree = AssetTree(sample_flow)
142
242
  node = list(tree.nodes.values())[0]
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from tinyfan.stores.gcs_pickle import GcsPickleStore
6
+
7
+
8
+ class FakeBlob:
9
+ def __init__(self, name: str, generation: int | None = None) -> None:
10
+ self.name = name
11
+ self.generation = generation
12
+ self.content_type: str | None = None
13
+ self.data: bytes | None = None
14
+
15
+ def upload_from_string(self, data: bytes, content_type: str) -> None:
16
+ self.data = data
17
+ self.content_type = content_type
18
+ self.generation = (self.generation or 0) + 1
19
+
20
+ def download_as_bytes(self) -> bytes:
21
+ assert self.data is not None
22
+ return self.data
23
+
24
+
25
+ class FakeBucket:
26
+ def __init__(self, name: str) -> None:
27
+ self.name = name
28
+ self.blobs: dict[str, FakeBlob] = {}
29
+
30
+ def blob(self, name: str, generation: int | None = None) -> FakeBlob:
31
+ blob = self.blobs.setdefault(name, FakeBlob(name, generation))
32
+ blob.generation = generation or blob.generation
33
+ return blob
34
+
35
+
36
+ class FakeClient:
37
+ def __init__(self) -> None:
38
+ self.buckets: dict[str, FakeBucket] = {}
39
+
40
+ def bucket(self, name: str) -> FakeBucket:
41
+ return self.buckets.setdefault(name, FakeBucket(name))
42
+
43
+
44
+ def test_gcs_pickle_store_roundtrip() -> None:
45
+ client = FakeClient()
46
+ store = GcsPickleStore("test-bucket", prefix="runs", client=client)
47
+ value = {"hello": ["world", 1]}
48
+ rundata = {
49
+ "flow_name": "flow",
50
+ "asset_name": "asset",
51
+ "ds": "2026-05-31",
52
+ "data_interval_start": datetime(2026, 5, 31, 12, 30, tzinfo=UTC),
53
+ }
54
+
55
+ index = store.store(value, rundata)
56
+
57
+ assert index["bucket"] == "test-bucket"
58
+ assert index["name"].startswith("runs/flow/asset/2026-05-31T12-30-00-00-00/")
59
+ assert index["name"].endswith(".pickle")
60
+ assert index["generation"] == 1
61
+ assert client.bucket("test-bucket").blob(index["name"]).content_type == "application/octet-stream"
62
+ assert store.retrieve(index, source_rundata=rundata, target_rundata={}) == value
63
+
64
+
65
+ def test_gcs_pickle_store_uses_metadata_blob_name() -> None:
66
+ store = GcsPickleStore("test-bucket", client=FakeClient())
67
+ rundata = {"metadata": {"blob_name": "/custom/path/value.pkl"}}
68
+
69
+ index = store.store("value", rundata)
70
+
71
+ assert index["name"] == "custom/path/value.pkl"
@@ -2,6 +2,15 @@ version = 1
2
2
  revision = 3
3
3
  requires-python = ">=3.12"
4
4
 
5
+ [[package]]
6
+ name = "annotated-doc"
7
+ version = "0.0.4"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
12
+ ]
13
+
5
14
  [[package]]
6
15
  name = "arrow"
7
16
  version = "1.3.0"
@@ -81,18 +90,6 @@ wheels = [
81
90
  { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446, upload-time = "2024-10-09T07:40:19.383Z" },
82
91
  ]
83
92
 
84
- [[package]]
85
- name = "click"
86
- version = "8.1.7"
87
- source = { registry = "https://pypi.org/simple" }
88
- dependencies = [
89
- { name = "colorama", marker = "sys_platform == 'win32'" },
90
- ]
91
- sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121, upload-time = "2023-08-17T17:29:11.868Z" }
92
- wheels = [
93
- { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941, upload-time = "2023-08-17T17:29:10.08Z" },
94
- ]
95
-
96
93
  [[package]]
97
94
  name = "colorama"
98
95
  version = "0.4.6"
@@ -397,6 +394,27 @@ wheels = [
397
394
  { url = "https://files.pythonhosted.org/packages/ee/07/44bd408781594c4d0a027666ef27fab1e441b109dc3b76b4f836f8fd04fe/jsonschema_specifications-2023.12.1-py3-none-any.whl", hash = "sha256:87e4fdf3a94858b8a2ba2778d9ba57d8a9cafca7c7489c46ba0d30a8bc6a9c3c", size = 18482, upload-time = "2023-12-25T15:16:51.997Z" },
398
395
  ]
399
396
 
397
+ [[package]]
398
+ name = "markdown-it-py"
399
+ version = "4.2.0"
400
+ source = { registry = "https://pypi.org/simple" }
401
+ dependencies = [
402
+ { name = "mdurl" },
403
+ ]
404
+ sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
405
+ wheels = [
406
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
407
+ ]
408
+
409
+ [[package]]
410
+ name = "mdurl"
411
+ version = "0.1.2"
412
+ source = { registry = "https://pypi.org/simple" }
413
+ sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
414
+ wheels = [
415
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
416
+ ]
417
+
400
418
  [[package]]
401
419
  name = "mypy"
402
420
  version = "1.14.0"
@@ -591,6 +609,15 @@ wheels = [
591
609
  { url = "https://files.pythonhosted.org/packages/77/89/bc88a6711935ba795a679ea6ebee07e128050d6382eaa35a0a47c8032bdc/pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd", size = 181537, upload-time = "2024-09-11T16:02:10.336Z" },
592
610
  ]
593
611
 
612
+ [[package]]
613
+ name = "pygments"
614
+ version = "2.20.0"
615
+ source = { registry = "https://pypi.org/simple" }
616
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
617
+ wheels = [
618
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
619
+ ]
620
+
594
621
  [[package]]
595
622
  name = "pytest"
596
623
  version = "8.3.3"
@@ -722,6 +749,19 @@ wheels = [
722
749
  { url = "https://files.pythonhosted.org/packages/65/d4/f7407c3d15d5ac779c3dd34fbbc6ea2090f77bd7dd12f207ccf881551208/rfc3987-1.3.8-py2.py3-none-any.whl", hash = "sha256:10702b1e51e5658843460b189b185c0366d2cf4cff716f13111b0ea9fd2dce53", size = 13377, upload-time = "2018-07-29T17:23:45.313Z" },
723
750
  ]
724
751
 
752
+ [[package]]
753
+ name = "rich"
754
+ version = "15.0.0"
755
+ source = { registry = "https://pypi.org/simple" }
756
+ dependencies = [
757
+ { name = "markdown-it-py" },
758
+ { name = "pygments" },
759
+ ]
760
+ sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
761
+ wheels = [
762
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
763
+ ]
764
+
725
765
  [[package]]
726
766
  name = "rpds-py"
727
767
  version = "0.20.0"
@@ -793,6 +833,15 @@ wheels = [
793
833
  { url = "https://files.pythonhosted.org/packages/3e/14/fd026bc74ded05e2351681545a5f626e78ef831f8edce064d61acd2e6ec7/ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93", size = 8679879, upload-time = "2024-10-04T13:40:25.797Z" },
794
834
  ]
795
835
 
836
+ [[package]]
837
+ name = "shellingham"
838
+ version = "1.5.4"
839
+ source = { registry = "https://pypi.org/simple" }
840
+ sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
841
+ wheels = [
842
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
843
+ ]
844
+
796
845
  [[package]]
797
846
  name = "six"
798
847
  version = "1.16.0"
@@ -804,7 +853,7 @@ wheels = [
804
853
 
805
854
  [[package]]
806
855
  name = "tinyfan"
807
- version = "0.2.9"
856
+ version = "0.4.0"
808
857
  source = { editable = "." }
809
858
  dependencies = [
810
859
  { name = "croniter" },
@@ -854,7 +903,7 @@ viz = [
854
903
 
855
904
  [package.metadata]
856
905
  requires-dist = [
857
- { name = "croniter", specifier = ">=6.0.0" },
906
+ { name = "croniter", specifier = ">=6" },
858
907
  { name = "google-cloud-bigquery", extras = ["pandas"], marker = "extra == 'bigquery'" },
859
908
  { name = "google-cloud-storage", marker = "extra == 'gcs'" },
860
909
  { name = "jsonpatch", marker = "extra == 'dev'", specifier = ">=1" },
@@ -872,7 +921,7 @@ requires-dist = [
872
921
  { name = "tinyfan", extras = ["pandas"], marker = "extra == 'all'" },
873
922
  { name = "tinyfan", extras = ["pandas"], marker = "extra == 'bigquery'" },
874
923
  { name = "tinyfan", extras = ["viz"], marker = "extra == 'all'" },
875
- { name = "typer-slim", specifier = ">=0.20.0" },
924
+ { name = "typer-slim", specifier = ">=0.24.0" },
876
925
  { name = "types-croniter", marker = "extra == 'dev'" },
877
926
  { name = "types-jsonschema", marker = "extra == 'dev'" },
878
927
  { name = "types-networkx", marker = "extra == 'dev'" },
@@ -880,17 +929,31 @@ requires-dist = [
880
929
  ]
881
930
  provides-extras = ["all", "gcs", "viz", "bigquery", "pandas", "dev"]
882
931
 
932
+ [[package]]
933
+ name = "typer"
934
+ version = "0.26.4"
935
+ source = { registry = "https://pypi.org/simple" }
936
+ dependencies = [
937
+ { name = "annotated-doc" },
938
+ { name = "colorama", marker = "sys_platform == 'win32'" },
939
+ { name = "rich" },
940
+ { name = "shellingham" },
941
+ ]
942
+ sdist = { url = "https://files.pythonhosted.org/packages/8e/d3/90c1ee19209cb59f6ad185883fd4ccfcf72f8f0bfd549d5a8b70474611d0/typer-0.26.4.tar.gz", hash = "sha256:25b128964de66c5ea36d5ac82adc579e5e113509b17469edf9f5a4a1864ff2a9", size = 201191, upload-time = "2026-05-30T17:05:04.213Z" }
943
+ wheels = [
944
+ { url = "https://files.pythonhosted.org/packages/f0/6d/5a525c69df4a90892135e5d490b00e9e46402491f3416d4395fcb0d0201e/typer-0.26.4-py3-none-any.whl", hash = "sha256:11bfd7b43557137e373c2b10f6967a555f9678a61ed72c808968b011d95534d6", size = 122436, upload-time = "2026-05-30T17:05:05.812Z" },
945
+ ]
946
+
883
947
  [[package]]
884
948
  name = "typer-slim"
885
- version = "0.20.0"
949
+ version = "0.24.0"
886
950
  source = { registry = "https://pypi.org/simple" }
887
951
  dependencies = [
888
- { name = "click" },
889
- { name = "typing-extensions" },
952
+ { name = "typer" },
890
953
  ]
891
- sdist = { url = "https://files.pythonhosted.org/packages/8e/45/81b94a52caed434b94da65729c03ad0fb7665fab0f7db9ee54c94e541403/typer_slim-0.20.0.tar.gz", hash = "sha256:9fc6607b3c6c20f5c33ea9590cbeb17848667c51feee27d9e314a579ab07d1a3", size = 106561, upload-time = "2025-10-20T17:03:46.642Z" }
954
+ sdist = { url = "https://files.pythonhosted.org/packages/a7/a7/e6aecc4b4eb59598829a3b5076a93aff291b4fdaa2ded25efc4e1f4d219c/typer_slim-0.24.0.tar.gz", hash = "sha256:f0ed36127183f52ae6ced2ecb2521789995992c521a46083bfcdbb652d22ad34", size = 4776, upload-time = "2026-02-16T22:08:51.2Z" }
892
955
  wheels = [
893
- { url = "https://files.pythonhosted.org/packages/5e/dd/5cbf31f402f1cc0ab087c94d4669cfa55bd1e818688b910631e131d74e75/typer_slim-0.20.0-py3-none-any.whl", hash = "sha256:f42a9b7571a12b97dddf364745d29f12221865acef7a2680065f9bb29c7dc89d", size = 47087, upload-time = "2025-10-20T17:03:44.546Z" },
956
+ { url = "https://files.pythonhosted.org/packages/a7/24/5480c20380dfd18cf33d14784096dca45a24eae6102e91d49a718d3b6855/typer_slim-0.24.0-py3-none-any.whl", hash = "sha256:d5d7ee1ee2834d5020c7c616ed5e0d0f29b9a4b1dd283bdebae198ec09778d0e", size = 3394, upload-time = "2026-02-16T22:08:49.92Z" },
894
957
  ]
895
958
 
896
959
  [[package]]
@@ -916,14 +979,14 @@ wheels = [
916
979
 
917
980
  [[package]]
918
981
  name = "types-networkx"
919
- version = "3.5.0.20251106"
982
+ version = "3.6.1.20260518"
920
983
  source = { registry = "https://pypi.org/simple" }
921
984
  dependencies = [
922
985
  { name = "numpy" },
923
986
  ]
924
- sdist = { url = "https://files.pythonhosted.org/packages/da/0d/0657390fb7e4fffb9e0a20d51b9b63451771c238ad7ca2eb31de12827a87/types_networkx-3.5.0.20251106.tar.gz", hash = "sha256:2f17a53a35cf6dbbb031039c19bf0f127ec1b37f3530c57a517a0dd67a42e343", size = 72307, upload-time = "2025-11-06T03:06:52.263Z" }
987
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/9f/e6fe2543aecbfe4d67fe86f280afae1e6d532c49755c5fb6c1f05f8af91e/types_networkx-3.6.1.20260518.tar.gz", hash = "sha256:af3ab153815b366c3695a0015cc75aece9b3eb0e5df56bb60ffee0b7cec8f630", size = 73977, upload-time = "2026-05-18T06:06:05.573Z" }
925
988
  wheels = [
926
- { url = "https://files.pythonhosted.org/packages/43/01/f768e7d3662841a5163f261510148a89564ffc4320568dfb98b79f8f6e68/types_networkx-3.5.0.20251106-py3-none-any.whl", hash = "sha256:674c53111d5ca6cf96eb6ccd3152d0d52c2720b8ddea8082c7b0116a21347a30", size = 160776, upload-time = "2025-11-06T03:06:50.89Z" },
989
+ { url = "https://files.pythonhosted.org/packages/1f/4f/422a91e9fa008b804746fc2ce4da5bcb7eca4bfb45c31e8282fbb3664601/types_networkx-3.6.1.20260518-py3-none-any.whl", hash = "sha256:574304deea33828fb32cdca757af84f79b67bd80164aa1725c52a95d4a9347e6", size = 162454, upload-time = "2026-05-18T06:06:04.417Z" },
927
990
  ]
928
991
 
929
992
  [[package]]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes