tinyfan 0.3.0__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 (59) hide show
  1. {tinyfan-0.3.0 → tinyfan-0.4.0}/.gitignore +4 -0
  2. {tinyfan-0.3.0 → tinyfan-0.4.0}/PKG-INFO +13 -1
  3. {tinyfan-0.3.0 → tinyfan-0.4.0}/README.md +12 -0
  4. {tinyfan-0.3.0 → tinyfan-0.4.0}/pyproject.toml +1 -1
  5. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/cli.py +6 -2
  6. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/codegen.py +97 -7
  7. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/test_codegen.py +70 -3
  8. {tinyfan-0.3.0 → tinyfan-0.4.0}/uv.lock +2 -2
  9. {tinyfan-0.3.0 → tinyfan-0.4.0}/.github/workflows/publish.yml +0 -0
  10. {tinyfan-0.3.0 → tinyfan-0.4.0}/.python-version +0 -0
  11. {tinyfan-0.3.0 → tinyfan-0.4.0}/examples/README.md +0 -0
  12. {tinyfan-0.3.0 → tinyfan-0.4.0}/examples/configmap.yaml +0 -0
  13. {tinyfan-0.3.0 → tinyfan-0.4.0}/examples/configs.py +0 -0
  14. {tinyfan-0.3.0 → tinyfan-0.4.0}/examples/getting-started.py +0 -0
  15. {tinyfan-0.3.0 → tinyfan-0.4.0}/examples/packaged/.python-version +0 -0
  16. {tinyfan-0.3.0 → tinyfan-0.4.0}/examples/packaged/Dockerfile +0 -0
  17. {tinyfan-0.3.0 → tinyfan-0.4.0}/examples/packaged/README.md +0 -0
  18. {tinyfan-0.3.0 → tinyfan-0.4.0}/examples/packaged/pyproject.toml +0 -0
  19. {tinyfan-0.3.0 → tinyfan-0.4.0}/examples/packaged/src/tinyfan_examples_packaged/__init__.py +0 -0
  20. {tinyfan-0.3.0 → tinyfan-0.4.0}/examples/packaged/src/tinyfan_examples_packaged/asset.py +0 -0
  21. {tinyfan-0.3.0 → tinyfan-0.4.0}/examples/resource.py +0 -0
  22. {tinyfan-0.3.0 → tinyfan-0.4.0}/examples/secret.yaml +0 -0
  23. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/__init__.py +0 -0
  24. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/argo_typing.py +0 -0
  25. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/config.py +0 -0
  26. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/dagviz.py +0 -0
  27. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/flow.py +0 -0
  28. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/flowrundata.py +0 -0
  29. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/py.typed +0 -0
  30. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/resources/__init__.py +0 -0
  31. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/resources/base.py +0 -0
  32. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/stores/__init__.py +0 -0
  33. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/stores/base.py +0 -0
  34. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/stores/bigquery.py +0 -0
  35. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/stores/gcs_pickle.py +0 -0
  36. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/stores/naive.py +0 -0
  37. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/utils/__init__.py +0 -0
  38. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/utils/embed.py +0 -0
  39. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/utils/exjson.py +0 -0
  40. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/utils/merge.py +0 -0
  41. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/utils/typing.py +0 -0
  42. {tinyfan-0.3.0 → tinyfan-0.4.0}/src/tinyfan/utils/yaml.py +0 -0
  43. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/__init__.py +0 -0
  44. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/codegen_samples/directory/__init__.py +0 -0
  45. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/codegen_samples/directory/asset.py +0 -0
  46. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/codegen_samples/directory/asset2.py +0 -0
  47. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/codegen_samples/singlefile.py +0 -0
  48. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/conftest.py +0 -0
  49. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/helpers.py +0 -0
  50. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/openapi2jsonschema.py +0 -0
  51. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/test_config.py +0 -0
  52. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/test_decorators.py +0 -0
  53. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/test_gcs_pickle_store.py +0 -0
  54. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/utils/__init__.py +0 -0
  55. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/utils/embed_samples/directory/__init__.py +0 -0
  56. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/utils/embed_samples/directory/dep.py +0 -0
  57. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/utils/embed_samples/singlefile.py +0 -0
  58. {tinyfan-0.3.0 → tinyfan-0.4.0}/tests/utils/test_embed.py +0 -0
  59. {tinyfan-0.3.0 → 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.3.0
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
@@ -101,6 +101,18 @@ def greeting(world: str):
101
101
  tinyfan main.py | kubectl apply -f -
102
102
  ```
103
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
+
104
116
  # Real World Example (still tiny though!)
105
117
 
106
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.3.0"
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,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,5 @@
1
1
  from tinyfan.flow import Flow, Asset, FLOW_REGISTER, DEFAULT_IMAGE
2
+ import ast
2
3
  import copy
3
4
  import importlib.util
4
5
  from typing import Self
@@ -9,7 +10,8 @@ import sys
9
10
  import re
10
11
  import json
11
12
  import os
12
- import pkgutil
13
+ from pathlib import Path
14
+ import tokenize
13
15
  import datetime
14
16
  import croniter
15
17
  from .argo_typing import ScriptTemplate, TemplateInputs, TemplateOutputs
@@ -28,7 +30,9 @@ INTERNAL_INPUT_PARAMETERS = {"rundata", "asset_name", "moduledata"}
28
30
  INTERNAL_OUTPUT_PARAMETERS = {"rundata"}
29
31
 
30
32
 
31
- def merge_template_io(template_io: dict, user_io: TemplateInputs | TemplateOutputs | None, reserved_parameter_names: set[str]) -> dict:
33
+ def merge_template_io(
34
+ template_io: dict, user_io: TemplateInputs | TemplateOutputs | None, reserved_parameter_names: set[str]
35
+ ) -> dict:
32
36
  result = copy.deepcopy(template_io)
33
37
  if user_io is None:
34
38
  return result
@@ -75,7 +79,85 @@ def encode_schedule_to_k8sname(tz: str, cron: str) -> str:
75
79
  return f"{tz}|{cron}".lower().translate(str.maketrans("* ,-/_+|", "a-cds-p-", "@"))[:58]
76
80
 
77
81
 
78
- 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."""
79
161
  try:
80
162
  package = importlib.import_module(location)
81
163
  except ModuleNotFoundError:
@@ -90,9 +172,16 @@ def import_all_submodules(location: str):
90
172
  package = importlib.util.module_from_spec(spec)
91
173
  sys.modules[spec.name] = package
92
174
  spec.loader.exec_module(package)
93
- if package.__name__ != "__tinyfan_repo__":
94
- for _, name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + "."):
95
- 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)
96
185
 
97
186
 
98
187
  def assetname2template(name: str):
@@ -496,6 +585,7 @@ def codegen(
496
585
  embedded: bool = False,
497
586
  container: ScriptTemplate = {},
498
587
  serviceAccountName: str | None = None,
588
+ import_all: bool = False,
499
589
  ) -> str:
500
590
  """Generate argocd workflow resource as yaml from tinyfan definitions"""
501
591
  try:
@@ -503,7 +593,7 @@ def codegen(
503
593
  if flow:
504
594
  result = AssetTree(flow).compile(embedded, container, serviceAccountName)
505
595
  elif location:
506
- import_all_submodules(location)
596
+ import_asset_modules(location, import_all=import_all)
507
597
  result = "\n---\n".join(
508
598
  AssetTree(flow).compile(embedded, container, serviceAccountName) for flow in FLOW_REGISTER.values()
509
599
  )
@@ -4,6 +4,7 @@ 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
8
9
  import yaml
9
10
 
@@ -121,9 +122,7 @@ def test_codegen_asset_inputs_outputs(validate_crds):
121
122
  validate_crds(actual)
122
123
  manifest = next(yaml.load_all(actual, yaml.Loader))
123
124
  template = next(
124
- template
125
- for template in manifest["spec"]["workflowSpec"]["templates"]
126
- if template["name"] == "asset1"
125
+ template for template in manifest["spec"]["workflowSpec"]["templates"] if template["name"] == "asset1"
127
126
  )
128
127
  input_params = {param["name"]: param for param in template["inputs"]["parameters"]}
129
128
  output_params = {param["name"]: param for param in template["outputs"]["parameters"]}
@@ -170,6 +169,74 @@ def test_codegen_directory(validate_crds):
170
169
  assert_code_is_running(actual)
171
170
 
172
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
+
173
240
  def test_rundata_data_interval(sample_flow):
174
241
  tree = AssetTree(sample_flow)
175
242
  node = list(tree.nodes.values())[0]
@@ -853,7 +853,7 @@ wheels = [
853
853
 
854
854
  [[package]]
855
855
  name = "tinyfan"
856
- version = "0.3.0"
856
+ version = "0.4.0"
857
857
  source = { editable = "." }
858
858
  dependencies = [
859
859
  { name = "croniter" },
@@ -903,7 +903,7 @@ viz = [
903
903
 
904
904
  [package.metadata]
905
905
  requires-dist = [
906
- { name = "croniter", specifier = ">=6.0.0" },
906
+ { name = "croniter", specifier = ">=6" },
907
907
  { name = "google-cloud-bigquery", extras = ["pandas"], marker = "extra == 'bigquery'" },
908
908
  { name = "google-cloud-storage", marker = "extra == 'gcs'" },
909
909
  { name = "jsonpatch", marker = "extra == 'dev'", specifier = ">=1" },
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