hyperstudy-perception 0.1.0__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.
- hyperstudy_perception-0.1.0.dist-info/METADATA +53 -0
- hyperstudy_perception-0.1.0.dist-info/RECORD +82 -0
- hyperstudy_perception-0.1.0.dist-info/WHEEL +4 -0
- hyperstudy_perception-0.1.0.dist-info/entry_points.txt +2 -0
- hyperstudy_perception-0.1.0.dist-info/licenses/LICENSE +21 -0
- perception/__init__.py +0 -0
- perception/_pipeline/RELEASE.json +59 -0
- perception/_pipeline/cloud/runpod/guard.sh +37 -0
- perception/_pipeline/cloud/runpod/image/Dockerfile +39 -0
- perception/_pipeline/cloud/runpod/image/start.sh +11 -0
- perception/_pipeline/cloud/runpod/image/tag.py +40 -0
- perception/_pipeline/cloud/skypilot/gpu-faces.yaml +39 -0
- perception/_pipeline/cloud/skypilot/gpu-tune.yaml +55 -0
- perception/_pipeline/cloud/skypilot/guard.sh +20 -0
- perception/_pipeline/cloud/skypilot/iam-perception-node.json +18 -0
- perception/_pipeline/cloud/skypilot/launch.sh +63 -0
- perception/_pipeline/cloud/skypilot/node-gpu.yaml +25 -0
- perception/_pipeline/cloud/skypilot/node-setup.sh +22 -0
- perception/_pipeline/cloud/skypilot/sky.yaml +7 -0
- perception/_pipeline/cloud/skypilot/smoke.yaml +28 -0
- perception/_pipeline/envs/api/pyproject.toml +12 -0
- perception/_pipeline/envs/api/uv.lock +276 -0
- perception/_pipeline/envs/audio/pyproject.toml +12 -0
- perception/_pipeline/envs/audio/uv.lock +575 -0
- perception/_pipeline/envs/core/pyproject.toml +12 -0
- perception/_pipeline/envs/core/uv.lock +157 -0
- perception/_pipeline/envs/faces/pyproject.toml +12 -0
- perception/_pipeline/envs/faces/uv.lock +1180 -0
- perception/_pipeline/graphs/decode.yaml +11 -0
- perception/_pipeline/graphs/faces.yaml +14 -0
- perception/_pipeline/graphs/full.yaml +31 -0
- perception/_pipeline/graphs/mvp-captions.yaml +27 -0
- perception/_pipeline/graphs/mvp.yaml +17 -0
- perception/_pipeline/nodes/audio/asr/manifest.yaml +20 -0
- perception/_pipeline/nodes/audio/asr/node.py +74 -0
- perception/_pipeline/nodes/audio/asr/prepare.py +12 -0
- perception/_pipeline/nodes/audio/asr/test_wav.py +38 -0
- perception/_pipeline/nodes/audio/asr/wav.py +19 -0
- perception/_pipeline/nodes/conftest.py +64 -0
- perception/_pipeline/nodes/core/decode/manifest.yaml +19 -0
- perception/_pipeline/nodes/core/decode/media.py +104 -0
- perception/_pipeline/nodes/core/decode/node.py +56 -0
- perception/_pipeline/nodes/core/decode/test_media.py +65 -0
- perception/_pipeline/nodes/testdata/README.md +10 -0
- perception/_pipeline/nodes/testdata/rotated-90.mp4 +0 -0
- perception/_pipeline/nodes/vision/face-track/frames.py +88 -0
- perception/_pipeline/nodes/vision/face-track/loader.py +18 -0
- perception/_pipeline/nodes/vision/face-track/manifest.yaml +30 -0
- perception/_pipeline/nodes/vision/face-track/node.py +117 -0
- perception/_pipeline/nodes/vision/face-track/prepare.py +8 -0
- perception/_pipeline/nodes/vision/face-track/test_frames.py +46 -0
- perception/_pipeline/nodes/vision/face-track/test_loader.py +16 -0
- perception/_pipeline/nodes/vision/face-track/test_thumbnails.py +66 -0
- perception/_pipeline/nodes/vision/face-track/thumbnails.py +68 -0
- perception/_pipeline/nodes/vision/face-track/tracking.py +150 -0
- perception/_pipeline/nodes/vlm/shot-captions/manifest.yaml +29 -0
- perception/_pipeline/nodes/vlm/shot-captions/node.py +185 -0
- perception/_pipeline/schema/README.md +54 -0
- perception/_pipeline/schema/bundle-0.1.schema.json +371 -0
- perception/_pipeline/sdk/pyproject.toml +13 -0
- perception/_pipeline/sdk/src/perception_node/__init__.py +157 -0
- perception/artifacts.py +180 -0
- perception/backends/__init__.py +0 -0
- perception/backends/base.py +179 -0
- perception/backends/cloud.py +214 -0
- perception/backends/native.py +140 -0
- perception/backends/probe.py +44 -0
- perception/backends/remote.py +140 -0
- perception/cli.py +780 -0
- perception/estimates.py +68 -0
- perception/export.py +100 -0
- perception/hosts.py +201 -0
- perception/ir.py +245 -0
- perception/keys.py +259 -0
- perception/manifests.py +169 -0
- perception/plan.py +170 -0
- perception/release.py +61 -0
- perception/remote_driver.py +990 -0
- perception/runpod.py +100 -0
- perception/scheduler.py +552 -0
- perception/setup.py +72 -0
- perception/store.py +219 -0
perception/setup.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""`perception setup`: everything a node needs on this machine, ready before a run rather than fetched in the middle of
|
|
2
|
+
one. For each node that would run here: its uv environment synced from its lock (`uv sync --frozen`, what `uv run`
|
|
3
|
+
would do on first use), then its `prepare` script run in that environment (its weights downloaded into the cache it
|
|
4
|
+
reads). Nodes call no tools of the machine's (they read video with PyAV). Each step is timed and reported; a failure is said, with the
|
|
5
|
+
end of its output, and the rest still runs. Doing it again finds everything in place and takes seconds.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .backends.base import child_env
|
|
19
|
+
from .release import REPO, env_vars
|
|
20
|
+
from .manifests import Manifest, Runner
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Step:
|
|
25
|
+
node: str # the node's ref, or "" for the machine's tools
|
|
26
|
+
what: str # environment | models | tools
|
|
27
|
+
ok: bool
|
|
28
|
+
seconds: float
|
|
29
|
+
message: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Setup:
|
|
34
|
+
steps: list[Step] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def ok(self) -> bool:
|
|
38
|
+
return all(s.ok for s in self.steps)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _run(cmd: list[str], cwd: Path, env: dict[str, str]) -> tuple[bool, float, str]:
|
|
42
|
+
"""Run a step in the node's own environment; what it said last, or the end of what went wrong."""
|
|
43
|
+
start = time.monotonic()
|
|
44
|
+
out = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, stdin=subprocess.DEVNULL, env=env)
|
|
45
|
+
said = (out.stdout.strip().splitlines() or [""])[-1] if out.returncode == 0 else \
|
|
46
|
+
" / ".join((out.stdout + out.stderr).strip().splitlines()[-3:])
|
|
47
|
+
return out.returncode == 0, round(time.monotonic() - start, 1), said
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def setup_node(m: Manifest, runner: Runner, uv: str | None = None, config: dict | None = None,
|
|
51
|
+
secrets: dict[str, str] | None = None) -> list[Step]:
|
|
52
|
+
"""Sync the node's environment (native runners) and run its prepare script (its weights) with its settings: a graph's
|
|
53
|
+
for the node when there is one (a setting can choose the model), else its defaults. With the environment a run gives
|
|
54
|
+
the node (child_env): none of this interpreter's settings, and no credential but the secrets it declares (a gated
|
|
55
|
+
model's token)."""
|
|
56
|
+
uv = uv or shutil.which("uv") or "uv"
|
|
57
|
+
env = child_env(m.secrets, secrets or {})
|
|
58
|
+
if runner.kind == "native" and runner.env:
|
|
59
|
+
env = {**env, **env_vars(runner.env)} # where a run makes it (the package's: in the store)
|
|
60
|
+
steps: list[Step] = []
|
|
61
|
+
if runner.kind == "native" and runner.env:
|
|
62
|
+
ok, seconds, tail = _run([uv, "sync", "--project", str(REPO / runner.env), "--frozen", "--no-dev"], REPO, env)
|
|
63
|
+
steps.append(Step(m.ref, "environment", ok, seconds, "" if ok else tail))
|
|
64
|
+
if not ok:
|
|
65
|
+
return steps # its prepare would only fail the same way
|
|
66
|
+
if m.prepare:
|
|
67
|
+
python = ([uv, "run", "--project", str(REPO / runner.env), "--frozen", "--no-dev", "python"]
|
|
68
|
+
if runner.kind == "native" and runner.env else [sys.executable])
|
|
69
|
+
ok, seconds, tail = _run([*python, str(m.directory / m.prepare), json.dumps(m.defaults() if config is None else config)], m.directory, env)
|
|
70
|
+
steps.append(Step(m.ref, "models", ok, seconds, tail))
|
|
71
|
+
return steps
|
|
72
|
+
|
perception/store.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""The artifact store and run state, under one root directory:
|
|
2
|
+
|
|
3
|
+
<root>/perception.db run state, artifacts, cache (SQLite, WAL)
|
|
4
|
+
<root>/objects/ab/<sha256>/<name> published files, read-only, keeping their file name (tools read extensions)
|
|
5
|
+
<root>/trees/ab/<sha256>/... published directories, read-only
|
|
6
|
+
<root>/work/<run>/<node>/<attempt>/ attempt directories (request.json, out/, response.json, log.txt)
|
|
7
|
+
<root>/tmp/ publication staging (same filesystem, so publication is a rename)
|
|
8
|
+
|
|
9
|
+
Publication is effectively-once: an output is copied into tmp/, hashed, and renamed into objects/ or trees/ under its
|
|
10
|
+
hash. A second publication of the same content finds the object already there. Nothing reads an object before its
|
|
11
|
+
row is committed, and a crash leaves at most an orphan file in tmp/. Staged files are named for their process, and
|
|
12
|
+
opening the store clears those of processes that are gone (never a running coordinator's: any command opens the store).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import sqlite3
|
|
21
|
+
import stat
|
|
22
|
+
import time
|
|
23
|
+
import uuid
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from . import artifacts
|
|
28
|
+
|
|
29
|
+
MIGRATIONS = [
|
|
30
|
+
"""
|
|
31
|
+
CREATE TABLE artifacts (
|
|
32
|
+
hash TEXT PRIMARY KEY, shape TEXT NOT NULL, type TEXT NOT NULL, bytes INTEGER NOT NULL, created_at REAL NOT NULL
|
|
33
|
+
);
|
|
34
|
+
-- a source file (the video) hashed once per (path, size, mtime): films are large
|
|
35
|
+
CREATE TABLE sources (
|
|
36
|
+
path TEXT NOT NULL, size INTEGER NOT NULL, mtime_ns INTEGER NOT NULL, sha256 TEXT NOT NULL,
|
|
37
|
+
PRIMARY KEY (path, size, mtime_ns)
|
|
38
|
+
);
|
|
39
|
+
CREATE TABLE runs (
|
|
40
|
+
id TEXT PRIMARY KEY, pipeline TEXT NOT NULL, graph TEXT NOT NULL, graph_path TEXT, inputs TEXT NOT NULL,
|
|
41
|
+
status TEXT NOT NULL, created_at REAL NOT NULL, finished_at REAL, max_cost_usd REAL NOT NULL, spent_usd REAL NOT NULL DEFAULT 0
|
|
42
|
+
);
|
|
43
|
+
CREATE TABLE tasks (
|
|
44
|
+
run_id TEXT NOT NULL REFERENCES runs(id), node_id TEXT NOT NULL,
|
|
45
|
+
state TEXT NOT NULL CHECK (state IN ('pending','ready','leased','running','succeeded','cached','failed','canceled')),
|
|
46
|
+
attempt INTEGER NOT NULL DEFAULT 0, -- increases for ever: each attempt gets its own directory
|
|
47
|
+
cache_key TEXT, runner TEXT,
|
|
48
|
+
lease_owner TEXT, lease_expires REAL, handle TEXT,
|
|
49
|
+
not_before REAL, started_at REAL, finished_at REAL, error_code TEXT, error TEXT,
|
|
50
|
+
cost_usd REAL NOT NULL DEFAULT 0, usage TEXT, outputs TEXT,
|
|
51
|
+
skip_cache INTEGER NOT NULL DEFAULT 0, -- rerun --no-cache: run even if the cache has the result
|
|
52
|
+
PRIMARY KEY (run_id, node_id)
|
|
53
|
+
);
|
|
54
|
+
CREATE TABLE cache (key TEXT PRIMARY KEY, node TEXT NOT NULL, outputs TEXT NOT NULL, usage TEXT, created_at REAL NOT NULL);
|
|
55
|
+
CREATE TABLE events (
|
|
56
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, node_id TEXT, at REAL NOT NULL, kind TEXT NOT NULL, message TEXT
|
|
57
|
+
);
|
|
58
|
+
CREATE INDEX events_by_run ON events(run_id, id);
|
|
59
|
+
""",
|
|
60
|
+
# 2: retries count from the first attempt since the last rerun; attempt numbers (directories) keep increasing
|
|
61
|
+
"""
|
|
62
|
+
ALTER TABLE tasks ADD COLUMN first_attempt INTEGER NOT NULL DEFAULT 1;
|
|
63
|
+
""",
|
|
64
|
+
# 3: whether the run may send data off the machine (--allow-uploads), held with the run so that resume and rerun
|
|
65
|
+
# keep to it; runs from before it was recorded may not (rerun --allow-uploads gives it)
|
|
66
|
+
"""
|
|
67
|
+
ALTER TABLE runs ADD COLUMN allow_uploads INTEGER NOT NULL DEFAULT 0;
|
|
68
|
+
""",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Artifact:
|
|
74
|
+
hash: str
|
|
75
|
+
shape: str # file | tree | source
|
|
76
|
+
type: str
|
|
77
|
+
path: Path # where it can be read
|
|
78
|
+
|
|
79
|
+
def to_json(self) -> dict:
|
|
80
|
+
return {"hash": self.hash, "shape": self.shape, "type": self.type, "path": str(self.path)}
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_json(cls, d: dict) -> "Artifact":
|
|
84
|
+
return cls(d["hash"], d["shape"], d["type"], Path(d["path"]))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _staged_by_live_process(name: str) -> bool:
|
|
88
|
+
"""Whether a tmp/ entry is being staged by a process that is still running (its name starts with that pid). Any
|
|
89
|
+
command opens the store (status, plan, nodes, which the app polls), and must not remove a running coordinator's
|
|
90
|
+
publication under it."""
|
|
91
|
+
pid = name.split("-", 1)[0]
|
|
92
|
+
if not pid.isdigit():
|
|
93
|
+
return False # named before staging carried a pid: a leftover
|
|
94
|
+
try:
|
|
95
|
+
os.kill(int(pid), 0)
|
|
96
|
+
except ProcessLookupError:
|
|
97
|
+
return False
|
|
98
|
+
except (PermissionError, OverflowError):
|
|
99
|
+
return True # another user's live process (or a pid we cannot check): left alone
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Store:
|
|
104
|
+
def __init__(self, root: Path):
|
|
105
|
+
self.root = root.resolve()
|
|
106
|
+
for sub in ("objects", "trees", "work", "tmp"):
|
|
107
|
+
(self.root / sub).mkdir(parents=True, exist_ok=True)
|
|
108
|
+
for leftover in (self.root / "tmp").iterdir(): # staging from a crashed publication, never a live one
|
|
109
|
+
if not _staged_by_live_process(leftover.name):
|
|
110
|
+
shutil.rmtree(leftover) if leftover.is_dir() else leftover.unlink()
|
|
111
|
+
self.db = sqlite3.connect(self.root / "perception.db", isolation_level=None, timeout=30)
|
|
112
|
+
self.db.row_factory = sqlite3.Row
|
|
113
|
+
self.db.execute("PRAGMA journal_mode=WAL")
|
|
114
|
+
self.db.execute("PRAGMA foreign_keys=ON")
|
|
115
|
+
self._migrate()
|
|
116
|
+
|
|
117
|
+
def _migrate(self):
|
|
118
|
+
current = self.db.execute("PRAGMA user_version").fetchone()[0]
|
|
119
|
+
for version, sql in enumerate(MIGRATIONS, start=1):
|
|
120
|
+
if version > current:
|
|
121
|
+
with self.tx(): # executescript would commit on its own; one statement at a time stays in the transaction
|
|
122
|
+
for statement in _statements(sql):
|
|
123
|
+
self.db.execute(statement)
|
|
124
|
+
self.db.execute(f"PRAGMA user_version = {version}")
|
|
125
|
+
|
|
126
|
+
def tx(self):
|
|
127
|
+
return _Tx(self.db)
|
|
128
|
+
|
|
129
|
+
def close(self):
|
|
130
|
+
self.db.close()
|
|
131
|
+
|
|
132
|
+
# --- sources and objects --------------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def source(self, path: Path, type_id: str) -> Artifact:
|
|
135
|
+
"""A file that stays where it is (the video), identified by its sha256."""
|
|
136
|
+
st = path.stat()
|
|
137
|
+
row = self.db.execute("SELECT sha256 FROM sources WHERE path=? AND size=? AND mtime_ns=?",
|
|
138
|
+
(str(path), st.st_size, st.st_mtime_ns)).fetchone()
|
|
139
|
+
sha = row["sha256"] if row else artifacts.sha256_file(path)
|
|
140
|
+
if not row:
|
|
141
|
+
self.db.execute("INSERT OR REPLACE INTO sources VALUES (?,?,?,?)", (str(path), st.st_size, st.st_mtime_ns, sha))
|
|
142
|
+
return Artifact(sha, "source", type_id, path)
|
|
143
|
+
|
|
144
|
+
def object_dir(self, shape: str, h: str) -> Path:
|
|
145
|
+
return self.root / ("trees" if shape == "tree" else "objects") / h[:2] / h
|
|
146
|
+
|
|
147
|
+
def publish(self, path: Path, type_id: str) -> Artifact:
|
|
148
|
+
"""Copy a node's output into the store under its content hash. Idempotent: the same content published again
|
|
149
|
+
(under any name) resolves to the object already there."""
|
|
150
|
+
bad = artifacts.special_files(path)
|
|
151
|
+
if bad: # never follow a link out of a node's output: it would publish a host file
|
|
152
|
+
raise ValueError(f"refusing to publish {path.name}: contains a symlink or special file: {', '.join(bad[:5])}")
|
|
153
|
+
shape = "tree" if path.is_dir() else "file"
|
|
154
|
+
staged = self.root / "tmp" / f"{os.getpid()}-{uuid.uuid4().hex}" # named for its process: see Store.__init__
|
|
155
|
+
if shape == "tree":
|
|
156
|
+
shutil.copytree(path, staged, symlinks=True)
|
|
157
|
+
h = artifacts.sha256_tree(staged)
|
|
158
|
+
else:
|
|
159
|
+
staged.mkdir()
|
|
160
|
+
shutil.copyfile(path, staged / path.name)
|
|
161
|
+
h = artifacts.sha256_file(staged / path.name)
|
|
162
|
+
final = self.object_dir(shape, h)
|
|
163
|
+
if final.exists():
|
|
164
|
+
shutil.rmtree(staged)
|
|
165
|
+
else:
|
|
166
|
+
final.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
_fsync(staged)
|
|
168
|
+
_read_only(staged)
|
|
169
|
+
os.replace(staged, final) # atomic: the object appears whole or not at all
|
|
170
|
+
readable = final if shape == "tree" else next(final.iterdir())
|
|
171
|
+
size = sum(p.stat().st_size for p in final.rglob("*") if p.is_file())
|
|
172
|
+
self.db.execute("INSERT OR IGNORE INTO artifacts VALUES (?,?,?,?,?)", (h, shape, type_id, size, time.time()))
|
|
173
|
+
return Artifact(h, shape, type_id, readable)
|
|
174
|
+
|
|
175
|
+
def has(self, a: Artifact) -> bool:
|
|
176
|
+
return a.path.exists()
|
|
177
|
+
|
|
178
|
+
# --- events ---------------------------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def event(self, run_id: str, node_id: str | None, kind: str, message: str = ""):
|
|
181
|
+
self.db.execute("INSERT INTO events (run_id, node_id, at, kind, message) VALUES (?,?,?,?,?)",
|
|
182
|
+
(run_id, node_id, time.time(), kind, message))
|
|
183
|
+
|
|
184
|
+
def events(self, run_id: str, after: int = 0) -> list[sqlite3.Row]:
|
|
185
|
+
return self.db.execute("SELECT * FROM events WHERE run_id=? AND id>? ORDER BY id", (run_id, after)).fetchall()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class _Tx:
|
|
189
|
+
def __init__(self, db):
|
|
190
|
+
self.db = db
|
|
191
|
+
|
|
192
|
+
def __enter__(self):
|
|
193
|
+
self.db.execute("BEGIN IMMEDIATE")
|
|
194
|
+
return self.db
|
|
195
|
+
|
|
196
|
+
def __exit__(self, exc_type, exc, tb):
|
|
197
|
+
self.db.execute("ROLLBACK" if exc_type else "COMMIT")
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _statements(sql: str) -> list[str]:
|
|
202
|
+
return [s.strip() for s in sql.split(";") if s.strip() and not all(l.strip().startswith("--") for l in s.strip().splitlines())]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _fsync(path: Path):
|
|
206
|
+
files = [p for p in path.rglob("*") if p.is_file()] if path.is_dir() else [path]
|
|
207
|
+
for p in files:
|
|
208
|
+
with open(p, "rb") as f:
|
|
209
|
+
os.fsync(f.fileno())
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _read_only(path: Path):
|
|
213
|
+
files = [p for p in path.rglob("*") if p.is_file()] if path.is_dir() else [path]
|
|
214
|
+
for p in files:
|
|
215
|
+
p.chmod(p.stat().st_mode & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def dumps(value) -> str:
|
|
219
|
+
return json.dumps(value, separators=(",", ":"))
|