spritegen-cli 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.
- spritegen/__init__.py +3 -0
- spritegen/atlas.py +99 -0
- spritegen/cli.py +178 -0
- spritegen/clip.py +81 -0
- spritegen/drive.py +163 -0
- spritegen/endpoints.py +113 -0
- spritegen/fal.py +228 -0
- spritegen/imaging.py +219 -0
- spritegen/ledger.py +143 -0
- spritegen/matting.py +136 -0
- spritegen/migrate.py +162 -0
- spritegen/prompts.py +96 -0
- spritegen/rrdb.py +91 -0
- spritegen/settings.py +127 -0
- spritegen/sheet.py +370 -0
- spritegen/skill/__init__.py +303 -0
- spritegen/skill/files/SKILL.md +553 -0
- spritegen/stages/__init__.py +490 -0
- spritegen/stages/anchor.py +215 -0
- spritegen/stages/board.py +68 -0
- spritegen/stages/matte.py +209 -0
- spritegen/stages/motion.py +164 -0
- spritegen/stages/pose.py +172 -0
- spritegen/stages/video.py +196 -0
- spritegen/upscale.py +444 -0
- spritegen/workspace.py +852 -0
- spritegen_cli-0.1.0.dist-info/METADATA +16 -0
- spritegen_cli-0.1.0.dist-info/RECORD +30 -0
- spritegen_cli-0.1.0.dist-info/WHEEL +4 -0
- spritegen_cli-0.1.0.dist-info/entry_points.txt +2 -0
spritegen/workspace.py
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
"""The asset directory, and the state file that says where it stopped.
|
|
2
|
+
|
|
3
|
+
Everything belonging to one character lives under `assets/<name>/`, and `state.json`
|
|
4
|
+
inside it is the only record of progress. No stage takes an input or output path: it
|
|
5
|
+
asks here. That is what lets `status` say what comes next and lets a stage refuse to
|
|
6
|
+
run out of order.
|
|
7
|
+
|
|
8
|
+
The state file holds what happened, never what should happen next — the pipeline's
|
|
9
|
+
shape is in `stages`, and two records of one fact disagree. So `stages` here maps only
|
|
10
|
+
the stages that have *completed*, and an asset that has just been opened has an empty
|
|
11
|
+
one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import stages as registry
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StageRefused(ValueError):
|
|
26
|
+
"""A stage was asked to run and must not.
|
|
27
|
+
|
|
28
|
+
A ValueError so the CLI turns it into an exit code and a line on stderr, rather
|
|
29
|
+
than a traceback: being refused is an answer, not a crash.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
STATE_FILE = "state.json"
|
|
34
|
+
LEDGER_FILE = "ledger.jsonl"
|
|
35
|
+
|
|
36
|
+
#: Where a sprite's prompts are kept, versioned, inside the sprite's own directory.
|
|
37
|
+
PROMPTS_DIR = "prompts"
|
|
38
|
+
|
|
39
|
+
#: The number of the on-disk shape. 1 was a directory per stage; 2 is a directory per
|
|
40
|
+
#: artifact kind, with art directions under `sheet/`. A state file with no number is 1.
|
|
41
|
+
LAYOUT = 2
|
|
42
|
+
|
|
43
|
+
#: An asset name is a directory name, so it may not steer the path anywhere.
|
|
44
|
+
_FORBIDDEN_CHARS = frozenset(r'/\:*?"<>|')
|
|
45
|
+
|
|
46
|
+
#: Names Windows reserves for devices, with or without an extension. `mkdir CON` fails
|
|
47
|
+
#: with something that reads like a permissions problem rather than a naming one, so it
|
|
48
|
+
#: is refused here where the reason can be said.
|
|
49
|
+
_RESERVED = frozenset(
|
|
50
|
+
{"con", "prn", "aux", "nul"}
|
|
51
|
+
| {f"com{digit}" for digit in "123456789"}
|
|
52
|
+
| {f"lpt{digit}" for digit in "123456789"}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
#: Where an installed spritegen keeps a project's assets: a directory of its own inside
|
|
57
|
+
#: the project, holding one subdirectory per character.
|
|
58
|
+
WORKSPACE_DIR = Path("spritegen") / "assets"
|
|
59
|
+
|
|
60
|
+
#: What makes a directory a project root, when there is no workspace to find. Any one of
|
|
61
|
+
#: them is enough — a repository is a project whether or not it is Python.
|
|
62
|
+
ROOT_MARKERS = (".git", ".claude", "pyproject.toml", "package.json")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _nearest(start: Path, relative: Path | str) -> Path | None:
|
|
66
|
+
"""The nearest existing `<base>/relative` at or above `start`, within the ceiling.
|
|
67
|
+
|
|
68
|
+
The ceiling is `settings.search_from`, and it is shared with the `.env` search on
|
|
69
|
+
purpose: both walk upward from the working directory, and both would otherwise reach
|
|
70
|
+
the filesystem root. A `spritegen/assets` created once in a home directory would
|
|
71
|
+
become the asset store of every unrelated project run from anywhere beneath it.
|
|
72
|
+
"""
|
|
73
|
+
from . import settings
|
|
74
|
+
|
|
75
|
+
for base in settings.search_from(start):
|
|
76
|
+
candidate = base / relative
|
|
77
|
+
if candidate.is_dir():
|
|
78
|
+
return candidate
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def project_root(start: Path | None = None) -> Path:
|
|
83
|
+
"""The project this CLI is being run inside, or the working directory.
|
|
84
|
+
|
|
85
|
+
Searched the same way `settings.dotenv` searches for a `.env` and `skill.target` for a
|
|
86
|
+
`.claude`, and bounded the same way: the directory a command is typed in is rarely the
|
|
87
|
+
top of the thing it is about, but neither is the filesystem root.
|
|
88
|
+
"""
|
|
89
|
+
from . import settings
|
|
90
|
+
|
|
91
|
+
start = Path.cwd() if start is None else start
|
|
92
|
+
for base in settings.search_from(start):
|
|
93
|
+
if any((base / marker).exists() for marker in ROOT_MARKERS):
|
|
94
|
+
return base
|
|
95
|
+
return start
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def assets_root(start: Path | None = None) -> Path:
|
|
99
|
+
"""Where the assets live.
|
|
100
|
+
|
|
101
|
+
Four answers, in this order, and the order is the whole design:
|
|
102
|
+
|
|
103
|
+
1. **`SPRITEGEN_ASSETS`** — an explicit answer always wins, which is what lets a test
|
|
104
|
+
and a second collection of characters exist at all.
|
|
105
|
+
2. **The nearest existing `spritegen/assets/`** at or above the working directory.
|
|
106
|
+
This is the installed shape: the CLI comes from PyPI, the project it serves is
|
|
107
|
+
somebody else's, and the state and the images belong to that project rather than
|
|
108
|
+
to the tool. Searching upward is what makes a command work from anywhere inside
|
|
109
|
+
the project instead of only from its root, and it stops at a repository boundary
|
|
110
|
+
— see `settings.search_from` for why that ceiling has to be there.
|
|
111
|
+
3. **An existing `assets/`** at or above the working directory — the shape before
|
|
112
|
+
there was a published CLI, kept so a workspace that already has one keeps working.
|
|
113
|
+
4. **`<project root>/spritegen/assets`**, which is where `init` would create it.
|
|
114
|
+
|
|
115
|
+
Nothing here creates a directory. A path that does not exist is the answer `status`
|
|
116
|
+
and every stage report, which is what lets them say to run `init` rather than
|
|
117
|
+
opening an empty workspace nobody asked for.
|
|
118
|
+
"""
|
|
119
|
+
from . import settings
|
|
120
|
+
|
|
121
|
+
override = settings.load().assets
|
|
122
|
+
if override:
|
|
123
|
+
return Path(override)
|
|
124
|
+
|
|
125
|
+
start = Path.cwd() if start is None else start
|
|
126
|
+
found = _nearest(start, WORKSPACE_DIR)
|
|
127
|
+
if found is not None:
|
|
128
|
+
return found
|
|
129
|
+
legacy = _nearest(start, "assets")
|
|
130
|
+
if legacy is not None:
|
|
131
|
+
return legacy
|
|
132
|
+
return project_root(start) / WORKSPACE_DIR
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def check_name(name: str) -> str:
|
|
136
|
+
"""`name` if it is usable as a directory name, and an error naming the reason if not.
|
|
137
|
+
|
|
138
|
+
Rejected here rather than by the filesystem: a name with a separator in it would
|
|
139
|
+
silently write outside the assets root, one that is empty or all dots would resolve
|
|
140
|
+
to the root itself, and a name Windows reserves fails at `mkdir` with an error that
|
|
141
|
+
reads like a permissions problem.
|
|
142
|
+
|
|
143
|
+
**Two inputs go through this, not one.** An asset's name, and the animation a pose is
|
|
144
|
+
for — both become a directory, and there is no second rule for the second one.
|
|
145
|
+
"""
|
|
146
|
+
if not name or name != name.strip():
|
|
147
|
+
raise ValueError(f"asset name {name!r} is empty or padded with spaces")
|
|
148
|
+
if set(name) <= {"."}:
|
|
149
|
+
raise ValueError(f"asset name {name!r} is not a name")
|
|
150
|
+
bad = sorted(_FORBIDDEN_CHARS & set(name))
|
|
151
|
+
if bad:
|
|
152
|
+
shown = "".join(bad)
|
|
153
|
+
raise ValueError(f"asset name {name!r} may not contain {shown!r}")
|
|
154
|
+
if ".." in name:
|
|
155
|
+
raise ValueError(f'asset name {name!r} may not contain ".."')
|
|
156
|
+
if name.endswith("."):
|
|
157
|
+
# Windows strips it, so `attack.` and `attack` would be one directory under two
|
|
158
|
+
# names — and the state file would hold both.
|
|
159
|
+
raise ValueError(f"asset name {name!r} may not end with a dot")
|
|
160
|
+
if name.split(".", 1)[0].lower() in _RESERVED:
|
|
161
|
+
raise ValueError(f"asset name {name!r} is a name Windows reserves for a device")
|
|
162
|
+
return name
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def asset_dir(name: str) -> Path:
|
|
166
|
+
return assets_root() / check_name(name)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def state_path(name: str) -> Path:
|
|
170
|
+
return asset_dir(name) / STATE_FILE
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def ledger_path(name: str) -> Path:
|
|
174
|
+
return asset_dir(name) / LEDGER_FILE
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class State:
|
|
179
|
+
"""One asset's progress.
|
|
180
|
+
|
|
181
|
+
Two records, and they answer different questions. `stages` maps a completed stage's
|
|
182
|
+
name to whatever that stage recorded, in the order the stages completed — that order
|
|
183
|
+
is not derivable from anything else and is what `Context.source` reads. `artifacts`
|
|
184
|
+
maps a kind to where its files are, which is what anyone asking "what does this
|
|
185
|
+
sprite have" wants and what no amount of reading `stages` gives you directly.
|
|
186
|
+
|
|
187
|
+
`layout` is the number of the on-disk shape. A file without one was written before
|
|
188
|
+
the shape had a number, and saying so is different from reading it as empty.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
name: str
|
|
192
|
+
created: str
|
|
193
|
+
stages: dict[str, dict] = field(default_factory=dict)
|
|
194
|
+
artifacts: dict[str, dict] = field(default_factory=dict)
|
|
195
|
+
layout: int = LAYOUT
|
|
196
|
+
|
|
197
|
+
def completed(self) -> set[str]:
|
|
198
|
+
return set(self.stages)
|
|
199
|
+
|
|
200
|
+
def last(self) -> str | None:
|
|
201
|
+
"""The stage that completed most recently, or None if none has.
|
|
202
|
+
|
|
203
|
+
Completion order is `stages` insertion order, which survives the JSON round
|
|
204
|
+
trip. A stage re-run with --force keeps the position it first took: the file
|
|
205
|
+
answers "what has been done", and re-doing one does not make it newer.
|
|
206
|
+
"""
|
|
207
|
+
return next(reversed(self.stages), None) if self.stages else None
|
|
208
|
+
|
|
209
|
+
def to_json(self) -> dict:
|
|
210
|
+
return {
|
|
211
|
+
"name": self.name,
|
|
212
|
+
"created": self.created,
|
|
213
|
+
"layout": self.layout,
|
|
214
|
+
"stages": self.stages,
|
|
215
|
+
"artifacts": self.artifacts,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@classmethod
|
|
219
|
+
def from_json(cls, raw: dict) -> State:
|
|
220
|
+
missing = {"name", "created"} - set(raw)
|
|
221
|
+
if missing:
|
|
222
|
+
raise ValueError(f"state file is missing {', '.join(sorted(missing))}")
|
|
223
|
+
return cls(
|
|
224
|
+
name=raw["name"],
|
|
225
|
+
created=raw["created"],
|
|
226
|
+
stages=raw.get("stages") or {},
|
|
227
|
+
artifacts=raw.get("artifacts") or {},
|
|
228
|
+
# A state file with no layout number was written before there were any, and
|
|
229
|
+
# that is exactly what R1.5 has to be able to tell apart from an empty one.
|
|
230
|
+
layout=int(raw.get("layout", 1)),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def outdated(self) -> bool:
|
|
234
|
+
"""Whether this workspace predates the current layout — R1.5."""
|
|
235
|
+
return self.layout < LAYOUT
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def require_workspace() -> Path:
|
|
239
|
+
"""The assets root, if it is there or was named outright. An error naming `init` if not.
|
|
240
|
+
|
|
241
|
+
Creating a discovered root here would be the wrong kindness. `assets_root` resolves
|
|
242
|
+
to a path whether or not anything is at it, so a `new` typed in the wrong directory
|
|
243
|
+
would quietly open a second workspace beside the real one — and the first sign of it
|
|
244
|
+
would be a `status` that has forgotten every character. `init` is where a workspace
|
|
245
|
+
comes from, and it says where it made one.
|
|
246
|
+
|
|
247
|
+
A root named by `SPRITEGEN_ASSETS` is the exception, and for the same reason it wins
|
|
248
|
+
everywhere else: it was not guessed. Whoever set it said where the assets go, and
|
|
249
|
+
being told to run `init` to create the directory they just named would be absurd.
|
|
250
|
+
"""
|
|
251
|
+
root = assets_root()
|
|
252
|
+
if root.is_dir():
|
|
253
|
+
return root
|
|
254
|
+
from . import settings
|
|
255
|
+
|
|
256
|
+
if settings.load().assets:
|
|
257
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
return root
|
|
259
|
+
raise FileNotFoundError(
|
|
260
|
+
f"no spritegen workspace at {root}; run `spritegen init` to make one"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def create(name: str) -> State:
|
|
265
|
+
"""Open a new asset — R1.1, R1.2.
|
|
266
|
+
|
|
267
|
+
Refuses an asset that already exists instead of merging into it: the state file is
|
|
268
|
+
the record of what was paid for, and re-opening on top of one would lose it.
|
|
269
|
+
"""
|
|
270
|
+
require_workspace()
|
|
271
|
+
directory = asset_dir(name)
|
|
272
|
+
if directory.exists():
|
|
273
|
+
raise FileExistsError(f"asset {name!r} already exists at {directory}")
|
|
274
|
+
directory.mkdir(parents=True)
|
|
275
|
+
state = State(name=name, created=datetime.now(UTC).isoformat(timespec="seconds"), stages={})
|
|
276
|
+
save(state)
|
|
277
|
+
return state
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def load(name: str) -> State:
|
|
281
|
+
path = state_path(name)
|
|
282
|
+
if not path.exists():
|
|
283
|
+
raise FileNotFoundError(f"asset {name!r} has no {STATE_FILE}; run `spritegen new {name}`")
|
|
284
|
+
return State.from_json(json.loads(path.read_text(encoding="utf-8")))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def save(state: State) -> Path:
|
|
288
|
+
path = state_path(state.name)
|
|
289
|
+
path.write_text(
|
|
290
|
+
json.dumps(state.to_json(), indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
291
|
+
)
|
|
292
|
+
return path
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def known() -> list[str]:
|
|
296
|
+
"""Every asset that has a state file, in name order."""
|
|
297
|
+
root = assets_root()
|
|
298
|
+
if not root.is_dir():
|
|
299
|
+
return []
|
|
300
|
+
return sorted(d.name for d in root.iterdir() if (d / STATE_FILE).is_file())
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def satisfied(state: State, stage: registry.Stage) -> bool:
|
|
304
|
+
"""Whether this stage's input exists — **by artifact, not by stage name**.
|
|
305
|
+
|
|
306
|
+
A stage names the stages it follows, but what it actually needs is the *kind* those
|
|
307
|
+
stages produce. Those were the same thing while a stage made one thing. They are not
|
|
308
|
+
now: `anchor` produces `anchor`, `box-art` or `icon` depending on what it was asked
|
|
309
|
+
for, and box art is the one the pipeline is told to make *first*, before the anchor
|
|
310
|
+
exists.
|
|
311
|
+
|
|
312
|
+
Asking by stage name made having box art mean having an anchor. `motion` and `video`
|
|
313
|
+
became eligible, `Context.source` resolved to the box-art directory, and a paid call
|
|
314
|
+
ran against an illustration instead of the neutral sprite it deforms from — silently,
|
|
315
|
+
because every check involved had been told the anchor was there.
|
|
316
|
+
"""
|
|
317
|
+
if not stage.requires:
|
|
318
|
+
return True
|
|
319
|
+
return any(registry.get(dep).produces in state.artifacts for dep in stage.requires)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def next_stages(state: State) -> tuple[str, ...]:
|
|
323
|
+
"""The stages that could run now — R1.3.
|
|
324
|
+
|
|
325
|
+
A stage is eligible when its input exists and it still has something to make. Plural
|
|
326
|
+
because the pipeline branches: with an anchor, both `motion` and `video` can run, and
|
|
327
|
+
choosing between them is the operator's call, not this function's.
|
|
328
|
+
|
|
329
|
+
**"Still has something to make" is per kind for a stage that makes several.** `anchor`
|
|
330
|
+
having run for box art has not made an anchor, and this route is one the skill tells
|
|
331
|
+
you to take — box art first, then the anchor that quotes it. Saying "nothing comes
|
|
332
|
+
next" there is advice against the tool's own recommendation.
|
|
333
|
+
|
|
334
|
+
For a stage that makes one thing it stays "has this stage run", which is what keeps
|
|
335
|
+
the untaken branch on offer: `motion` and `video` are alternatives, having taken one
|
|
336
|
+
leaves the other worth running, and they are two stages rather than two kinds.
|
|
337
|
+
"""
|
|
338
|
+
return tuple(
|
|
339
|
+
stage.name
|
|
340
|
+
for stage in registry.STAGES
|
|
341
|
+
if pending(state, stage) and satisfied(state, stage)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def pending(state: State, stage: registry.Stage) -> bool:
|
|
346
|
+
"""Whether this stage still has the artifact it is chiefly for.
|
|
347
|
+
|
|
348
|
+
`produces` is that artifact — the anchor, out of a stage that can also make box art
|
|
349
|
+
and an icon. Those two are asked for by name when somebody wants them; they are not
|
|
350
|
+
what comes next, and offering `anchor` forever until all three exist would be as
|
|
351
|
+
useless as the bug this replaced, in the other direction.
|
|
352
|
+
|
|
353
|
+
**A stage that varies by variant rather than by kind falls to "has it run".** `pose`
|
|
354
|
+
and `board` can both make more — another animation, another art direction — and how
|
|
355
|
+
many is unbounded, so there is no count at which they are finished. Offering them
|
|
356
|
+
forever is the honest answer and the useless one: `status` would never be able to say
|
|
357
|
+
an asset is complete, which is the signal people read it for. So they are offered
|
|
358
|
+
once, `require_ready` keeps letting you make another, and the skill is what says so.
|
|
359
|
+
"""
|
|
360
|
+
if stage.kind_option is None:
|
|
361
|
+
return stage.name not in state.stages
|
|
362
|
+
return stage.produces not in state.artifacts
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def output_dir(
|
|
366
|
+
asset: str, stage_name: str, kind: str | None = None, variant: str | None = None
|
|
367
|
+
) -> Path:
|
|
368
|
+
"""Where a stage writes for `asset` — R1.1, R1.3.
|
|
369
|
+
|
|
370
|
+
The only place that turns a stage and its options into a path, which is what lets
|
|
371
|
+
the layout change without every stage learning about it.
|
|
372
|
+
|
|
373
|
+
`kind` is what the artifact *is*, and it is the directory: box art, an anchor and an
|
|
374
|
+
icon all come out of `anchor` and are three different things. `variant` is a
|
|
375
|
+
dimension under that kind rather than a step after it — `sheet/sharp/` and
|
|
376
|
+
`sheet/pixel-art/` are one sprite closed two ways, and a project may want both.
|
|
377
|
+
|
|
378
|
+
Both default to the stage's own `produces` and to no variant, so a stage that makes
|
|
379
|
+
one thing needs to say nothing.
|
|
380
|
+
"""
|
|
381
|
+
stage = registry.get(stage_name)
|
|
382
|
+
directory = asset_dir(asset) / (kind or stage.produces)
|
|
383
|
+
return directory / variant if variant else directory
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class Escaped(ValueError):
|
|
387
|
+
"""A recorded path that does not stay inside the asset it belongs to."""
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def inside(asset: str, relative: str) -> Path:
|
|
391
|
+
"""`asset_dir(asset) / relative`, refused unless it stays inside the asset.
|
|
392
|
+
|
|
393
|
+
**Every path that came out of `state.json` goes through here.** `check_name` guards
|
|
394
|
+
the asset's *name*, which is what this tool chooses; `dir` is a string in a file on
|
|
395
|
+
disk that anything with write access can change — a synced workspace, an `assets/`
|
|
396
|
+
somebody committed, another tool in the same session.
|
|
397
|
+
|
|
398
|
+
Two ways it escapes and both are silent. `Path("a") / "/etc"` discards the left side
|
|
399
|
+
entirely, so an absolute value replaces the asset directory outright; and `..`
|
|
400
|
+
segments are honoured by the filesystem however deep they go. What that would buy is
|
|
401
|
+
not hypothetical: `inspect` lists filenames, `upscale` reads and overwrites in place,
|
|
402
|
+
and `Context.source` decides what a paid stage uploads — which would make an edited
|
|
403
|
+
state file a way to send somebody's files to a third party on their own key.
|
|
404
|
+
"""
|
|
405
|
+
base = asset_dir(asset).resolve()
|
|
406
|
+
candidate = (base / relative).resolve()
|
|
407
|
+
if candidate != base and base not in candidate.parents:
|
|
408
|
+
raise Escaped(
|
|
409
|
+
f"{asset}: {relative!r} in {STATE_FILE} points outside the asset directory; "
|
|
410
|
+
f"it has been edited by something other than spritegen"
|
|
411
|
+
)
|
|
412
|
+
return candidate
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def stage_output(asset: str, stage_name: str, recorded: dict | None = None) -> Path:
|
|
416
|
+
"""Where that stage actually wrote, read back from what it recorded.
|
|
417
|
+
|
|
418
|
+
A stage records the directory it used, because the options that chose it are gone by
|
|
419
|
+
the time the next stage runs. Falling back to the default is for a state file written
|
|
420
|
+
before this existed, and for a stage that has not run.
|
|
421
|
+
"""
|
|
422
|
+
if recorded and recorded.get("dir"):
|
|
423
|
+
return inside(asset, recorded["dir"])
|
|
424
|
+
return output_dir(asset, stage_name)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def has_output(asset: str, stage_name: str, kind: str | None = None,
|
|
428
|
+
variant: str | None = None) -> bool:
|
|
429
|
+
"""Whether that output is already on disk.
|
|
430
|
+
|
|
431
|
+
Asked of the filesystem rather than of the state file. The two disagree when a run
|
|
432
|
+
died between writing its files and recording itself, and in that case the files are
|
|
433
|
+
what would be overwritten.
|
|
434
|
+
"""
|
|
435
|
+
directory = output_dir(asset, stage_name, kind, variant)
|
|
436
|
+
return directory.is_dir() and any(directory.iterdir())
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def require_ready(
|
|
440
|
+
state: State,
|
|
441
|
+
stage_name: str,
|
|
442
|
+
*,
|
|
443
|
+
force: bool = False,
|
|
444
|
+
kind: str | None = None,
|
|
445
|
+
variant: str | None = None,
|
|
446
|
+
) -> registry.Stage:
|
|
447
|
+
"""The stage, if it may run now — R1.4, R1.5.
|
|
448
|
+
|
|
449
|
+
Refuses in two cases and names what to do about each: a prerequisite that has not
|
|
450
|
+
completed, and output that is already there. Every stage calls this before doing
|
|
451
|
+
anything, which is why the checks live here and not in five modules.
|
|
452
|
+
"""
|
|
453
|
+
stage = registry.get(stage_name)
|
|
454
|
+
|
|
455
|
+
if not satisfied(state, stage):
|
|
456
|
+
wanted = sorted({registry.get(dep).produces for dep in stage.requires})
|
|
457
|
+
missing = " or ".join(stage.requires)
|
|
458
|
+
needs = " or ".join(wanted)
|
|
459
|
+
raise StageRefused(
|
|
460
|
+
f"{state.name}: {stage_name!r} needs {needs!r}, which {missing} produces "
|
|
461
|
+
f"and which this asset does not have"
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# What is checked is the directory *this* run would write, not the stage as a whole:
|
|
465
|
+
# box art and an anchor both come out of `anchor`, and having one is not a reason to
|
|
466
|
+
# refuse the other. The same goes for a second art direction of one sheet.
|
|
467
|
+
made = kind or stage.produces
|
|
468
|
+
artifact = f"{made}:{variant}" if variant else made
|
|
469
|
+
entry = state.stages.get(stage_name)
|
|
470
|
+
# An entry that never said what it produced predates this, and what it made cannot
|
|
471
|
+
# be told from here. Refusing is the recoverable answer; overwriting is not.
|
|
472
|
+
completed = entry is not None and entry.get("produced", artifact) == artifact
|
|
473
|
+
if not force and (completed or has_output(state.name, stage_name, kind, variant)):
|
|
474
|
+
raise StageRefused(
|
|
475
|
+
f"{state.name}: {stage_name!r} already produced {artifact!r}; "
|
|
476
|
+
f"pass --force to rewrite it"
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
return stage
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@dataclass
|
|
483
|
+
class Context:
|
|
484
|
+
"""What a stage is handed: its asset, and every path it is allowed to touch — R3.2.
|
|
485
|
+
|
|
486
|
+
A stage takes no input or output path from the command line. It asks this object,
|
|
487
|
+
which derives everything from the asset directory. That is what makes a run
|
|
488
|
+
reproducible from `state.json` alone, and what stops two stages from disagreeing
|
|
489
|
+
about where the board is.
|
|
490
|
+
"""
|
|
491
|
+
|
|
492
|
+
state: State
|
|
493
|
+
stage: registry.Stage
|
|
494
|
+
force: bool = False
|
|
495
|
+
dry_run: bool = False
|
|
496
|
+
kind: str | None = None
|
|
497
|
+
variant: str | None = None
|
|
498
|
+
prompt: str | None = None
|
|
499
|
+
parts: dict[str, str] = field(default_factory=dict)
|
|
500
|
+
|
|
501
|
+
@property
|
|
502
|
+
def artifact(self) -> str:
|
|
503
|
+
"""The key this run's output is recorded under: the kind, or kind:variant."""
|
|
504
|
+
kind = self.kind or self.stage.produces
|
|
505
|
+
return f"{kind}:{self.variant}" if self.variant else kind
|
|
506
|
+
|
|
507
|
+
@property
|
|
508
|
+
def asset(self) -> str:
|
|
509
|
+
return self.state.name
|
|
510
|
+
|
|
511
|
+
@property
|
|
512
|
+
def directory(self) -> Path:
|
|
513
|
+
return asset_dir(self.asset)
|
|
514
|
+
|
|
515
|
+
@property
|
|
516
|
+
def out(self) -> Path:
|
|
517
|
+
"""Where this stage writes. Not created — see `ensure_out`."""
|
|
518
|
+
return output_dir(self.asset, self.stage.name, self.kind, self.variant)
|
|
519
|
+
|
|
520
|
+
def ensure_out(self) -> Path:
|
|
521
|
+
"""`out`, created. A dry run never calls this, so it leaves no directory behind."""
|
|
522
|
+
self.out.mkdir(parents=True, exist_ok=True)
|
|
523
|
+
return self.out
|
|
524
|
+
|
|
525
|
+
@property
|
|
526
|
+
def source(self) -> Path:
|
|
527
|
+
"""The directory holding this stage's input — resolved by kind, not by stage.
|
|
528
|
+
|
|
529
|
+
A stage's `requires` names stages, but the input is the *artifact* those stages
|
|
530
|
+
produce, and that is what is looked up. Where two branches produce the same kind
|
|
531
|
+
— `motion` and `video` both make `frames` — they write the same directory, so
|
|
532
|
+
there is no "which branch was most recent" left to decide: whichever ran last is
|
|
533
|
+
what is in there.
|
|
534
|
+
"""
|
|
535
|
+
if not self.stage.requires:
|
|
536
|
+
raise ValueError(f"{self.stage.name!r} has no input stage; its input is its prompt")
|
|
537
|
+
|
|
538
|
+
for dep in self.stage.requires:
|
|
539
|
+
kind = registry.get(dep).produces
|
|
540
|
+
entry = self.state.artifacts.get(kind)
|
|
541
|
+
if entry is not None:
|
|
542
|
+
return inside(self.asset, entry["dir"])
|
|
543
|
+
|
|
544
|
+
wanted = " or ".join(sorted({registry.get(d).produces for d in self.stage.requires}))
|
|
545
|
+
raise StageRefused(f"{self.asset}: {self.stage.name!r} has no {wanted} to read")
|
|
546
|
+
|
|
547
|
+
def source_files(self, pattern: str = "*") -> list[Path]:
|
|
548
|
+
"""The input files, sorted by name, so a frame order is a frame order."""
|
|
549
|
+
return sorted(path for path in self.source.glob(pattern) if path.is_file())
|
|
550
|
+
|
|
551
|
+
@property
|
|
552
|
+
def ledger(self) -> Path:
|
|
553
|
+
return ledger_path(self.asset)
|
|
554
|
+
|
|
555
|
+
def keep_prompt(self, text: str, part: str | None = None) -> str:
|
|
556
|
+
"""Store a prompt for this run and return the version to record — R2.1, R2.4.
|
|
557
|
+
|
|
558
|
+
Called before the paid call, not after it. A prompt written only on success is
|
|
559
|
+
missing from exactly the runs somebody needs to go back and look at, and a
|
|
560
|
+
`--prompt-file` outside the workspace can be edited or deleted the moment the
|
|
561
|
+
call returns.
|
|
562
|
+
|
|
563
|
+
`part` names one piece of a run that makes more than one image from more than one
|
|
564
|
+
text — a pose's closing frame against its opening one. Without it the prompt is
|
|
565
|
+
the run's, and it is the one the artifact cites. **A part is stored and recorded
|
|
566
|
+
but does not become the artifact's prompt**: one artifact cites one text, and
|
|
567
|
+
citing the second call's text for the first call's image would be a provenance
|
|
568
|
+
record that is wrong rather than missing.
|
|
569
|
+
"""
|
|
570
|
+
from . import prompts
|
|
571
|
+
|
|
572
|
+
stem = self.artifact.replace(":", "-")
|
|
573
|
+
kept = prompts.store(self.directory, f"{stem}-{part}" if part else stem, text)
|
|
574
|
+
if part is None:
|
|
575
|
+
self.prompt = kept
|
|
576
|
+
else:
|
|
577
|
+
self.parts[part] = kept
|
|
578
|
+
return kept
|
|
579
|
+
|
|
580
|
+
def paid_call(
|
|
581
|
+
self,
|
|
582
|
+
*,
|
|
583
|
+
endpoint: str,
|
|
584
|
+
payload: dict,
|
|
585
|
+
urls: list[str] | None = None,
|
|
586
|
+
files: list[Path] | None = None,
|
|
587
|
+
) -> dict:
|
|
588
|
+
"""Record one paid call against this asset — R2.1.
|
|
589
|
+
|
|
590
|
+
Called by the stage right after the call returns and the files are on disk,
|
|
591
|
+
whether or not the stage goes on to succeed: the money is gone either way.
|
|
592
|
+
"""
|
|
593
|
+
from . import ledger
|
|
594
|
+
|
|
595
|
+
line = ledger.entry(
|
|
596
|
+
stage=self.stage.name,
|
|
597
|
+
endpoint=endpoint,
|
|
598
|
+
payload=payload,
|
|
599
|
+
urls=urls,
|
|
600
|
+
files=files,
|
|
601
|
+
)
|
|
602
|
+
return ledger.append(self.ledger, line)
|
|
603
|
+
|
|
604
|
+
def record(self, **fields) -> State:
|
|
605
|
+
"""Mark this stage complete, note what it produced, and write the state file — R1.4.
|
|
606
|
+
|
|
607
|
+
Called once, at the end of a stage that actually produced something. A dry run
|
|
608
|
+
does not call it, which is why a dry run cannot advance an asset.
|
|
609
|
+
|
|
610
|
+
Two entries go in, because two questions get asked. `stages` keeps the order the
|
|
611
|
+
pipeline ran in, and carries `dir` so the next stage can find the output without
|
|
612
|
+
re-deriving the options that chose it. `artifacts` is indexed by what the thing
|
|
613
|
+
*is*, and it lists the files — which is what `show` reads and what the editor
|
|
614
|
+
will read after it.
|
|
615
|
+
"""
|
|
616
|
+
directory = self.out.relative_to(self.directory).as_posix()
|
|
617
|
+
self.state.stages[self.stage.name] = {
|
|
618
|
+
"produced": self.artifact,
|
|
619
|
+
"dir": directory,
|
|
620
|
+
**fields,
|
|
621
|
+
}
|
|
622
|
+
self.state.artifacts[self.artifact] = {
|
|
623
|
+
"dir": directory,
|
|
624
|
+
"files": sorted(path.name for path in self.out.glob("*") if path.is_file())
|
|
625
|
+
if self.out.is_dir()
|
|
626
|
+
else [],
|
|
627
|
+
"stage": self.stage.name,
|
|
628
|
+
# R2.3 — which text produced this, answered without opening the image.
|
|
629
|
+
"prompt": fields.get("prompt", self.prompt),
|
|
630
|
+
**({"prompts": dict(self.parts)} if self.parts else {}),
|
|
631
|
+
}
|
|
632
|
+
save(self.state)
|
|
633
|
+
return self.state
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def prepare(
|
|
637
|
+
asset: str,
|
|
638
|
+
stage_name: str,
|
|
639
|
+
*,
|
|
640
|
+
force: bool = False,
|
|
641
|
+
dry_run: bool = False,
|
|
642
|
+
kind: str | None = None,
|
|
643
|
+
variant: str | None = None,
|
|
644
|
+
) -> Context:
|
|
645
|
+
"""Load the asset, check the stage may run, and hand back its context — R3.2.
|
|
646
|
+
|
|
647
|
+
The one way into a stage. Every stage module's `run` starts here, so the order
|
|
648
|
+
check and the path resolution cannot be skipped by one of them.
|
|
649
|
+
"""
|
|
650
|
+
state = load(asset)
|
|
651
|
+
stage = require_ready(state, stage_name, force=force, kind=kind, variant=variant)
|
|
652
|
+
return Context(
|
|
653
|
+
state=state, stage=stage, force=force, dry_run=dry_run, kind=kind, variant=variant
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def context_from_args(args: argparse.Namespace) -> Context:
|
|
658
|
+
"""`prepare`, filled from the parsed command line.
|
|
659
|
+
|
|
660
|
+
`dry_run` is absent on a free stage's parser, so it is read defensively rather
|
|
661
|
+
than assumed.
|
|
662
|
+
"""
|
|
663
|
+
stage = registry.get(args.command)
|
|
664
|
+
return prepare(
|
|
665
|
+
args.name,
|
|
666
|
+
args.command,
|
|
667
|
+
force=getattr(args, "force", False),
|
|
668
|
+
dry_run=getattr(args, "dry_run", False),
|
|
669
|
+
kind=_option_value(args, stage.kind_option),
|
|
670
|
+
variant=_option_value(args, stage.variant_option),
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _option_value(args: argparse.Namespace, option: str | None) -> str | None:
|
|
675
|
+
"""The value of a named option, or None when the stage declares none."""
|
|
676
|
+
return getattr(args, option, None) if option else None
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
680
|
+
"""Where each asset stopped, and what comes next — R1.3.
|
|
681
|
+
|
|
682
|
+
ASCII only. A Windows console runs on a codepage that has no em dash, and a
|
|
683
|
+
console this tool cannot encode into drops the line rather than raising where
|
|
684
|
+
anyone would see it.
|
|
685
|
+
"""
|
|
686
|
+
root = assets_root()
|
|
687
|
+
as_json = getattr(args, "json", False)
|
|
688
|
+
|
|
689
|
+
if not root.is_dir():
|
|
690
|
+
if as_json:
|
|
691
|
+
print(json.dumps({"root": str(root), "workspace": False, "assets": []}, indent=2))
|
|
692
|
+
return 0
|
|
693
|
+
print(f"no spritegen workspace at {root}; run `spritegen init` to make one")
|
|
694
|
+
return 0
|
|
695
|
+
|
|
696
|
+
names = known()
|
|
697
|
+
if as_json:
|
|
698
|
+
print(
|
|
699
|
+
json.dumps(
|
|
700
|
+
{"root": str(root), "workspace": True, "assets": [inspect(n) for n in names]},
|
|
701
|
+
indent=2,
|
|
702
|
+
ensure_ascii=False,
|
|
703
|
+
)
|
|
704
|
+
)
|
|
705
|
+
return 0
|
|
706
|
+
|
|
707
|
+
if not names:
|
|
708
|
+
print(f"no assets under {root}; run `spritegen new <name>` to open one")
|
|
709
|
+
return 0
|
|
710
|
+
|
|
711
|
+
width = max(len(name) for name in names)
|
|
712
|
+
outdated = []
|
|
713
|
+
for name in names:
|
|
714
|
+
state = load(name)
|
|
715
|
+
last = state.last() or "-"
|
|
716
|
+
upcoming = next_stages(state)
|
|
717
|
+
nxt = ", ".join(upcoming) if upcoming else "- complete"
|
|
718
|
+
stale = " [layout 1]" if state.outdated() else ""
|
|
719
|
+
if state.outdated():
|
|
720
|
+
outdated.append(name)
|
|
721
|
+
print(f"{name:<{width}} last: {last:<7} next: {nxt}{stale}")
|
|
722
|
+
|
|
723
|
+
# R1.5 — an asset in the old shape is not an empty one, and saying which command
|
|
724
|
+
# fixes it is the difference between a finding and a puzzle.
|
|
725
|
+
if outdated:
|
|
726
|
+
which = "these assets are" if len(outdated) > 1 else f"{outdated[0]} is"
|
|
727
|
+
print(f"{which} in an older layout; run `spritegen migrate` to move them")
|
|
728
|
+
return 0
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def inspect(name: str) -> dict:
|
|
732
|
+
"""Everything known about one sprite — R3.1, R3.3.
|
|
733
|
+
|
|
734
|
+
Two independent records go in and neither is reconciled here. `artifacts` is what the
|
|
735
|
+
stage said it wrote; `on_disk` is what is there now. `drift` is where they differ, and
|
|
736
|
+
it is reported rather than resolved: a file deleted by hand and a file written by hand
|
|
737
|
+
are different mistakes, and only the person in front of it knows which happened.
|
|
738
|
+
"""
|
|
739
|
+
from . import prompts
|
|
740
|
+
|
|
741
|
+
state = load(name)
|
|
742
|
+
directory = asset_dir(name)
|
|
743
|
+
|
|
744
|
+
artifacts = {}
|
|
745
|
+
drift = []
|
|
746
|
+
for key, entry in state.artifacts.items():
|
|
747
|
+
target = inside(name, entry["dir"])
|
|
748
|
+
found = (
|
|
749
|
+
sorted(path.name for path in target.glob("*") if path.is_file())
|
|
750
|
+
if target.is_dir()
|
|
751
|
+
else []
|
|
752
|
+
)
|
|
753
|
+
recorded = list(entry.get("files") or [])
|
|
754
|
+
missing = [one for one in recorded if one not in found]
|
|
755
|
+
extra = [one for one in found if one not in recorded]
|
|
756
|
+
if missing or extra:
|
|
757
|
+
drift.append({"artifact": key, "dir": entry["dir"], "missing": missing, "extra": extra})
|
|
758
|
+
artifacts[key] = {**entry, "on_disk": found}
|
|
759
|
+
|
|
760
|
+
kept = {}
|
|
761
|
+
for path in sorted(prompts.directory(directory).glob("*.md")):
|
|
762
|
+
match = prompts.PATTERN.match(path.name)
|
|
763
|
+
if match:
|
|
764
|
+
kept.setdefault(match.group("kind"), []).append(int(match.group("version")))
|
|
765
|
+
|
|
766
|
+
return {
|
|
767
|
+
"name": state.name,
|
|
768
|
+
"created": state.created,
|
|
769
|
+
"layout": state.layout,
|
|
770
|
+
"outdated": state.outdated(),
|
|
771
|
+
"last": state.last(),
|
|
772
|
+
"next": list(next_stages(state)),
|
|
773
|
+
"artifacts": artifacts,
|
|
774
|
+
"prompts": {kind: sorted(found) for kind, found in kept.items()},
|
|
775
|
+
"drift": drift,
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
780
|
+
"""What one sprite holds, and what could be produced next — R3.1, R3.2, R3.3.
|
|
781
|
+
|
|
782
|
+
ASCII only, for the reason `cmd_status` is. `--json` is for another program; a person
|
|
783
|
+
who ran this without the flag asked for the terminal form and should not get JSON.
|
|
784
|
+
"""
|
|
785
|
+
found = inspect(check_name(args.name))
|
|
786
|
+
|
|
787
|
+
if getattr(args, "json", False):
|
|
788
|
+
print(json.dumps(found, indent=2, ensure_ascii=False))
|
|
789
|
+
return 0
|
|
790
|
+
|
|
791
|
+
stale = " [layout 1: run `spritegen migrate`]" if found["outdated"] else ""
|
|
792
|
+
print(f"{found['name']} created {found['created']}{stale}")
|
|
793
|
+
print(f" last: {found['last'] or '-'} next: {', '.join(found['next']) or '- complete'}")
|
|
794
|
+
|
|
795
|
+
if not found["artifacts"]:
|
|
796
|
+
print(" no artifact yet")
|
|
797
|
+
for key, entry in found["artifacts"].items():
|
|
798
|
+
prompt = f" <- {entry['prompt']}" if entry.get("prompt") else ""
|
|
799
|
+
print(f" {key:<16} {entry['dir']:<16} {len(entry['on_disk'])} file(s){prompt}")
|
|
800
|
+
|
|
801
|
+
for kind, found_versions in found["prompts"].items():
|
|
802
|
+
print(f" prompt {kind}: v{', v'.join(str(one) for one in found_versions)}")
|
|
803
|
+
|
|
804
|
+
for one in found["drift"]:
|
|
805
|
+
where = one["artifact"]
|
|
806
|
+
if one["missing"]:
|
|
807
|
+
gone = ", ".join(one["missing"])
|
|
808
|
+
print(f" drift {where}: recorded but not on disk: {gone}")
|
|
809
|
+
if one["extra"]:
|
|
810
|
+
new_files = ", ".join(one["extra"])
|
|
811
|
+
print(f" drift {where}: on disk but not recorded: {new_files}")
|
|
812
|
+
return 0
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def cmd_new(args: argparse.Namespace) -> int:
|
|
816
|
+
state = create(args.name)
|
|
817
|
+
print(f"{state.name} opened at {asset_dir(state.name)} no stage complete")
|
|
818
|
+
return 0
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def cmd_cost(args: argparse.Namespace) -> int:
|
|
822
|
+
"""The paid calls per asset, read from the ledger and nothing else — R2.4.
|
|
823
|
+
|
|
824
|
+
Deliberately does not open `state.json`. An asset whose outputs were deleted, or
|
|
825
|
+
whose state file was lost, still cost what it cost, and the ledger is the only
|
|
826
|
+
record that survives either.
|
|
827
|
+
"""
|
|
828
|
+
from . import ledger
|
|
829
|
+
|
|
830
|
+
requested = getattr(args, "name", None)
|
|
831
|
+
names = [check_name(requested)] if requested else known()
|
|
832
|
+
if not names:
|
|
833
|
+
print(f"no assets under {assets_root()}")
|
|
834
|
+
return 0
|
|
835
|
+
|
|
836
|
+
total_calls = 0
|
|
837
|
+
for name in names:
|
|
838
|
+
rows = ledger.summarise(ledger.read(ledger_path(name)))
|
|
839
|
+
calls = sum(row["calls"] for row in rows)
|
|
840
|
+
total_calls += calls
|
|
841
|
+
print(f"{name} {calls} paid call{'' if calls == 1 else 's'}")
|
|
842
|
+
for row in sorted(rows, key=lambda r: (r["stage"], r["endpoint"])):
|
|
843
|
+
size = f"{row['bytes'] / 1e6:.1f} MB" if row["bytes"] else "-"
|
|
844
|
+
files = f"{row['files']} file" + ("" if row["files"] == 1 else "s")
|
|
845
|
+
print(
|
|
846
|
+
f" {row['stage']:<7} {row['endpoint']:<38} "
|
|
847
|
+
f"{row['calls']:>3} x {files:<9} {size}"
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
if len(names) > 1:
|
|
851
|
+
print(f"total {total_calls} paid calls")
|
|
852
|
+
return 0
|