argus-forge 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ """argus-forge — Training bridge: turn curated dataset exports into ready-to-run LoRA training configs (kohya_ss / OneTrainer / diffusers)"""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ # Written by hatch-vcs at build time (see pyproject [tool.hatch.build.hooks.vcs]).
7
+ from argus_forge._version import __version__
8
+ except ImportError: # running from a source checkout that hasn't been built
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ try:
12
+ __version__ = version("argus-forge")
13
+ except PackageNotFoundError:
14
+ __version__ = "0.0.0+unknown"
15
+
16
+ __all__ = ["__version__"]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.1'
22
+ __version_tuple__ = version_tuple = (0, 1, 1)
23
+
24
+ __commit_id__ = commit_id = None
argus_forge/cli.py ADDED
@@ -0,0 +1,374 @@
1
+ """argus-forge CLI — ``config``, ``run``, ``inspect``, ``trainers``, ``schema``, ``serve``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import structlog
12
+
13
+ try:
14
+ import typer
15
+ from typer import Argument, Option
16
+ except ImportError as _exc: # pragma: no cover
17
+ print("CLI requires: pip install argus-forge[cli]", file=sys.stderr)
18
+ raise SystemExit(1) from _exc
19
+
20
+ app = typer.Typer(
21
+ name="argus-forge",
22
+ help="Training bridge: turn curated dataset exports into ready-to-run LoRA training configs.",
23
+ no_args_is_help=True,
24
+ )
25
+
26
+
27
+ @app.callback()
28
+ def _cli(verbose: bool = Option(False, "--verbose", "-v", help="Show info/debug logs")) -> None:
29
+ """Keep stdout clean for --json output; ``serve`` re-enables info logs."""
30
+ level = logging.DEBUG if verbose else logging.WARNING
31
+ structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(level))
32
+
33
+
34
+ @app.command()
35
+ def config(
36
+ export_dir: Path = Argument(..., help="Curator export dir (images + manifest.jsonl + .txt sidecars)"),
37
+ trainer: str = Option("kohya", "--trainer", "-t", help="kohya | onetrainer | diffusers"),
38
+ base_model: str | None = Option(None, "--base-model", help="Checkpoint/repo (default: manifest, else SDXL base)"),
39
+ trigger: str | None = Option(None, "--trigger", help="Concept token (default: slug of the export dir name)"),
40
+ output_name: str | None = Option(None, "--output-name", help="LoRA filename stem (default: <dir>-lora)"),
41
+ category: str | None = Option(
42
+ None, "--category", help="Override manifest category: identity|wardrobe|pose_composition|setting"
43
+ ),
44
+ repeats: int | None = Option(None, "--repeats", help="Override per-image repeats"),
45
+ epochs: int | None = Option(None, "--epochs", help="Override epochs"),
46
+ network_dim: int | None = Option(None, "--network-dim", help="Override LoRA rank"),
47
+ network_alpha: int | None = Option(None, "--network-alpha", help="Override LoRA alpha"),
48
+ unet_lr: float | None = Option(None, "--unet-lr", help="Override UNet learning rate"),
49
+ text_encoder_lr: float | None = Option(None, "--text-encoder-lr", help="Override text-encoder learning rate"),
50
+ optimizer: str | None = Option(None, "--optimizer", help="Override optimizer (kohya name, e.g. AdamW8bit)"),
51
+ scheduler: str | None = Option(None, "--scheduler", help="Override LR scheduler"),
52
+ resolution: int | None = Option(None, "--resolution", help="Override training resolution"),
53
+ batch_size: int | None = Option(None, "--batch-size", help="Override batch size"),
54
+ precision: str | None = Option(None, "--precision", help="Override mixed precision (bf16/fp16)"),
55
+ collect_captions: bool = Option(
56
+ True,
57
+ "--collect-captions/--no-collect-captions",
58
+ help="Copy .txt sidecars from manifest source paths into the export",
59
+ ),
60
+ path_map: list[str] = Option(
61
+ [],
62
+ "--path-map",
63
+ help="Rewrite a path prefix in emitted configs, as CONTAINER=HOST (one entry per flag; also FORGE_PATH_MAP env)",
64
+ ),
65
+ dry_run: bool = Option(False, "--dry-run", help="Print rendered files without writing anything"),
66
+ as_json: bool = Option(False, "--json", help="Print the full ForgeResult as JSON"),
67
+ ) -> None:
68
+ """Emit a ready-to-run training config for a curated export."""
69
+ from argus_forge.core import forge_config, path_map_entry
70
+ from argus_forge.emitters.base import map_path
71
+ from argus_forge.models import ForgeError, ForgeRequest, ParamOverrides
72
+
73
+ overrides = ParamOverrides(
74
+ repeats=repeats,
75
+ epochs=epochs,
76
+ network_dim=network_dim,
77
+ network_alpha=network_alpha,
78
+ unet_lr=unet_lr,
79
+ text_encoder_lr=text_encoder_lr,
80
+ optimizer=optimizer,
81
+ scheduler=scheduler,
82
+ resolution=resolution,
83
+ batch_size=batch_size,
84
+ precision=precision,
85
+ )
86
+ try:
87
+ req = ForgeRequest(
88
+ export_dir=str(export_dir),
89
+ trainer=trainer, # type: ignore[arg-type] (pydantic validates the literal)
90
+ base_model=base_model,
91
+ trigger=trigger,
92
+ output_name=output_name,
93
+ category=category, # type: ignore[arg-type]
94
+ overrides=overrides,
95
+ collect_captions=collect_captions,
96
+ # One entry per flag, parsed individually — paths may contain commas.
97
+ path_map=dict(path_map_entry(entry, source="--path-map") for entry in path_map),
98
+ dry_run=dry_run,
99
+ )
100
+ result = forge_config(req)
101
+ except (ForgeError, ValueError) as exc:
102
+ typer.echo(f"Error: {exc}", err=True)
103
+ raise typer.Exit(1) from exc
104
+
105
+ if as_json:
106
+ typer.echo(result.model_dump_json(indent=2))
107
+ return
108
+
109
+ p = result.params
110
+ typer.echo(f"Forged {result.trainer} config for {result.export_dir}")
111
+ typer.echo(
112
+ f" {p.images} images x {p.repeats} repeats x {p.epochs} epochs "
113
+ f"= {p.total_steps} samples ({p.optimizer_steps} steps @ batch {p.batch_size})"
114
+ )
115
+ typer.echo(f" dim/alpha {p.network_dim}/{p.network_alpha} · lr {p.unet_lr}/{p.text_encoder_lr} · {p.precision}")
116
+ typer.echo(f" base model: {result.base_model}")
117
+ typer.echo(f" trigger: {result.trigger}")
118
+ if result.captions_collected:
119
+ typer.echo(f" captions collected from sources: {result.captions_collected}")
120
+ for w in result.warnings:
121
+ typer.echo(f" ! {w}")
122
+ typer.echo("")
123
+ if dry_run:
124
+ for f in result.files:
125
+ typer.echo(f"----- {f.name} -----")
126
+ typer.echo(f.content)
127
+ else:
128
+ for f in result.files:
129
+ typer.echo(f" wrote {f.path}")
130
+ run_hint = next((f.path for f in result.files if f.name.endswith("train.sh")), None)
131
+ if run_hint:
132
+ # The next step runs where the *mapped* paths live, so hint with those
133
+ # even though the "wrote" lines above are forge's own (real) locations.
134
+ from argus_forge.core import resolve_path_map
135
+
136
+ effective_map = resolve_path_map(req.path_map)
137
+ typer.echo(
138
+ f"\nNext: review {map_path(result.out_dir, effective_map)}/README.md, "
139
+ f"then run {map_path(run_hint, effective_map)}"
140
+ )
141
+
142
+
143
+ def _run_exit_status(returncode: int | None, error_seen: bool) -> int:
144
+ """The CLI's own exit code for a run: the trainer's return code (128+N for a
145
+ signal death, per shell convention), or 1 if the run errored without a clean
146
+ exit. Keeps a signal-killed or errored run from ever reporting success."""
147
+ if returncode is None:
148
+ return 1 if error_seen else 0
149
+ if returncode < 0: # killed by signal -N
150
+ return 128 + (-returncode)
151
+ return returncode or (1 if error_seen else 0)
152
+
153
+
154
+ @app.command()
155
+ def run(
156
+ export_dir: Path = Argument(..., help="Forged export dir (contains forge/<trainer>/train.sh)"),
157
+ trainer: str = Option("kohya", "--trainer", "-t", help="kohya | diffusers (onetrainer has no train.sh)"),
158
+ env: list[str] = Option(
159
+ [],
160
+ "--env",
161
+ help="Extra env for the trainer as KEY=VALUE (e.g. SD_SCRIPTS_DIR=~/kohya-ss/sd-scripts); one per flag",
162
+ ),
163
+ dry_run: bool = Option(False, "--dry-run", help="Print the command that would run, without executing it"),
164
+ as_json: bool = Option(False, "--json", help="Stream raw NDJSON RunEvents instead of human-readable output"),
165
+ ) -> None:
166
+ """Run a forged training config: shell out to the trainer, streaming progress."""
167
+ from argus_forge.models import ForgeError, RunRequest
168
+ from argus_forge.runner import astream_run
169
+
170
+ env_map: dict[str, str] = {}
171
+ for item in env:
172
+ key, sep, val = item.partition("=")
173
+ if not sep or not key:
174
+ typer.echo(f"Error: --env expects KEY=VALUE, got {item!r}", err=True)
175
+ raise typer.Exit(1)
176
+ env_map[key] = val
177
+
178
+ try:
179
+ req = RunRequest(
180
+ export_dir=str(export_dir),
181
+ trainer=trainer, # type: ignore[arg-type] (pydantic validates the literal)
182
+ env=env_map,
183
+ dry_run=dry_run,
184
+ )
185
+ except ValueError as exc: # pydantic ValidationError, e.g. an unknown --trainer
186
+ typer.echo(f"Error: {exc}", err=True)
187
+ raise typer.Exit(1) from exc
188
+
189
+ async def drive() -> int:
190
+ returncode: int | None = None
191
+ error_seen = False
192
+ async for ev in astream_run(req):
193
+ if as_json:
194
+ typer.echo(ev.model_dump_json())
195
+ continue
196
+ if ev.type == "start":
197
+ typer.echo(f"▶ run {ev.run_id}: {' '.join(ev.command or [])} (cwd {ev.cwd})")
198
+ if dry_run:
199
+ typer.echo(" dry run — not executing")
200
+ elif ev.type == "log":
201
+ typer.echo(ev.message or "")
202
+ elif ev.type == "exit":
203
+ returncode = ev.returncode
204
+ typer.echo(f"{'✔' if returncode == 0 else '✗'} run {ev.run_id} finished (exit {returncode})")
205
+ elif ev.type == "error":
206
+ error_seen = True
207
+ typer.echo(f"Error: {ev.message}", err=True)
208
+ return _run_exit_status(returncode, error_seen)
209
+
210
+ try:
211
+ exit_code = asyncio.run(drive())
212
+ except ForgeError as exc:
213
+ typer.echo(f"Error: {exc}", err=True)
214
+ raise typer.Exit(1) from exc
215
+ raise typer.Exit(exit_code)
216
+
217
+
218
+ @app.command()
219
+ def inspect(
220
+ export_dir: Path = Argument(..., help="Curator export dir to inspect"),
221
+ category: str | None = Option(None, "--category", help="Override manifest category"),
222
+ as_json: bool = Option(False, "--json", help="Print DatasetInfo as JSON"),
223
+ ) -> None:
224
+ """Summarise an export dir: images, captions, manifest, suggested params."""
225
+ from argus_forge.manifest import inspect_export
226
+ from argus_forge.models import ForgeError
227
+
228
+ try:
229
+ info, _ = inspect_export(export_dir, category=category) # type: ignore[arg-type]
230
+ except ForgeError as exc:
231
+ typer.echo(f"Error: {exc}", err=True)
232
+ raise typer.Exit(1) from exc
233
+
234
+ if as_json:
235
+ typer.echo(info.model_dump_json(indent=2))
236
+ return
237
+
238
+ typer.echo(f"Export: {info.export_dir}")
239
+ typer.echo(f" images: {info.image_count} ({info.caption_count} captioned)")
240
+ if info.manifest_present:
241
+ typer.echo(f" manifest: v{info.manifest_version}, {info.manifest_rows} rows, {info.missing_from_disk} missing")
242
+ else:
243
+ typer.echo(" manifest: none (bare folder mode)")
244
+ tp = info.target_profile
245
+ typer.echo(f" profile: {tp.target_category} / {tp.target_style} / {tp.target_backend or '-'}")
246
+ typer.echo(f" size: [{info.size_hint.tone}] {info.size_hint.text}")
247
+ p = info.suggested
248
+ typer.echo(
249
+ f" suggest: {p.repeats} repeats x {p.epochs} epochs = {p.total_steps} samples, "
250
+ f"dim/alpha {p.network_dim}/{p.network_alpha}, lr {p.unet_lr}/{p.text_encoder_lr}"
251
+ )
252
+
253
+
254
+ @app.command()
255
+ def trainers() -> None:
256
+ """List supported trainers and what forge emits for each."""
257
+ from argus_forge.emitters import TRAINER_INFO
258
+
259
+ for info in TRAINER_INFO.values():
260
+ typer.echo(f"{info.id:12s} {info.label}")
261
+ typer.echo(f"{'':12s} files: {', '.join(info.files)}")
262
+ typer.echo(f"{'':12s} {info.notes}")
263
+
264
+
265
+ DEFAULT_SCHEMA_PATH = Path("schema/forge-wire.schema.json")
266
+
267
+
268
+ @app.command()
269
+ def schema(
270
+ output: Path = Option(DEFAULT_SCHEMA_PATH, "--output", "-o", help="Where to write the JSON Schema"),
271
+ check: bool = Option(False, "--check", help="Exit non-zero if the committed schema is stale (for CI)"),
272
+ ) -> None:
273
+ """Emit the wire-contract JSON Schema consumers codegen against."""
274
+ import json
275
+
276
+ from argus_forge.models import wire_schema
277
+
278
+ rendered = json.dumps(wire_schema(), indent=2, sort_keys=True) + "\n"
279
+
280
+ if check:
281
+ existing = output.read_text(encoding="utf-8") if output.exists() else ""
282
+ if existing != rendered:
283
+ typer.echo(f"{output} is stale — run `argus-forge schema` and commit the result.", err=True)
284
+ raise typer.Exit(1)
285
+ typer.echo(f"{output} is up to date.")
286
+ return
287
+
288
+ output.parent.mkdir(parents=True, exist_ok=True)
289
+ output.write_text(rendered, encoding="utf-8")
290
+ typer.echo(f"Wrote wire schema -> {output}")
291
+
292
+
293
+ @app.command()
294
+ def serve(
295
+ port: int = Option(8103, "--port", "-p", help="Port to listen on"),
296
+ host: str = Option("0.0.0.0", "--host", help="Host to bind to"),
297
+ cors: bool = Option(False, "--cors", help="Enable CORS for the localhost:3000 studio frontend"),
298
+ cors_origin: list[str] = Option(
299
+ [], "--cors-origin", help="Allowed CORS origin (repeatable; implies CORS; or FORGE_CORS_ORIGINS)"
300
+ ),
301
+ cors_any: bool = Option(
302
+ False,
303
+ "--cors-any",
304
+ help="Allow ANY origin, credential-less (public demos; pair with --cors-origin to let your own frontend write)",
305
+ ),
306
+ export_root: str | None = Option(
307
+ None,
308
+ "--export-root",
309
+ help="Contain request export_dir paths under this directory (or ARGUS_FORGE_EXPORT_ROOT); required by the API",
310
+ ),
311
+ no_run: bool = Option(
312
+ False,
313
+ "--no-run",
314
+ help="Demo-safe mode: /config renders (dry-run only) but /run is refused with 403. Or ARGUS_FORGE_READONLY=1.",
315
+ ),
316
+ ) -> None:
317
+ """Start the argus-forge micro-server (FastAPI) on :8103."""
318
+ try:
319
+ import uvicorn
320
+ except ImportError as _exc: # pragma: no cover
321
+ typer.echo("Server requires: pip install argus-forge[server]", err=True)
322
+ raise typer.Exit(1) from _exc
323
+
324
+ from argus_forge.core import env_path_map
325
+ from argus_forge.models import ARGUS_ROOT_ENV, CORS_ORIGINS_ENV, LEGACY_ROOT_ENV, ForgeError
326
+ from argus_forge.server import create_app, env_readonly
327
+
328
+ structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(logging.INFO))
329
+ try:
330
+ env_path_map() # fail fast at startup, not as a 400 on every /config
331
+ except ForgeError as exc:
332
+ typer.echo(f"Error: {exc}", err=True)
333
+ raise typer.Exit(1) from exc
334
+
335
+ # These warnings must describe the app create_app will actually build, so
336
+ # they resolve the same env fallbacks it does — a warning that contradicts
337
+ # the running server sends the operator to debug a working config.
338
+ resolved_root = export_root or os.environ.get(ARGUS_ROOT_ENV) or os.environ.get(LEGACY_ROOT_ENV)
339
+ # Normalised the way create_app normalises them, so a value that only *looks*
340
+ # like an origin list (",", whitespace) is correctly reported as no CORS at
341
+ # all rather than silently suppressing the warning for a server that has none.
342
+ env_origins = [o for o in (os.environ.get(CORS_ORIGINS_ENV) or "").split(",") if o.strip().rstrip("/")]
343
+ cors_on = bool(cors or cors_origin or cors_any or env_origins)
344
+ readonly = no_run or env_readonly()
345
+
346
+ if not cors_on:
347
+ typer.echo(
348
+ "CORS is disabled — browser clients (e.g. the argus-studio frontend on :3000) "
349
+ f"will fail with 'Failed to fetch'; pass --cors (or set {CORS_ORIGINS_ENV}) to allow them.",
350
+ err=True,
351
+ )
352
+ if readonly:
353
+ typer.echo(
354
+ "Demo-safe mode: /run is refused with 403 and /config renders dry-run only (nothing is written).",
355
+ err=True,
356
+ )
357
+ if not resolved_root:
358
+ typer.echo(
359
+ "No export root — /inspect, /config and /run will refuse every request with a 400. "
360
+ f"Pass --export-root (or set {ARGUS_ROOT_ENV}) to the dir holding curator exports.",
361
+ err=True,
362
+ )
363
+ application = create_app(
364
+ cors=cors,
365
+ cors_origins=list(cors_origin) or None,
366
+ cors_allow_any=cors_any,
367
+ export_root=export_root,
368
+ allow_run=not readonly,
369
+ )
370
+ uvicorn.run(application, host=host, port=port)
371
+
372
+
373
+ if __name__ == "__main__":
374
+ app()