tinyfan 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tinyfan/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .flow import Flow, Asset, asset
2
+ from .stores.base import StoreBase
3
+ from .stores.naive import NaiveStore
4
+
5
+ __all__ = ["Flow", "Asset", "asset", "StoreBase", "NaiveStore"]
tinyfan/cli.py ADDED
@@ -0,0 +1,21 @@
1
+ import typer
2
+
3
+ from .codegen import codegen, DEFAULT_IMAGE
4
+
5
+
6
+ def tinyfan(location: str, embedded: bool = False, image: str = DEFAULT_IMAGE):
7
+ """
8
+ Generate argocd workflow resource as yaml from tinyfan definitions
9
+ """
10
+ if location.endswith('.py'):
11
+ print(codegen(location=location, embedded=True, image=image))
12
+ else:
13
+ print(codegen(location=location, embedded=embedded, image=image))
14
+
15
+
16
+ def main():
17
+ return typer.run(tinyfan)
18
+
19
+
20
+ if __name__ == "__main__":
21
+ main()
tinyfan/codegen.py ADDED
@@ -0,0 +1,304 @@
1
+ from tinyfan.flow import Flow, Asset, FLOW_CATALOG
2
+ import importlib.util
3
+ from typing import Self
4
+ import inspect
5
+ import sys
6
+ import re
7
+ import json
8
+ import yaml
9
+ import os
10
+ import pkgutil
11
+ from .utils.embed import embed
12
+
13
+ VALID_CRON_REGEXP = r"^(?:(?:(?:(?:\d+,)+\d+|(?:\d+(?:\/|-|#)\d+)|\d+L?|\*(?:\/\d+)?|L(?:-\d+)?|\?|[A-Z]{3}(?:-[A-Z]{3})?) ?){5,7})|(@hourly|@daily|@midnight|@weekly|@monthly|@yearly|@annually)$"
14
+ VALID_DEPENDS_REGEXP = r"^(?!.*\.[A-Za-z0-9_]+\.[A-Za-z0-9_]+)(?!.*\.(?!Succeeded|Failed|Errored|Skipped|Omitted|Daemoned)\w+)(?!.*(?:&&|\|\|)\s*(?:&&|\|\|))(?!.*!!)[A-Za-z0-9_&|!().\s]+$"
15
+
16
+ EXTRACT_DEPENDS_REGEXP = r"(?<!\.)\b([A-Za-z0-9_]+)\b"
17
+
18
+ RUNDATA_FILE_PATH = "/tmp/tinyfan/{flow_name}/{asset_name}/rundata.json"
19
+
20
+ DEFAULT_IMAGE = "python:alpine"
21
+
22
+
23
+ def brackets_balanced(code: str) -> bool:
24
+ code = re.sub(r"[^()]", "", code)
25
+ while "()" in code:
26
+ code = code.replace("()", "")
27
+ return code == ""
28
+
29
+
30
+ def get_root_path(func):
31
+ filename = inspect.getfile(func)
32
+ path = os.path.abspath(os.path.dirname(filename))
33
+ if not os.path.exists(os.path.join(path, "__init__.py")):
34
+ return filename
35
+ while os.path.exists(os.path.join(os.path.dirname(path), "__init__.py")):
36
+ path = os.path.dirname(path)
37
+ return path
38
+
39
+
40
+ def encode_schedule_to_k8sname(tz: str, cron: str) -> str:
41
+ return f"{tz}|{cron}".lower().translate(str.maketrans("* ,-/_+|", "a-cds-p-", "@"))[:58]
42
+
43
+
44
+ def import_all_submodules(location: str):
45
+ try:
46
+ package = importlib.import_module(location)
47
+ except ModuleNotFoundError:
48
+ if not location.endswith(".py"):
49
+ pkg_name = os.path.dirname(location)
50
+ location = os.path.join(location, "__init__.py")
51
+ else:
52
+ pkg_name = "__tinyfan_repo__"
53
+ spec = importlib.util.spec_from_file_location(pkg_name, location)
54
+ if spec is None or spec.loader is None:
55
+ raise ValueError(f"fail to load module from `{location}.`")
56
+ package = importlib.util.module_from_spec(spec)
57
+ sys.modules[spec.name] = package
58
+ spec.loader.exec_module(package)
59
+ if package.__name__ != "__tinyfan_repo__":
60
+ for _, name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + "."):
61
+ importlib.import_module(name)
62
+
63
+
64
+ class AssetNode:
65
+ asset: Asset
66
+ children: list[Self]
67
+ parents: list[Self]
68
+
69
+ def __init__(self, asset):
70
+ self.asset = asset
71
+ self.children = []
72
+ self.parents = []
73
+
74
+ def __hash__(self):
75
+ return hash(self.asset.name)
76
+
77
+ def asset_module_name(self):
78
+ mod = inspect.getmodule(self.asset.func)
79
+ return mod.__name__ if mod else None
80
+
81
+ def rundatatmpl(self):
82
+ return re.sub(
83
+ r'"(\{\{tasks\.[^.]+\.outputs\.parameters\.rundata\}\})"',
84
+ r"\1",
85
+ json.dumps(
86
+ {
87
+ "ds": "{{=sprig.date(workflow.scheduledTime)}}",
88
+ "ts": "{{workflow.scheduledTime}}",
89
+ "parents": {
90
+ p.asset.name: "{{tasks.%s.outputs.parameters.rundata}}" % p.asset.name for p in self.parents
91
+ },
92
+ "asset_name": self.asset.name,
93
+ "flow_name": self.asset.flow.name,
94
+ "module_name": self.asset_module_name(),
95
+ }
96
+ ),
97
+ )
98
+
99
+ def relatives(self, result: set[Self] | None = None) -> set[Self]:
100
+ if result is None:
101
+ result = set[Self]([self])
102
+ for o in self.parents + self.children:
103
+ if o not in result:
104
+ result.add(o)
105
+ o.relatives(result)
106
+ return result
107
+
108
+
109
+ class AssetTree:
110
+ flow: Flow
111
+ nodes: dict[str, AssetNode]
112
+
113
+ def __init__(self, flow: Flow):
114
+ self.flow = flow
115
+ self.nodes = {name: AssetNode(asset) for name, asset in flow.assets.items()}
116
+ for node in self.nodes.values():
117
+ sigs = inspect.signature(node.asset.func)
118
+ func_param_names = list(sigs.parameters.keys())
119
+ params_ids = set(name for name in func_param_names if name in self.flow.assets)
120
+
121
+ if node.asset.depends is not None:
122
+ depends_ids = set(re.findall(EXTRACT_DEPENDS_REGEXP, node.asset.depends))
123
+ node.parents = [self.nodes[n] for n in depends_ids.union(params_ids)]
124
+ node.asset.depends = f"({node.asset.depends}) && {' && '.join(params_ids - depends_ids)}"
125
+ for p in node.parents:
126
+ p.children.append(node)
127
+ else:
128
+ node.parents = [self.nodes[n] for n in params_ids]
129
+ for p in node.parents:
130
+ p.children.append(node)
131
+ node.asset.depends = " && ".join(params_ids)
132
+
133
+ def compile(
134
+ self,
135
+ embedded: bool = True,
136
+ image: str = DEFAULT_IMAGE,
137
+ ) -> str:
138
+ if len(self.nodes) == 0:
139
+ return ""
140
+
141
+ if embedded:
142
+ source = embed("tinyfan", excludes=["codegen.py", "utils/embed.py", "**/__pycache__/*"], minify=False)
143
+ root_location = get_root_path(list(self.nodes.values())[0].asset.func)
144
+ source += embed(root_location)
145
+ if not root_location.endswith(".py"):
146
+ source += "from {{inputs.parameters.module_name}}" " import {{inputs.parameters.asset_name}}\n"
147
+ source += "asset = {{inputs.parameters.asset_name}}.asset\n"
148
+ else:
149
+ source = (
150
+ "from {{inputs.parameters.module_name}}"
151
+ " import {{inputs.parameters.asset_name}}\n"
152
+ "asset = {{inputs.parameters.asset_name}}.asset\n"
153
+ )
154
+ source += (
155
+ "_, rundata = asset.run({{inputs.parameters.rundata}})\n"
156
+ "import os\n"
157
+ "from tinyfan.utils.exjson import dumps\n"
158
+ f"path = '{RUNDATA_FILE_PATH}'"
159
+ ".format(flow_name=asset.flow.name, asset_name=asset.name)\n"
160
+ "os.makedirs(os.path.dirname(path), exist_ok=True)\n"
161
+ "with open(path, 'w') as f:\n"
162
+ " f.write(dumps(rundata))\n"
163
+ )
164
+
165
+ relatives_by_schedules: dict[tuple[str, str], set[AssetNode]] = {}
166
+ for node in self.nodes.values():
167
+ if node.asset.schedule is None:
168
+ continue
169
+ schedule = (node.asset.tz or self.flow.tz, node.asset.schedule)
170
+ if schedule not in relatives_by_schedules:
171
+ relatives_by_schedules[schedule] = set()
172
+ for o in node.relatives():
173
+ relatives_by_schedules[schedule].add(o)
174
+
175
+ flow_image = self.flow.image or image
176
+
177
+ manifests = [
178
+ {
179
+ "apiVersion": "argoproj.io/v1alpha1",
180
+ "kind": "WorkflowTemplate",
181
+ "metadata": {
182
+ "name": self.flow.name,
183
+ },
184
+ "spec": {
185
+ "templateDefaults": {
186
+ "script": {
187
+ "name": self.flow.name,
188
+ "image": flow_image,
189
+ "command": ["python"],
190
+ "source": source,
191
+ },
192
+ },
193
+ "templates": [
194
+ {
195
+ "name": node.asset.name,
196
+ "synchronization": {"mutexes": [{"name": f"{self.flow.name}-{node.asset.name}"}]},
197
+ **(
198
+ {} if node.asset.image == self.flow.image else {
199
+ "script": {
200
+ "name": node.asset.name,
201
+ "image": node.asset.image or flow_image,
202
+ "command": ["python"],
203
+ "source": source,
204
+ # "command": ["python"]
205
+ }
206
+ }
207
+ ),
208
+ "inputs": {
209
+ "parameters": [
210
+ {
211
+ "name": "rundata",
212
+ "value": "{{= nil }}",
213
+ },
214
+ {
215
+ "name": "asset_name",
216
+ "value": "{{= nil }}",
217
+ },
218
+ {
219
+ "name": "module_name",
220
+ "value": "{{= nil }}",
221
+ },
222
+ ]
223
+ },
224
+ "outputs": {
225
+ "parameters": [
226
+ {
227
+ "name": "rundata",
228
+ "valueFrom": {
229
+ "path": RUNDATA_FILE_PATH.format(
230
+ flow_name=self.flow.name, asset_name=node.asset.name
231
+ )
232
+ },
233
+ }
234
+ ],
235
+ },
236
+ }
237
+ for node in self.nodes.values()
238
+ ],
239
+ },
240
+ }
241
+ ] + [
242
+ {
243
+ "apiVersion": "argoproj.io/v1alpha1",
244
+ "kind": "CronWorkflow",
245
+ "metadata": {
246
+ "name": f"""{self.flow.name}-{ encode_schedule_to_k8sname(tz, schedule) }""",
247
+ "generateName": f"""{self.flow.name}-{ encode_schedule_to_k8sname(tz, schedule) }-""",
248
+ },
249
+ "spec": {
250
+ "schedule": schedule,
251
+ "timezone": tz,
252
+ "workflowSpec": {
253
+ "entrypoint": "flow",
254
+ "templates": [
255
+ {
256
+ "name": "flow",
257
+ "dag": {
258
+ "tasks": [
259
+ {
260
+ "name": node.asset.name,
261
+ "depends": node.asset.depends,
262
+ "templateRef": {
263
+ "name": self.flow.name,
264
+ "template": node.asset.name,
265
+ },
266
+ "arguments": {
267
+ "parameters": [
268
+ {
269
+ "name": "rundata",
270
+ "value": node.rundatatmpl(),
271
+ },
272
+ {
273
+ "name": "asset_name",
274
+ "value": node.asset.name,
275
+ },
276
+ {
277
+ "name": "module_name",
278
+ "value": node.asset_module_name(),
279
+ },
280
+ ]
281
+ },
282
+ }
283
+ for node in relatives
284
+ ]
285
+ },
286
+ }
287
+ ],
288
+ },
289
+ },
290
+ }
291
+ for (tz, schedule), relatives in relatives_by_schedules.items()
292
+ ]
293
+ return yaml.dump_all(manifests)
294
+
295
+
296
+ def codegen(
297
+ location: str | None = None,
298
+ embedded: bool = False,
299
+ image: str = DEFAULT_IMAGE,
300
+ ) -> str:
301
+ """Generate argocd workflow resource as yaml from tinyfan definitions"""
302
+ if location:
303
+ import_all_submodules(location)
304
+ return "\n---\n".join(AssetTree(flow).compile(embedded, image) for flow in FLOW_CATALOG.values())
tinyfan/flow.py ADDED
@@ -0,0 +1,96 @@
1
+ import inspect
2
+ from .stores.base import StoreBase
3
+ from .stores.naive import NaiveStore
4
+ from .flowrundata import FlowRunData, StoreIdx, UMeta
5
+ from dataclasses import dataclass, field
6
+ from typing import Callable, Any, Generic, TypeVar, Mapping
7
+
8
+ FLOW_CATALOG = {}
9
+
10
+ Res = TypeVar("Res", bound=Mapping)
11
+ Ret = TypeVar("Ret", bound=Mapping)
12
+
13
+
14
+ @dataclass
15
+ class Flow(Generic[Res]):
16
+ name: str
17
+ image: str | None = None
18
+ tz: str = "UTC"
19
+ assets: dict[str, "Asset[Any, Res, Any, Any]"] = field(default_factory=dict)
20
+ resources: Res | None = None
21
+ store: StoreBase = field(default_factory=NaiveStore)
22
+
23
+ def __post_init__(self):
24
+ FLOW_CATALOG[self.name] = self
25
+
26
+
27
+ DEFAULT_FLOW: Flow[Any] = Flow("tinyfan", image="python:alpine")
28
+
29
+
30
+ @dataclass
31
+ class Asset(Generic[Ret, Res, UMeta, StoreIdx]):
32
+ flow: Flow[Res]
33
+ func: Callable[..., Ret]
34
+ store: StoreBase[Ret, StoreIdx]
35
+ image: str | None = None
36
+ schedule: str | None = None
37
+ tz: str | None = None
38
+ metadata: UMeta | None = None
39
+ depends: str | None = None
40
+ name: str = field(init=False)
41
+
42
+ def __post_init__(self):
43
+ self.name = self.func.__name__
44
+ self.flow.assets[self.name] = self
45
+
46
+ def run(self, rundata: FlowRunData[UMeta, StoreIdx] | None = None) -> tuple[Ret, FlowRunData[UMeta, StoreIdx]]:
47
+ rundata = rundata or {}
48
+ sigs = inspect.signature(self.func)
49
+ func_param_names = list(sigs.parameters.keys())
50
+ if self.metadata is not None:
51
+ rundata["metadata"] = self.metadata
52
+ rundata["asset_name"] = self.name
53
+ rundata["flow_name"] = self.flow.name
54
+ mod = inspect.getmodule(self.func)
55
+ if mod and mod.__name__:
56
+ rundata["module_name"] = mod.__name__
57
+ params = {k: v for k, v in rundata.items() if k in func_param_names}
58
+ if self.flow.resources:
59
+ for k, v in self.flow.resources:
60
+ if k in func_param_names:
61
+ params[k] = v
62
+
63
+ parent_flowrundatas: dict[str, FlowRunData] = rundata.get("parents") or {}
64
+ for name, prundata in parent_flowrundatas.items():
65
+ parent = self.flow.assets[name]
66
+ index = prundata.get("store_entry_idx", None)
67
+ data = parent.store.retrieve(index, source_rundata=prundata, target_rundata=rundata)
68
+ if name in func_param_names:
69
+ params[name] = data
70
+ ret = self.func(**params)
71
+ store_entry_idx = self.store.store(ret, rundata)
72
+ rundata["store_entry_idx"] = store_entry_idx
73
+ if "parents" in rundata:
74
+ del rundata["parents"]
75
+ return (ret, rundata)
76
+
77
+
78
+ def asset(
79
+ flow: Flow = DEFAULT_FLOW,
80
+ schedule: str | None = None,
81
+ depends: str | None = None,
82
+ store: StoreBase | None = None,
83
+ ):
84
+ class wrapper(Generic[Ret]):
85
+ func: Callable[..., Ret]
86
+ asset: Asset
87
+
88
+ def __init__(self, func: Callable[..., Ret]):
89
+ self.func = func
90
+ self.asset = Asset(flow, func=func, depends=depends, schedule=schedule, store=store or flow.store)
91
+
92
+ def __call__(self, *args, **kwargs) -> Ret:
93
+ res = self.func(*args, **kwargs)
94
+ return res
95
+
96
+ return wrapper
tinyfan/flowrundata.py ADDED
@@ -0,0 +1,24 @@
1
+ from typing import TypedDict
2
+ from typing import Generic, TypeVar
3
+ from .utils.exjson import SerializableDict
4
+
5
+ UMeta = TypeVar("UMeta", bound=SerializableDict)
6
+ StoreIdx = TypeVar("StoreIdx", bound=SerializableDict)
7
+
8
+
9
+ class FlowRunData(TypedDict, Generic[UMeta, StoreIdx], total=False):
10
+ # asset bound data
11
+ metadata: UMeta
12
+ asset_name: str
13
+ flow_name: str
14
+ module_name: str | None
15
+
16
+ # runtime data
17
+ ds: str
18
+ ts: str
19
+ parents: dict[str, "FlowRunData"] | None
20
+
21
+ # generated data
22
+ store_entry_idx: StoreIdx | None
23
+ # data_interval_start: datetime
24
+
File without changes
tinyfan/stores/base.py ADDED
@@ -0,0 +1,30 @@
1
+ from abc import abstractmethod
2
+ from typing import Generic, TypeVar
3
+ from ..flowrundata import FlowRunData, StoreIdx, UMeta
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ class StoreBase(Generic[T, StoreIdx]):
9
+ @staticmethod
10
+ @abstractmethod
11
+ def id() -> str:
12
+ return "tinyfan.basestore"
13
+
14
+ @abstractmethod
15
+ def store(
16
+ self,
17
+ value: T,
18
+ rundata: FlowRunData,
19
+ ) -> StoreIdx:
20
+ """should return store index, which is used when retrieve back"""
21
+ pass
22
+
23
+ @abstractmethod
24
+ def retrieve(
25
+ self,
26
+ index: StoreIdx,
27
+ source_rundata: FlowRunData[UMeta, StoreIdx],
28
+ target_rundata: FlowRunData,
29
+ ) -> T:
30
+ pass
@@ -0,0 +1,49 @@
1
+ """
2
+ from .base import StoreBase, PredefinedParams, Asset
3
+ import pickle
4
+ from typing import TypedDict, Literal
5
+ from google.cloud import bigquery
6
+ from pandas import DataFrame
7
+
8
+ DataType = Literal["Pandas.DataFrame", "object"]
9
+
10
+ T = DataFrame
11
+ Idx = TypedDict("Idx", {
12
+ "project_id": str,
13
+ "dataset_id": str,
14
+ "table_id": str,
15
+ })
16
+ Meta = TypedDict("Meta", {
17
+ "bq_table_id": str,
18
+ })
19
+
20
+ class BigqueryStore(StoreBase[Idx, Meta, T]):
21
+ project_id: str
22
+ dataset_id: str|None
23
+
24
+ @staticmethod
25
+ def id() -> str:
26
+ return "tinyfan.bigquery"
27
+
28
+ def __init__(self, project_id:str, dataset_id:str|None = None):
29
+ self.project_id = project_id
30
+ self.dataset_id = dataset_id
31
+
32
+ def store(self, data: T, asset: Asset, params: PredefinedParams) -> Idx:
33
+ client = bigquery.Client()
34
+ ts = params.get('ts')
35
+ table_id = asset.name
36
+ if asset.user_metadata and 'table_id' in asset.user_metadata:
37
+ table_id = asset.user_metadata['table_id']
38
+ if '.' not in table_id:
39
+ if self.dataset_id is None:
40
+ raise ValueError(f"`{table_id}` is not valid. set the id as `<dataset-id>.<table-id>`, or provide `dataset_id` at store level.")
41
+ table_id = self.dataset_id + '.' + table_id
42
+ if isinstance(data, DataFrame):
43
+ return pandas_gbq.to_gbq(data, table_id, project_id=self.project_id)
44
+ else:
45
+ raise ValueError(f"Unsupported data type `{type(data)}` for store `{self.id()}`", )
46
+
47
+ def retrieve(self, index: Idx, _: dict | None = None) -> T:
48
+ return pandas_gbq.from_gbq(index['table_id'], project_id=self.project_id)
49
+ """
tinyfan/stores/gcs.py ADDED
File without changes
@@ -0,0 +1,37 @@
1
+ from .base import StoreBase, FlowRunData
2
+ import pickle
3
+ import zlib
4
+ import base64
5
+
6
+
7
+ def encode(s: str | bytes) -> str:
8
+ b = s.encode() if isinstance(s, str) else s
9
+ return base64.b64encode(zlib.compress(b, 9)).decode()
10
+
11
+
12
+ def decode(s: str) -> bytes:
13
+ return zlib.decompress(base64.b64decode(s))
14
+
15
+
16
+ class NaiveStore(StoreBase[object, str]):
17
+ @staticmethod
18
+ def id() -> str:
19
+ return "tinyfan.naivestore"
20
+
21
+ def store(
22
+ self,
23
+ value: object,
24
+ rundata: FlowRunData,
25
+ ) -> str:
26
+ del rundata
27
+ """should return store index, which is used when retrieve back"""
28
+ return encode(pickle.dumps(value))
29
+
30
+ def retrieve(
31
+ self,
32
+ index: str,
33
+ source_rundata: FlowRunData,
34
+ target_rundata: FlowRunData,
35
+ ) -> object:
36
+ del source_rundata, target_rundata
37
+ return pickle.loads(decode(index))
File without changes
tinyfan/utils/embed.py ADDED
@@ -0,0 +1,129 @@
1
+ import os
2
+ import base64
3
+ import importlib
4
+ import zipfile
5
+ from pathlib import Path
6
+ import io
7
+ import ast
8
+
9
+ DEFAULT_INCLUDES = ["**/*.*"]
10
+ DEFAULT_EXCLUDES = ["**/__pycache__/*"]
11
+
12
+
13
+ class TypeHintRemover(ast.NodeTransformer):
14
+ def visit_FunctionDef(self, node):
15
+ node.returns = None
16
+ if node.args.args:
17
+ for arg in node.args.args:
18
+ arg.annotation = None
19
+ self.generic_visit(node)
20
+ return node
21
+
22
+ def visit_AnnAssign(self, node):
23
+ if node.value is None:
24
+ return None
25
+ return ast.Assign([node.target], node.value)
26
+
27
+ def visit_Import(self, node):
28
+ node.names = [n for n in node.names if n.name != "typing"]
29
+ return node if node.names else None
30
+
31
+ def visit_ImportFrom(self, node):
32
+ return node if node.module != "typing" else None
33
+
34
+
35
+ def remove_type_hint(source: str):
36
+ parsed_source = ast.parse(source)
37
+ transformed = TypeHintRemover().visit(parsed_source)
38
+ return ast.unparse(ast.fix_missing_locations(transformed))
39
+
40
+
41
+ def zip_directory(
42
+ dir_path: str,
43
+ includes: list[str] = DEFAULT_INCLUDES,
44
+ excludes: list[str] = DEFAULT_EXCLUDES,
45
+ minify: bool = False,
46
+ ) -> tuple[bytes, str]:
47
+ dir_path = os.path.abspath(dir_path)
48
+ parent_dir = os.path.dirname(dir_path)
49
+ base_name = os.path.basename(dir_path)
50
+ buf = io.BytesIO()
51
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
52
+ path = Path(dir_path)
53
+ files = set().union(*map(path.glob, includes))
54
+ files -= set().union(*map(path.glob, excludes))
55
+ for file_path in map(str, files):
56
+ arcname = os.path.relpath(file_path, start=parent_dir)
57
+ with open(file_path, "r") as f:
58
+ try:
59
+ source = f.read()
60
+ except Exception as e:
61
+ raise ValueError(f"fail to zip directory {dir_path}: fail to read file {file_path}") from e
62
+ if minify:
63
+ source = remove_type_hint(source)
64
+ zf.writestr(arcname, source)
65
+ buf.seek(0)
66
+ return buf.read(), base_name
67
+
68
+
69
+ def embed_singlefile(path: str) -> str:
70
+ with open(path, "r") as f:
71
+ sourcecode = f.read()
72
+ return sourcecode
73
+
74
+
75
+ def embed_directory(
76
+ dir_path: str,
77
+ main_module: str | None = None,
78
+ includes: list[str] = DEFAULT_INCLUDES,
79
+ excludes: list[str] = DEFAULT_EXCLUDES,
80
+ minify=True,
81
+ ) -> str:
82
+ zip_bytes, base_name = zip_directory(dir_path, includes, excludes, minify)
83
+ encoded = base64.b64encode(zip_bytes).decode("utf-8")
84
+ snippet = f"""
85
+ import sys, base64, tempfile
86
+ zip_data = base64.b64decode('{encoded}')
87
+ with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp_file:
88
+ tmp_file.write(zip_data)
89
+ sys.path.append(tmp_file.name)
90
+ """
91
+ # snippet += f'import {main_module or base_name}\n'
92
+ return snippet
93
+
94
+
95
+ def embed_path(
96
+ path: str,
97
+ main_module: str | None = None,
98
+ includes: list[str] = DEFAULT_INCLUDES,
99
+ excludes: list[str] = DEFAULT_EXCLUDES,
100
+ ) -> str:
101
+ if os.path.isfile(path):
102
+ return embed_singlefile(path)
103
+ else:
104
+ return embed_directory(path, main_module, includes, excludes)
105
+
106
+
107
+ def embed_module(
108
+ module_name: str,
109
+ includes: list[str] = DEFAULT_INCLUDES,
110
+ excludes: list[str] = DEFAULT_EXCLUDES,
111
+ minify=True,
112
+ ) -> str:
113
+ mod = importlib.import_module(module_name)
114
+ if mod.__file__ is None:
115
+ raise ValueError(f"{module_name}.__file__ is None.")
116
+ pkg_dir = os.path.dirname(mod.__file__)
117
+ return embed_directory(pkg_dir, module_name, includes, excludes, minify)
118
+
119
+
120
+ def embed(
121
+ path_or_module: str,
122
+ includes: list[str] = DEFAULT_INCLUDES,
123
+ excludes: list[str] = DEFAULT_EXCLUDES,
124
+ minify=False,
125
+ ) -> str:
126
+ try:
127
+ return embed_module(path_or_module, includes, excludes, minify)
128
+ except ModuleNotFoundError:
129
+ return embed_path(path_or_module, None, includes, excludes)
@@ -0,0 +1,30 @@
1
+ from datetime import datetime
2
+ import json
3
+ from typing import Mapping, TypeAlias, Sequence
4
+
5
+
6
+ SerializableDict: TypeAlias = (
7
+ Mapping[str, "SerializableDict"] | Sequence["SerializableDict"] | str | int | float | bool | datetime | None
8
+ )
9
+
10
+
11
+ def default(obj):
12
+ if isinstance(obj, datetime):
13
+ return obj.isoformat()
14
+ else:
15
+ return obj
16
+
17
+
18
+ def object_hook(obj):
19
+ try:
20
+ return datetime.fromisoformat(obj)
21
+ except ValueError:
22
+ return obj
23
+
24
+
25
+ def dumps(obj: object):
26
+ return json.dumps(obj, default=default)
27
+
28
+
29
+ def loads(obj: str):
30
+ return json.loads(obj, object_hook=object_hook)
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.1
2
+ Name: tinyfan
3
+ Version: 0.0.1
4
+ Summary: Tiny Argo Workflows python devkit
5
+ Author-email: eunchuldev <eunchulsong@gmail.com>
6
+ Project-URL: Documentation, https://github.com/eunchuldev/tinyfan#readme
7
+ Project-URL: Issues, https://github.com/eunchuldev/tinyfan/issues
8
+ Project-URL: Source, https://github.com/eunchuldev/tinyfan
9
+ Keywords: argo-workflow,tinyfan,workflow,pipeline
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: croniter>=3.0.3
18
+ Requires-Dist: pyyaml>=6.0.2
19
+ Requires-Dist: typer-slim>=0.15.1
20
+ Provides-Extra: all
21
+ Requires-Dist: tinyfan[gcp]; extra == "all"
22
+ Requires-Dist: tinyfan[pandas]; extra == "all"
23
+ Provides-Extra: gcp
24
+ Requires-Dist: google-cloud-storage; extra == "gcp"
25
+ Requires-Dist: google-cloud-bigquery[pandas]; extra == "gcp"
26
+ Provides-Extra: pandas
27
+ Requires-Dist: pandas; extra == "pandas"
28
+ Requires-Dist: pyarrow; extra == "pandas"
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=8.3.3; extra == "dev"
31
+ Requires-Dist: jsonpatch>=1; extra == "dev"
32
+ Requires-Dist: jsonschema[format]; extra == "dev"
33
+ Requires-Dist: mypy>=1.12; extra == "dev"
34
+ Requires-Dist: ruff; extra == "dev"
35
+ Requires-Dist: types-PyYAML; extra == "dev"
36
+ Requires-Dist: types-jsonschema; extra == "dev"
37
+
38
+ # Tinyfan
39
+
40
+ Tinyfan: Minimalist Data Pipeline Kit - Generate Argo Workflows with Python
41
+
42
+ # Features
43
+
44
+ * Generate Argo Workflows manifests from Python data pipeline definitions.
45
+ * Ease of Use and highly extendable
46
+ * Intuitive data model abstraction: let Argo handle orchestration—we focus on the data.
47
+ * Argo Workflows is notably lightweight and powerful – and so are we!
48
+ * Enhanced DevOps Experience: easly testable, Cloud Native and GitOps-ready.
49
+
50
+ # Our Goal
51
+
52
+ * **Minimize mental overhead** when building data pipelines.
53
+
54
+ # Not Our Goal
55
+
56
+ * **Full-featured orchestration framework:** We don't aim to be a battery-powered, comprehensive data pipeline orchestration solution.
57
+ No databases, web servers, or controllers—just a data pipeline compiler. Let's Algo Workflows handle all the complexity.
58
+
59
+ # Installation
60
+
61
+ ```
62
+ # Requires Python 3.10+
63
+ pipx install tinyfan
64
+ ```
65
+
66
+ # Tiny Example
67
+
68
+ ```python
69
+ # main.py
70
+
71
+ # Asset definitions
72
+
73
+ from tinyfan import asset
74
+
75
+ @asset(schedule="*/3 * * * *")
76
+ def world() -> str:
77
+ return "world"
78
+
79
+ @asset()
80
+ def greeting(world: str):
81
+ print("hello " + world)
82
+ ```
83
+
84
+ ```shell
85
+ # Apply the changes to argo workflow
86
+
87
+ tinyfan template main.py | kubectl apply -f -
88
+ ```
89
+
90
+ # Real World Example (still tiny though!)
91
+
92
+ ```python
93
+
94
+ from tinyfan import GcsStore, Flow
95
+ import os
96
+
97
+ # Build time configs:
98
+ # Pod configs are settled on the build time.
99
+ image = os.environ["IMAGE_NAME"]
100
+
101
+ # Runtime Configs:
102
+ # Other configs are revisited on runetime.
103
+ some_secret = os.environ[""]
104
+
105
+ # Flow is refer to the Argo Workflow Object.
106
+ # Flow configs are applied to all of child assets
107
+ flow = Flow(
108
+ name="flow",
109
+ image=os.environ["IMAGE_NAME"],
110
+ envSecrets=[""],
111
+ envConfigMaps=[""],
112
+ as_default=True,
113
+ )
114
+ image = os.environ['IMAGE_NAME'],
115
+ sa_name = os.environ['SERVICE_ACCOUNT_NAME'],
116
+ gcs_store = GcsStore(),
117
+ naive_store = NaiveStore()
118
+
119
+ flow = Flow(
120
+ store=naive_store,
121
+ )
122
+ # authorized by workload-identity-federation
123
+
124
+ @asset(
125
+ schedule = "*/10 * * * *",
126
+ image = "python3:alpine",
127
+ store = NaiveStore(),
128
+ )
129
+ def target() -> str:
130
+ return "world"
131
+
132
+
133
+ @asset(
134
+ schedule = "*/10 * * * *",
135
+ image = "python3:alpine",
136
+ store = GcsStore(),
137
+ )
138
+ def target() -> str:
139
+ return "world"
140
+
141
+ ```
142
+
143
+
144
+ # License
145
+
146
+ This project is licensed under the MIT License.
@@ -0,0 +1,18 @@
1
+ tinyfan/__init__.py,sha256=FqHH3BIb-qKtBuDnEpJMC-KoBxerKSCco3GqvoBlR3o,174
2
+ tinyfan/cli.py,sha256=Jsocb0keH8ywgNdA2TXR40g7BPd6ouU8WDOUwsNRIDs,498
3
+ tinyfan/codegen.py,sha256=vIklPIU9urNF57KVImscpWLoo56Xiruzy3GSi_UPneE,12556
4
+ tinyfan/flow.py,sha256=uoCo4mNculSvBUajBE5LKL0ZSd_J38wD1tdB3WVlMHY,3199
5
+ tinyfan/flowrundata.py,sha256=SKv-aQjXqEQ0AzUGO4AnMTplgbnSSJ4T2FiRkxdTBGc,579
6
+ tinyfan/stores/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ tinyfan/stores/base.py,sha256=DU_91eU-V-ZRwvm8tNLifeo3fvwyGAo2M1iZxCcpljk,673
8
+ tinyfan/stores/bigquery.py,sha256=bz-ZgXmLvTBT4Yb6ROSrvIB8iNVed4_01haId8c1svc,1648
9
+ tinyfan/stores/gcs.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ tinyfan/stores/naive.py,sha256=U_qNWs_Vt9H-gsFWloeG7t8y4QkVzaDuL7O_yTlE_0M,886
11
+ tinyfan/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ tinyfan/utils/embed.py,sha256=BgfG8Fmv6RM4boKpBO4GNFG7LpaCqzpNgE3Y-sDfsjo,3911
13
+ tinyfan/utils/exjson.py,sha256=amaiAwNfIqb-GoBQlt0h8fRR9d7i6r-EyRN99pm5I48,616
14
+ tinyfan-0.0.1.dist-info/METADATA,sha256=C1zgBfSHGRatR3HnrDMpg_IWpEDZRXRnjE7050NRc3M,3754
15
+ tinyfan-0.0.1.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
16
+ tinyfan-0.0.1.dist-info/entry_points.txt,sha256=qqB-I0wVkvSRcxuKun-jQ-n8FAD9RKy5SMsaQ83v1Xg,45
17
+ tinyfan-0.0.1.dist-info/top_level.txt,sha256=qNwWOfVkNzotKJHxqQnW3unx0CCL1pwE_2JahjMxDP8,8
18
+ tinyfan-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.6.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tinyfan = tinyfan.cli:main
@@ -0,0 +1 @@
1
+ tinyfan