velophex 0.2.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.
Files changed (43) hide show
  1. velophex/__init__.py +38 -0
  2. velophex/_json.py +297 -0
  3. velophex/agents/__init__.py +130 -0
  4. velophex/agents/_errors.py +75 -0
  5. velophex/agents/_loop.py +436 -0
  6. velophex/agents/_tools.py +168 -0
  7. velophex/agents/_tracing.py +87 -0
  8. velophex/agents/testing.py +249 -0
  9. velophex/cli/__init__.py +5 -0
  10. velophex/cli/main.py +548 -0
  11. velophex/cli/publish.py +152 -0
  12. velophex/cli/sync.py +90 -0
  13. velophex/cli/templates.py +126 -0
  14. velophex/contracts/__init__.py +103 -0
  15. velophex/contracts/_model.py +26 -0
  16. velophex/contracts/agents.py +110 -0
  17. velophex/contracts/diagnostics.py +157 -0
  18. velophex/contracts/executor.py +1921 -0
  19. velophex/contracts/package.py +391 -0
  20. velophex/contracts/project.py +1008 -0
  21. velophex/contracts/runtimes.py +226 -0
  22. velophex/contracts/signin.py +53 -0
  23. velophex/contracts/validation.py +158 -0
  24. velophex/entrypoints.py +329 -0
  25. velophex/errors.py +44 -0
  26. velophex/packaging.py +465 -0
  27. velophex/py.typed +0 -0
  28. velophex/runtime/__init__.py +471 -0
  29. velophex/runtime/_session.py +192 -0
  30. velophex/runtime/client.py +957 -0
  31. velophex/runtime/models.py +326 -0
  32. velophex/signing.py +425 -0
  33. velophex/worker/__init__.py +27 -0
  34. velophex/worker/__main__.py +118 -0
  35. velophex/worker/framing.py +248 -0
  36. velophex/worker/loader.py +105 -0
  37. velophex/worker/outbox.py +147 -0
  38. velophex/worker/session.py +519 -0
  39. velophex-0.2.0.dist-info/METADATA +313 -0
  40. velophex-0.2.0.dist-info/RECORD +43 -0
  41. velophex-0.2.0.dist-info/WHEEL +4 -0
  42. velophex-0.2.0.dist-info/entry_points.txt +2 -0
  43. velophex-0.2.0.dist-info/licenses/LICENSE +21 -0
velophex/cli/main.py ADDED
@@ -0,0 +1,548 @@
1
+ """``velophex init | run | pack | publish``.
2
+
3
+ The four verbs a Python automation author needs, in the order they are used:
4
+
5
+ * ``init`` scaffolds a uv project with one decorated handler and a ``project.json``.
6
+ * ``run`` executes an entry point locally, with the same input validation and the same
7
+ ``Business``/``Application`` distinction the Runner applies, so a run that works here
8
+ works on a robot.
9
+ * ``pack`` refreshes ``project.json`` from the handlers and builds the ``.nupkg``
10
+ (ADR-P003), optionally adding the detached signature with ``--sign``.
11
+ * ``publish`` pushes that package to an Orchestrator tenant's NuGet v3 feed.
12
+
13
+ Diagnostics are printed with the same codes the .NET validators emit (``VPC1012`` and so on),
14
+ so a message from the CLI and one from the Orchestrator upload name the same rule.
15
+
16
+ Exit codes are stable: ``0`` success, ``1`` the command failed, ``2`` the inputs given to
17
+ ``run`` could not be read or do not fit the entry point, ``3`` the handler raised a business
18
+ rule.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ import os
26
+ import re
27
+ import shutil
28
+ import sys
29
+ import traceback
30
+ from datetime import UTC, datetime
31
+ from pathlib import Path
32
+ from typing import Annotated, Any
33
+ from uuid import UUID
34
+
35
+ import typer
36
+
37
+ from velophex import __version__, _json
38
+ from velophex.cli.publish import PublishError, publish_package
39
+ from velophex.cli.sync import synchronize
40
+ from velophex.cli.templates import handler_module, module_name, new_project_manifest, pyproject_toml
41
+ from velophex.contracts.diagnostics import Diagnostic, Severity, has_errors
42
+ from velophex.contracts.project import ProjectFiles, read_project_manifest, write_project_manifest
43
+ from velophex.contracts.runtimes import FaultCodes
44
+ from velophex.entrypoints import EntryPointInputError, normalize_outputs
45
+ from velophex.errors import BusinessError
46
+ from velophex.packaging import PackageBuildOptions, build_package_to_directory
47
+ from velophex.signing import SigningError, SigningKey, load_signing_key, sign_package_file
48
+ from velophex.worker.loader import PackageLoadError, load_entry_point
49
+
50
+ app = typer.Typer(
51
+ name="velophex",
52
+ help="Author, run, pack and publish VeloPhex Python automations.",
53
+ no_args_is_help=True,
54
+ add_completion=False,
55
+ )
56
+
57
+ _CREATED_BY = f"velophex-cli {__version__}"
58
+
59
+ _EXIT_FAILED = 1
60
+ _EXIT_INPUT = 2
61
+ _EXIT_BUSINESS = 3
62
+
63
+ #: Where ``pack --sign`` reads the PKCS#12 password from. There is deliberately no option for
64
+ #: it: a command-line argument is visible in shell history and in every process listing.
65
+ SIGNING_PASSWORD_VARIABLE = "VELOPHEX_SIGNING_PASSWORD"
66
+
67
+ #: Where ``publish`` reads the personal access token from, for the same reason.
68
+ API_KEY_VARIABLE = "VELOPHEX_API_KEY"
69
+
70
+
71
+ @app.callback()
72
+ def _root(
73
+ version: Annotated[bool, typer.Option("--version", help="Print the SDK version and exit.")] = False,
74
+ ) -> None:
75
+ if version:
76
+ typer.echo(__version__)
77
+ raise typer.Exit
78
+
79
+
80
+ @app.command()
81
+ def init(
82
+ name: Annotated[str, typer.Argument(help="Project and package name, e.g. Invoice.Ocr.")],
83
+ directory: Annotated[Path | None, typer.Option("--directory", "-d", help="Where to create it.")] = None,
84
+ version: Annotated[str, typer.Option(help="Initial SemVer version.")] = "1.0.0",
85
+ sdk: Annotated[
86
+ str | None,
87
+ typer.Option(help="Version specifier for the velophex SDK dependency; this SDK's series by default."),
88
+ ] = None,
89
+ sdk_wheel: Annotated[
90
+ Path | None,
91
+ typer.Option(
92
+ help="The velophex SDK wheel to vendor into the project; by default this SDK's own wheel, "
93
+ "looked up in the UV_FIND_LINKS folders."
94
+ ),
95
+ ] = None,
96
+ ) -> None:
97
+ """Create a new Python automation project."""
98
+ sdk = sdk or sdk_specifier(__version__)
99
+ root = Path(directory) if directory is not None else Path.cwd() / name
100
+
101
+ if root.exists() and any(root.iterdir()):
102
+ _fail(f"'{root}' is not empty.")
103
+
104
+ if sdk_wheel is not None and not sdk_wheel.is_file():
105
+ _fail(f"The SDK wheel '{sdk_wheel}' does not exist.")
106
+
107
+ wheel = sdk_wheel or find_sdk_wheel(os.environ.get("UV_FIND_LINKS", ""), __version__)
108
+ vendored = None
109
+
110
+ if wheel is not None:
111
+ (root / SDK_WHEEL_FOLDER).mkdir(parents=True, exist_ok=True)
112
+ shutil.copyfile(wheel, root / SDK_WHEEL_FOLDER / wheel.name)
113
+ vendored = f"{SDK_WHEEL_FOLDER}/{wheel.name}"
114
+
115
+ module = module_name(name)
116
+ manifest = new_project_manifest(name, version)
117
+
118
+ (root / "src" / module).mkdir(parents=True, exist_ok=True)
119
+ (root / "src" / module / "__init__.py").write_text("", encoding="utf-8")
120
+ (root / "src" / module / "main.py").write_text(handler_module(name), encoding="utf-8", newline="\n")
121
+ (root / "pyproject.toml").write_text(pyproject_toml(name, version, sdk, vendored), encoding="utf-8", newline="\n")
122
+ (root / ProjectFiles.MANIFEST_FILE_NAME).write_bytes(write_project_manifest(manifest))
123
+ (root / ".gitignore").write_text(".venv/\n__pycache__/\n*.nupkg\n", encoding="utf-8", newline="\n")
124
+
125
+ typer.echo(f"Created {name} in {root}.")
126
+
127
+ if vendored is None:
128
+ typer.echo(
129
+ f"Warning: no velophex-{__version__} wheel was vendored (pass --sdk-wheel, or put its folder in "
130
+ "UV_FIND_LINKS). The SDK is not on a public index, so a Robot cannot restore this project "
131
+ "until it is.",
132
+ err=True,
133
+ )
134
+
135
+ typer.echo(next_steps(root))
136
+
137
+
138
+ #: Where ``init`` vendors the SDK wheel, relative to the project.
139
+ SDK_WHEEL_FOLDER = "wheels"
140
+
141
+
142
+ def find_sdk_wheel(find_links: str, version: str) -> Path | None:
143
+ """This SDK's wheel in one of the comma-separated *find_links* folders (``UV_FIND_LINKS``)."""
144
+ for folder in filter(None, (part.strip() for part in find_links.split(","))):
145
+ candidate = Path(folder) / f"velophex-{version}-py3-none-any.whl"
146
+
147
+ if candidate.is_file():
148
+ return candidate
149
+
150
+ return None
151
+
152
+
153
+ def sdk_specifier(version: str) -> str:
154
+ """The SDK dependency a new project gets: this SDK's compatible series, never unbounded.
155
+
156
+ Before 1.0 a minor release may break, so ``0.1.1`` gives ``>=0.1.1,<0.2``; from 1.0 on the
157
+ bound is the next major.
158
+ """
159
+ found = re.match(r"(\d+)\.(\d+)\.\d+", version)
160
+
161
+ if found is None:
162
+ raise ValueError(f"'{version}' is not a release version.")
163
+
164
+ major, minor = int(found[1]), int(found[2])
165
+ upper = f"{major}.{minor + 1}" if major == 0 else f"{major + 1}"
166
+ return f">={found[0]},<{upper}"
167
+
168
+
169
+ def next_steps(root: Path) -> str:
170
+ """What to type after ``init``: the same transcript as the README quickstart."""
171
+ try:
172
+ where = os.path.relpath(root)
173
+ except ValueError: # another drive
174
+ where = str(root)
175
+
176
+ return (
177
+ "Next:\n"
178
+ f" cd {where}\n"
179
+ " uv sync\n"
180
+ """ echo '{"message": "Hi"}' > inputs.json\n"""
181
+ " uv run velophex run main --inputs-file inputs.json"
182
+ )
183
+
184
+
185
+ @app.command()
186
+ def run(
187
+ entry_point: Annotated[str | None, typer.Argument(help="Entry point id; the default one when omitted.")] = None,
188
+ directory: Annotated[Path, typer.Option("--directory", "-d", help="Project directory.")] = Path("."),
189
+ inputs: Annotated[str | None, typer.Option("--inputs", help="Inputs as a JSON object.")] = None,
190
+ inputs_file: Annotated[Path | None, typer.Option("--inputs-file", help="Inputs as a JSON file.")] = None,
191
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show debug logging.")] = False,
192
+ ) -> None:
193
+ """Run one entry point locally.
194
+
195
+ There is no Orchestrator behind a local run, so ``velophex.runtime`` calls that need one
196
+ (assets, queues, child jobs) raise rather than silently doing nothing.
197
+ """
198
+ logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO, format="%(levelname)s %(name)s: %(message)s")
199
+
200
+ try:
201
+ loaded = load_entry_point(directory, entry_point)
202
+ except PackageLoadError as error:
203
+ _fail(str(error))
204
+ return
205
+
206
+ try:
207
+ arguments = loaded.definition.bind(_read_inputs(inputs, inputs_file, verbose))
208
+ except EntryPointInputError as error:
209
+ _input_error(
210
+ f"{error} (at {', '.join(error.pointers)}). Check the inputs against the entry point's "
211
+ "parameters in project.json.",
212
+ error,
213
+ verbose,
214
+ )
215
+ return
216
+
217
+ # A local run installs the same ambient session the worker does, so stop_requested() and
218
+ # context() behave the same way here as on a robot.
219
+ from velophex.contracts.executor import ExecutionContextInfo
220
+ from velophex.runtime import _session as ambient
221
+
222
+ session = ambient.RunSession.create(ExecutionContextInfo(), loaded.definition, str(Path(directory).resolve()))
223
+ ambient.set_session(session)
224
+
225
+ try:
226
+ outputs = loaded.definition.function(**arguments)
227
+
228
+ if loaded.definition.is_async:
229
+ import asyncio
230
+
231
+ outputs = asyncio.run(outputs)
232
+ except BusinessError as error:
233
+ typer.secho(f"Business rule: [{error.code}] {error.message}", fg=typer.colors.YELLOW, err=True)
234
+ raise typer.Exit(_EXIT_BUSINESS) from error
235
+ finally:
236
+ ambient.clear_session()
237
+
238
+ # The worker's rule and the worker's writer, so a local run prints what a robot would send
239
+ # and refuses what a robot would refuse.
240
+ normalized = normalize_outputs(outputs)
241
+
242
+ try:
243
+ wire = _json.dumps_wire(normalized)
244
+ except (TypeError, ValueError) as error:
245
+ _fail(f"{FaultCodes.RESULT_NOT_SERIALIZABLE}: the outputs cannot be written as JSON: {error}")
246
+ return
247
+
248
+ typer.echo(json.dumps(json.loads(wire), indent=2, ensure_ascii=False))
249
+
250
+
251
+ @app.command()
252
+ def pack(
253
+ directory: Annotated[Path, typer.Option("--directory", "-d", help="Project directory.")] = Path("."),
254
+ output: Annotated[Path, typer.Option("--output", "-o", help="Where to write the .nupkg.")] = Path("artifacts"),
255
+ sync: Annotated[
256
+ bool,
257
+ typer.Option("--sync/--no-sync", help="Refresh project.json from the decorated handlers before packing."),
258
+ ] = True,
259
+ sign: Annotated[
260
+ Path | None,
261
+ typer.Option("--sign", help="Sign the package with this PKCS#12 (.pfx/.p12) certificate and key."),
262
+ ] = None,
263
+ sign_password_stdin: Annotated[
264
+ bool,
265
+ typer.Option(
266
+ "--sign-password-stdin",
267
+ help=f"Read the PKCS#12 password from stdin instead of ${SIGNING_PASSWORD_VARIABLE}.",
268
+ ),
269
+ ] = False,
270
+ ) -> None:
271
+ """Build the automation package (``.nupkg``) for this project.
272
+
273
+ ``--sign`` adds the detached ``velophex/signature.json`` (ADR-P003) after the manifest is
274
+ final. Its password comes from ``$VELOPHEX_SIGNING_PASSWORD``, from stdin with
275
+ ``--sign-password-stdin``, or from a hidden prompt on a terminal - never from an option,
276
+ which would be recorded in shell history and readable in the process list. Signing is
277
+ opt-in: an unsigned package is a valid package, and policy decides whether it may run.
278
+ """
279
+ root = Path(directory)
280
+ project_file = root / ProjectFiles.MANIFEST_FILE_NAME
281
+
282
+ if not project_file.is_file():
283
+ _fail(f"'{root}' has no {ProjectFiles.MANIFEST_FILE_NAME}. Run 'velophex init' first.")
284
+
285
+ manifest, diagnostics = read_project_manifest(project_file.read_bytes())
286
+
287
+ if manifest is None or has_errors(diagnostics):
288
+ _report(diagnostics)
289
+ _fail(f"{project_file} is not a valid project manifest.")
290
+ return
291
+
292
+ lock_file = root / (manifest.runtime.lock_file or ProjectFiles.PYTHON_LOCK_FILE_NAME)
293
+
294
+ if not lock_file.is_file():
295
+ # Without a lock file the Runner cannot reproduce the environment, so the package
296
+ # would be refused on upload; failing here saves that round trip (ADR-P008).
297
+ _fail(f"'{lock_file.name}' is missing. Run 'uv sync' to lock the environment before packing.")
298
+
299
+ if sync:
300
+ result = synchronize(root, manifest)
301
+
302
+ for warning in result.warnings:
303
+ typer.secho(f"warning: {warning}", fg=typer.colors.YELLOW, err=True)
304
+
305
+ if result.changed:
306
+ project_file.write_bytes(write_project_manifest(result.manifest))
307
+ typer.echo(f"Updated {ProjectFiles.MANIFEST_FILE_NAME} from the decorated handlers.")
308
+
309
+ built, path = build_package_to_directory(root, output, PackageBuildOptions(created_by=_CREATED_BY))
310
+ _report(built.diagnostics)
311
+
312
+ if not built.succeeded or path is None:
313
+ _fail("The package was not built.")
314
+ return
315
+
316
+ if sign is not None:
317
+ _sign_package(path, sign, sign_password_stdin)
318
+
319
+ typer.echo(f"Wrote {path} ({path.stat().st_size} bytes).")
320
+ typer.echo(f"Required runtimes: {', '.join(built.manifest.required_runtimes) if built.manifest else ''}")
321
+
322
+
323
+ @app.command()
324
+ def publish(
325
+ package: Annotated[
326
+ Path | None,
327
+ typer.Argument(help="The .nupkg to publish; the newest in --output when omitted."),
328
+ ] = None,
329
+ orchestrator: Annotated[str | None, typer.Option("--orchestrator", help="Orchestrator public base URL.")] = None,
330
+ tenant: Annotated[str | None, typer.Option("--tenant", help="Tenant id.")] = None,
331
+ api_key_stdin: Annotated[
332
+ bool,
333
+ typer.Option(
334
+ "--api-key-stdin",
335
+ help=f"Read the personal access token from stdin instead of ${API_KEY_VARIABLE}.",
336
+ ),
337
+ ] = False,
338
+ output: Annotated[Path, typer.Option("--output", "-o", help="Where packages are written.")] = Path("artifacts"),
339
+ retired_api_key: Annotated[
340
+ str | None,
341
+ typer.Option("--api-key", hidden=True, help="Retired: the token is never taken as an option."),
342
+ ] = None,
343
+ ) -> None:
344
+ """Publish a package to an Orchestrator tenant's NuGet v3 feed.
345
+
346
+ The personal access token comes from ``$VELOPHEX_API_KEY``, from stdin with
347
+ ``--api-key-stdin``, or from a hidden prompt on a terminal - never from an option, which
348
+ would be recorded in shell history and readable in the process list. The URL and tenant
349
+ come from ``--orchestrator``/``--tenant`` or ``VELOPHEX_ORCHESTRATOR_URL``/``VELOPHEX_TENANT_ID``.
350
+ """
351
+ if retired_api_key is not None:
352
+ # Refuse before anything else, and never echo or use the value.
353
+ _fail(
354
+ f"--api-key is not supported: a token on the command line ends up in shell history and the "
355
+ f"process list. Set ${API_KEY_VARIABLE}, pass --api-key-stdin, or run on a terminal to be prompted."
356
+ )
357
+ return
358
+
359
+ path = package or _only_package(output)
360
+ base_url = orchestrator or os.environ.get("VELOPHEX_ORCHESTRATOR_URL")
361
+ tenant_id = tenant or os.environ.get("VELOPHEX_TENANT_ID")
362
+
363
+ if path is None:
364
+ _fail(f"No package given and none found in '{output}'.")
365
+ return
366
+
367
+ if not base_url or not tenant_id:
368
+ _fail("An Orchestrator URL and a tenant id are required (--orchestrator/--tenant or their variables).")
369
+ return
370
+
371
+ token = _api_key(api_key_stdin)
372
+
373
+ if not token:
374
+ _fail(
375
+ f"No API key. Set ${API_KEY_VARIABLE}, pass --api-key-stdin, or run on a terminal to be prompted; "
376
+ "it is never taken as a command-line option."
377
+ )
378
+ return
379
+
380
+ try:
381
+ result = publish_package(path, public_base_url=base_url, tenant_id=UUID(tenant_id), api_key=token)
382
+ except PublishError as error:
383
+ for diagnostic in (error.problem or {}).get("errors") or []:
384
+ typer.secho(f" {diagnostic}", fg=typer.colors.RED, err=True)
385
+
386
+ _fail(str(error))
387
+ return
388
+ except ValueError as error:
389
+ _fail(f"'{tenant_id}' is not a tenant id: {error}")
390
+ return
391
+
392
+ if result.already_published:
393
+ typer.secho(f"{path.name} is already published; nothing to do.", fg=typer.colors.YELLOW)
394
+ else:
395
+ typer.echo(f"Published {path.name}.")
396
+
397
+
398
+ # --------------------------------------------------------------------------- signing
399
+
400
+
401
+ def _sign_package(package: Path, certificate: Path, password_from_stdin: bool) -> None:
402
+ """Add the detached signature to a package that has already been written."""
403
+ try:
404
+ key = load_signing_key(certificate, _signing_password(password_from_stdin))
405
+ sign_package_file(package, key)
406
+ except SigningError as error:
407
+ # str(error) is the whole message by design: SigningError never carries a password,
408
+ # a key or a chained third-party exception that might.
409
+ _fail(str(error))
410
+ return
411
+
412
+ _warn_if_outside_validity(key)
413
+
414
+ typer.echo(f"Signed with {key.subject}.")
415
+ typer.echo(f"Pin this signer as SHA-256 {key.thumbprint}.")
416
+
417
+
418
+ def _signing_password(from_stdin: bool) -> bytes | None:
419
+ """Resolve the PKCS#12 password without it ever becoming a process argument.
420
+
421
+ An empty password means an unprotected file, which PKCS#12 spells as no password at all.
422
+ """
423
+ if from_stdin:
424
+ secret = sys.stdin.readline().rstrip("\r\n")
425
+ elif SIGNING_PASSWORD_VARIABLE in os.environ:
426
+ secret = os.environ[SIGNING_PASSWORD_VARIABLE]
427
+ elif sys.stdin.isatty():
428
+ secret = typer.prompt("Signing key password", hide_input=True, default="", show_default=False)
429
+ else:
430
+ _fail(
431
+ f"No signing key password. Set ${SIGNING_PASSWORD_VARIABLE} or pass --sign-password-stdin; "
432
+ "it is never taken as a command-line option."
433
+ )
434
+ return None
435
+
436
+ return secret.encode("utf-8") or None
437
+
438
+
439
+ def _api_key(from_stdin: bool) -> str | None:
440
+ """Resolve the personal access token without it ever becoming a process argument."""
441
+ if from_stdin:
442
+ return sys.stdin.readline().strip() or None
443
+
444
+ if os.environ.get(API_KEY_VARIABLE):
445
+ return os.environ[API_KEY_VARIABLE].strip() or None
446
+
447
+ if _stdin_is_terminal():
448
+ typed = typer.prompt("Personal access token", hide_input=True, default="", show_default=False)
449
+ return str(typed).strip() or None
450
+
451
+ return None
452
+
453
+
454
+ def _stdin_is_terminal() -> bool:
455
+ return sys.stdin.isatty()
456
+
457
+
458
+ def _warn_if_outside_validity(key: SigningKey) -> None:
459
+ if not key.is_valid_at(datetime.now(UTC)):
460
+ typer.secho(
461
+ "warning: the signer certificate is expired or not yet valid, so every verifier will "
462
+ "refuse this signature.",
463
+ fg=typer.colors.YELLOW,
464
+ err=True,
465
+ )
466
+
467
+
468
+ # --------------------------------------------------------------------------- helpers
469
+
470
+
471
+ def _read_inputs(inline: str | None, from_file: Path | None, verbose: bool = False) -> Any:
472
+ if inline and from_file:
473
+ _input_error("Use --inputs or --inputs-file, not both.", None, verbose)
474
+
475
+ text: str | bytes
476
+
477
+ if from_file is not None:
478
+ try:
479
+ # Bytes, so json detects the encoding: Windows PowerShell's `>` writes UTF-16.
480
+ text = from_file.read_bytes()
481
+ except OSError as error:
482
+ _input_error(
483
+ f"Cannot read the inputs file '{from_file}': {error.strerror or error}. "
484
+ "Check the path, or pass the inputs inline with --inputs.",
485
+ error,
486
+ verbose,
487
+ )
488
+ return None
489
+
490
+ source = f"The inputs file '{from_file}'"
491
+ elif inline:
492
+ text = inline
493
+ source = "--inputs"
494
+ else:
495
+ return {}
496
+
497
+ try:
498
+ return json.loads(text)
499
+ except ValueError as error:
500
+ _input_error(
501
+ f'{source} is not valid JSON: {error}. Pass a JSON object such as {{"message": "Hi"}}; '
502
+ "if your shell strips the quotes (Windows PowerShell 5.1 does), put it in a file and use --inputs-file.",
503
+ error,
504
+ verbose,
505
+ )
506
+ return None
507
+
508
+
509
+ def _only_package(output: Path) -> Path | None:
510
+ """The one package in *output*; more than one is ambiguous and must be named."""
511
+ candidates = sorted(output.glob("*.nupkg"))
512
+
513
+ if len(candidates) > 1:
514
+ _fail(
515
+ f"'{output}' holds {len(candidates)} packages ({', '.join(item.name for item in candidates)}). "
516
+ f"Name the one to publish, e.g. velophex publish {candidates[-1]}"
517
+ )
518
+
519
+ return candidates[0] if candidates else None
520
+
521
+
522
+ def _report(diagnostics: list[Diagnostic]) -> None:
523
+ for diagnostic in diagnostics:
524
+ colour = typer.colors.RED if diagnostic.severity is Severity.ERROR else typer.colors.YELLOW
525
+ typer.secho(f"{diagnostic.code} {diagnostic.path}: {diagnostic.message}", fg=colour, err=True)
526
+
527
+
528
+ def _fail(message: str) -> None:
529
+ typer.secho(message, fg=typer.colors.RED, err=True)
530
+ raise typer.Exit(_EXIT_FAILED)
531
+
532
+
533
+ def _input_error(message: str, error: BaseException | None, verbose: bool) -> None:
534
+ """Report unusable ``run`` inputs: the problem and what to do; the traceback only with --verbose."""
535
+ if verbose and error is not None:
536
+ traceback.print_exception(error, file=sys.stderr)
537
+
538
+ typer.secho(message, fg=typer.colors.RED, err=True)
539
+ raise typer.Exit(_EXIT_INPUT)
540
+
541
+
542
+ def main() -> None:
543
+ """Console entry point for the ``velophex`` command."""
544
+ app()
545
+
546
+
547
+ if __name__ == "__main__":
548
+ sys.exit(app())
@@ -0,0 +1,152 @@
1
+ """Publishing a package to an Orchestrator tenant's NuGet v3 feed.
2
+
3
+ Every tenant's packages are a NuGet v3 feed (``VeloPhex.Orchestrator/docs/package-feed.md``),
4
+ so publishing is a ``PackagePublish/2.0.0`` push: a multipart ``PUT`` of the ``.nupkg`` with a
5
+ personal access token. The server runs exactly the same inspection as an upload through the
6
+ packages screen, which is why a ``400`` here carries the contract diagnostics and is worth
7
+ showing verbatim.
8
+
9
+ The token is a ``vpx_pat_…`` created with ``POST /api/v1/me/access-tokens`` or through the
10
+ device flow; it is passed in ``X-NuGet-ApiKey`` so the same call works from ``dotnet nuget
11
+ push -k``. It is never written to ``project.json`` or to the package.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import zipfile
18
+ from dataclasses import dataclass
19
+ from hashlib import sha256
20
+ from pathlib import Path
21
+ from typing import Any, Final
22
+ from uuid import UUID
23
+
24
+ import httpx
25
+
26
+ from velophex import _json
27
+ from velophex.contracts.package import PackageLayout
28
+ from velophex.errors import VelophexError
29
+
30
+ __all__ = ["PUBLISH_PATH_TEMPLATE", "PublishError", "PublishResult", "publish_package"]
31
+
32
+ PUBLISH_PATH_TEMPLATE: Final = "/api/v1/tenants/{tenant_id}/feed/v3/package"
33
+
34
+ #: The 409 ``code`` for bytes the feed already holds; the problem carries the server's ``sha256``.
35
+ DUPLICATE_BINARY_CODE: Final = "package_binary_duplicate"
36
+ #: The 409 ``code`` for a version the feed already holds with different bytes.
37
+ VERSION_EXISTS_CODE: Final = "package_version_exists"
38
+
39
+ _API_KEY_HEADER: Final = "X-NuGet-ApiKey"
40
+ _DEFAULT_TIMEOUT: Final = httpx.Timeout(300.0, connect=15.0)
41
+
42
+
43
+ class PublishError(VelophexError):
44
+ """The feed refused the package."""
45
+
46
+ def __init__(self, status_code: int, problem: dict[str, Any] | None, message: str) -> None:
47
+ super().__init__(message)
48
+ self.status_code = status_code
49
+ self.problem = problem or {}
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class PublishResult:
54
+ """What the feed reported for a successful push."""
55
+
56
+ status_code: int
57
+ already_published: bool
58
+ body: Any | None
59
+
60
+
61
+ def publish_package(
62
+ package_path: str | Path,
63
+ *,
64
+ public_base_url: str,
65
+ tenant_id: UUID | str,
66
+ api_key: str,
67
+ transport: httpx.BaseTransport | None = None,
68
+ timeout: httpx.Timeout | float = _DEFAULT_TIMEOUT,
69
+ ) -> PublishResult:
70
+ """Push *package_path* to the tenant's feed.
71
+
72
+ Args:
73
+ package_path: The ``.nupkg`` to publish.
74
+ public_base_url: The Orchestrator's public base URL, e.g.
75
+ ``https://orchestrator.example.com``.
76
+ tenant_id: The tenant whose feed receives the package.
77
+ api_key: A personal access token (``vpx_pat_…``) with ``packages.create``.
78
+ transport: An httpx transport, for tests.
79
+ timeout: Upload timeout; packages can be large and feeds can be far away.
80
+
81
+ Raises:
82
+ PublishError: The feed answered 4xx or 5xx, or could not be reached.
83
+ """
84
+ path = Path(package_path)
85
+
86
+ if not path.is_file():
87
+ raise PublishError(0, None, f"'{package_path}' does not exist.")
88
+
89
+ url = public_base_url.rstrip("/") + PUBLISH_PATH_TEMPLATE.format(tenant_id=tenant_id)
90
+ content = path.read_bytes()
91
+ files = {"package": (path.name, content, "application/octet-stream")}
92
+
93
+ with httpx.Client(timeout=timeout, transport=transport) as client:
94
+ try:
95
+ response = client.put(url, files=files, headers={_API_KEY_HEADER: api_key, "Accept": "application/json"})
96
+ except httpx.HTTPError as error:
97
+ raise PublishError(0, None, f"The feed at {url} could not be reached: {error}") from error
98
+
99
+ body = _body(response)
100
+
101
+ # A 409 is success only when the server proves it already holds these exact bytes: its own
102
+ # SHA-256 of the pushed package must equal ours. Different bytes under a published version
103
+ # would otherwise "succeed" while robots keep running the old code.
104
+ if response.status_code == httpx.codes.CONFLICT:
105
+ problem = body if isinstance(body, dict) else {}
106
+
107
+ if problem.get("code") == DUPLICATE_BINARY_CODE and problem.get("sha256") == sha256(content).hexdigest():
108
+ return PublishResult(status_code=response.status_code, already_published=True, body=body)
109
+
110
+ package = _identity(content) or path.name
111
+ why = (
112
+ "is already published with different content"
113
+ if problem.get("code") == VERSION_EXISTS_CODE
114
+ else f"conflicts with a published package ({problem.get('detail') or 'no detail'}), and the feed did "
115
+ "not confirm the bytes are identical"
116
+ )
117
+ raise PublishError(
118
+ response.status_code,
119
+ problem or None,
120
+ f"{package} {why}. A published version never changes: increment the version in project.json, "
121
+ "pack again and publish the new package.",
122
+ )
123
+
124
+ if not response.is_success:
125
+ detail = (body or {}).get("detail") if isinstance(body, dict) else None
126
+ raise PublishError(
127
+ response.status_code,
128
+ body if isinstance(body, dict) else None,
129
+ f"The feed refused {path.name} with {response.status_code}: {detail or response.reason_phrase}",
130
+ )
131
+
132
+ return PublishResult(status_code=response.status_code, already_published=False, body=body)
133
+
134
+
135
+ def _identity(content: bytes) -> str | None:
136
+ """``<id> <version>`` from the package's manifest, or ``None`` when it cannot be read."""
137
+ try:
138
+ with zipfile.ZipFile(io.BytesIO(content)) as archive:
139
+ manifest = _json.loads(archive.read(PackageLayout.MANIFEST_PATH))
140
+ return f"{manifest['packageId']} {manifest['packageVersion']}"
141
+ except (KeyError, ValueError, TypeError, zipfile.BadZipFile):
142
+ return None
143
+
144
+
145
+ def _body(response: httpx.Response) -> Any | None:
146
+ if not response.content:
147
+ return None
148
+
149
+ try:
150
+ return _json.loads(response.content)
151
+ except ValueError:
152
+ return None