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/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