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.
- {tinyfan-0.2.9 → tinyfan-0.4.0}/.gitignore +4 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/PKG-INFO +15 -5
- {tinyfan-0.2.9 → tinyfan-0.4.0}/README.md +12 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/pyproject.toml +3 -5
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/argo_typing.py +11 -1
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/cli.py +6 -2
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/codegen.py +173 -44
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/flow.py +11 -2
- tinyfan-0.4.0/src/tinyfan/stores/gcs_pickle.py +149 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/utils/embed.py +2 -2
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/test_codegen.py +100 -0
- tinyfan-0.4.0/tests/test_gcs_pickle_store.py +71 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/uv.lock +86 -23
- tinyfan-0.2.9/src/tinyfan/stores/gcs_pickle.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/.github/workflows/publish.yml +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/.python-version +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/README.md +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/configmap.yaml +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/configs.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/getting-started.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/packaged/.python-version +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/packaged/Dockerfile +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/packaged/README.md +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/packaged/pyproject.toml +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/packaged/src/tinyfan_examples_packaged/__init__.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/packaged/src/tinyfan_examples_packaged/asset.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/resource.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/examples/secret.yaml +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/__init__.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/config.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/dagviz.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/flowrundata.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/py.typed +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/resources/__init__.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/resources/base.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/stores/__init__.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/stores/base.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/stores/bigquery.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/stores/naive.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/utils/__init__.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/utils/exjson.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/utils/merge.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/utils/typing.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/src/tinyfan/utils/yaml.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/__init__.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/codegen_samples/directory/__init__.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/codegen_samples/directory/asset.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/codegen_samples/directory/asset2.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/codegen_samples/singlefile.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/conftest.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/helpers.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/openapi2jsonschema.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/test_config.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/test_decorators.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/utils/__init__.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/utils/embed_samples/directory/__init__.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/utils/embed_samples/directory/dep.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/utils/embed_samples/singlefile.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/utils/test_embed.py +0 -0
- {tinyfan-0.2.9 → tinyfan-0.4.0}/tests/utils/test_exjson.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tinyfan
|
|
3
|
-
Version: 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
|
|
@@ -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
|
|
15
|
+
Requires-Dist: croniter>=6
|
|
18
16
|
Requires-Dist: pyyaml>=6.0.3
|
|
19
|
-
Requires-Dist: typer-slim>=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.
|
|
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
|
|
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
|
|
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
|
|
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__
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
949
|
+
version = "0.24.0"
|
|
886
950
|
source = { registry = "https://pypi.org/simple" }
|
|
887
951
|
dependencies = [
|
|
888
|
-
{ name = "
|
|
889
|
-
{ name = "typing-extensions" },
|
|
952
|
+
{ name = "typer" },
|
|
890
953
|
]
|
|
891
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
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/
|
|
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.
|
|
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/
|
|
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/
|
|
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
|
|
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
|
|
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
|