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 +5 -0
- tinyfan/cli.py +21 -0
- tinyfan/codegen.py +304 -0
- tinyfan/flow.py +96 -0
- tinyfan/flowrundata.py +24 -0
- tinyfan/stores/__init__.py +0 -0
- tinyfan/stores/base.py +30 -0
- tinyfan/stores/bigquery.py +49 -0
- tinyfan/stores/gcs.py +0 -0
- tinyfan/stores/naive.py +37 -0
- tinyfan/utils/__init__.py +0 -0
- tinyfan/utils/embed.py +129 -0
- tinyfan/utils/exjson.py +30 -0
- tinyfan-0.0.1.dist-info/METADATA +146 -0
- tinyfan-0.0.1.dist-info/RECORD +18 -0
- tinyfan-0.0.1.dist-info/WHEEL +5 -0
- tinyfan-0.0.1.dist-info/entry_points.txt +2 -0
- tinyfan-0.0.1.dist-info/top_level.txt +1 -0
tinyfan/__init__.py
ADDED
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
|
tinyfan/stores/naive.py
ADDED
|
@@ -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)
|
tinyfan/utils/exjson.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
tinyfan
|