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.
- velophex/__init__.py +38 -0
- velophex/_json.py +297 -0
- velophex/agents/__init__.py +130 -0
- velophex/agents/_errors.py +75 -0
- velophex/agents/_loop.py +436 -0
- velophex/agents/_tools.py +168 -0
- velophex/agents/_tracing.py +87 -0
- velophex/agents/testing.py +249 -0
- velophex/cli/__init__.py +5 -0
- velophex/cli/main.py +548 -0
- velophex/cli/publish.py +152 -0
- velophex/cli/sync.py +90 -0
- velophex/cli/templates.py +126 -0
- velophex/contracts/__init__.py +103 -0
- velophex/contracts/_model.py +26 -0
- velophex/contracts/agents.py +110 -0
- velophex/contracts/diagnostics.py +157 -0
- velophex/contracts/executor.py +1921 -0
- velophex/contracts/package.py +391 -0
- velophex/contracts/project.py +1008 -0
- velophex/contracts/runtimes.py +226 -0
- velophex/contracts/signin.py +53 -0
- velophex/contracts/validation.py +158 -0
- velophex/entrypoints.py +329 -0
- velophex/errors.py +44 -0
- velophex/packaging.py +465 -0
- velophex/py.typed +0 -0
- velophex/runtime/__init__.py +471 -0
- velophex/runtime/_session.py +192 -0
- velophex/runtime/client.py +957 -0
- velophex/runtime/models.py +326 -0
- velophex/signing.py +425 -0
- velophex/worker/__init__.py +27 -0
- velophex/worker/__main__.py +118 -0
- velophex/worker/framing.py +248 -0
- velophex/worker/loader.py +105 -0
- velophex/worker/outbox.py +147 -0
- velophex/worker/session.py +519 -0
- velophex-0.2.0.dist-info/METADATA +313 -0
- velophex-0.2.0.dist-info/RECORD +43 -0
- velophex-0.2.0.dist-info/WHEEL +4 -0
- velophex-0.2.0.dist-info/entry_points.txt +2 -0
- 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())
|
velophex/cli/publish.py
ADDED
|
@@ -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
|