inline-core 1.2.0__tar.gz → 1.2.1__tar.gz
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.
- {inline_core-1.2.0 → inline_core-1.2.1}/CLAUDE.md +14 -3
- {inline_core-1.2.0 → inline_core-1.2.1}/PKG-INFO +1 -1
- {inline_core-1.2.0 → inline_core-1.2.1}/pyproject.toml +1 -1
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/config.py +17 -0
- inline_core-1.2.1/src/inline_core/extensions/__init__.py +10 -0
- inline_core-1.2.1/src/inline_core/extensions/api.py +214 -0
- inline_core-1.2.1/src/inline_core/extensions/constraints.py +179 -0
- inline_core-1.2.1/src/inline_core/extensions/fetch.py +183 -0
- inline_core-1.2.1/src/inline_core/extensions/handlers.py +144 -0
- inline_core-1.2.1/src/inline_core/extensions/importer.py +91 -0
- inline_core-1.2.1/src/inline_core/extensions/install.py +669 -0
- inline_core-1.2.1/src/inline_core/extensions/loader.py +285 -0
- inline_core-1.2.1/src/inline_core/extensions/manifest.py +361 -0
- inline_core-1.2.1/src/inline_core/extensions/models.py +58 -0
- inline_core-1.2.1/src/inline_core/extensions/paths.py +235 -0
- inline_core-1.2.1/src/inline_core/extensions/resolve.py +298 -0
- inline_core-1.2.1/src/inline_core/extensions/scanner.py +448 -0
- inline_core-1.2.1/src/inline_core/extensions/state.py +168 -0
- inline_core-1.2.1/src/inline_core/extensions/tools.py +138 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/graph/registry.py +22 -3
- inline_core-1.2.1/src/inline_core/models/requirements.py +87 -0
- inline_core-1.2.1/src/inline_core/models/zimage/provider.py +61 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/models/zimage/requirements.py +21 -19
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/runtime/context.py +7 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/server/__main__.py +23 -4
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/server/app.py +34 -8
- inline_core-1.2.1/src/inline_core/server/bootstrap.py +89 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/server/manager.py +4 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/server/rpc.py +10 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/models.py +38 -42
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_cache.py +1 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_config.py +1 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_executor.py +1 -0
- inline_core-1.2.1/tests/test_extension_api.py +210 -0
- inline_core-1.2.1/tests/test_extension_install.py +515 -0
- inline_core-1.2.1/tests/test_extension_manifest.py +184 -0
- inline_core-1.2.1/tests/test_extension_resolve.py +298 -0
- inline_core-1.2.1/tests/test_extension_scanner.py +291 -0
- inline_core-1.2.1/tests/test_extension_spine.py +98 -0
- inline_core-1.2.1/tests/test_extension_state.py +114 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_file_store.py +1 -0
- inline_core-1.2.1/tests/test_model_requirements.py +147 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_primitives.py +1 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_run_store.py +1 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_schema.py +1 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_server.py +1 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_take_bytes.py +1 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_topo.py +1 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_validate.py +1 -0
- inline_core-1.2.0/src/inline_core/server/bootstrap.py +0 -28
- {inline_core-1.2.0 → inline_core-1.2.1}/.gitignore +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/.python-version +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/README.md +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/main.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/scripts/reference.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/__init__.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/components/__init__.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/components/conditioning.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/components/interfaces.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/device/__init__.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/device/auto.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/device/detect.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/device/memory.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/device/policy.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/device/types.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/errors.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/graph/__init__.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/graph/cache.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/graph/descriptor.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/graph/executor.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/graph/loader_runners.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/graph/primitives.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/graph/runners.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/graph/schema.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/graph/topo.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/graph/validate.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/importer/__init__.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/importer/comfy.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/media.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/models/__init__.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/models/catalog.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/models/loaders.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/models/sampling.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/models/zimage/__init__.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/models/zimage/primitives.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/models/zimage/runner.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/parallel/__init__.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/parallel/config.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/parallel/group.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/parallel/launch.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/parallel/protocol.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/parallel/registry.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/parallel/worker.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/runtime/__init__.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/runtime/file_store.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/runtime/progress.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/runtime/run.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/runtime/store.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/sampling/__init__.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/sampling/batch.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/server/__init__.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/server/assets.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/server/frontend.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/server/run_store.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/server/serialize.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/__init__.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/assets.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/config.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/fal.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/frames.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/generation.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/graph_build.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/handlers.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/moodboard.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/peaks.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/schema.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/store.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/timeline/__init__.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/timeline/compose.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/timeline/ffmpeg.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/timeline/render.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/studio/timeline/resolve.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/src/inline_core/takes.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/helpers.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_catalog.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_comfy_import.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_device_detect.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_frontend_serving.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_hidden_nodes.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_loader_runners.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_loaders.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_memory_policy.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_parallel_group.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_rpc_bridge.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_sampling.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_studio_assets.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_studio_fal.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_studio_frames.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_studio_generation.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_studio_models.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_studio_moodboard.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_studio_peaks.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_studio_rpc.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_studio_schema.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_studio_store.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_studio_timeline.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_xfuser_sampler.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_zimage_primitives.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_zimage_resolve.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/tests/test_zimage_runner.py +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/uv.lock +0 -0
- {inline_core-1.2.0 → inline_core-1.2.1}/webui.sh +0 -0
|
@@ -6,9 +6,14 @@ models across macOS, Windows, and Linux - from CPU-only boxes and low-VRAM lapto
|
|
|
6
6
|
machines that split a single image's sampling across GPUs (via xDiT). **It is the render backend that
|
|
7
7
|
replaces ComfyUI for Inline.**
|
|
8
8
|
|
|
9
|
-
> The UI client lives in the separate **Inline Studio
|
|
10
|
-
>
|
|
11
|
-
>
|
|
9
|
+
> The UI client lives in the separate **Inline Studio** repo
|
|
10
|
+
> ([`inlineresearch/Inline-Studio`](https://github.com/inlineresearch/Inline-Studio)), which vendors
|
|
11
|
+
> this engine under `core/` via `git subtree`. It drives the engine over the `/v1` HTTP + websocket
|
|
12
|
+
> API; Inline Core is headless and knows nothing about the UI.
|
|
13
|
+
|
|
14
|
+
> **GitHub org: `inlineresearch`** - never `inline-studio/` or any other org in a URL, manifest, or
|
|
15
|
+
> doc. Sibling repos: `Inline-Studio` (UI + this engine), `Inline-Registry` (the published extension
|
|
16
|
+
> index served to the Available tab), `Inline-Studio-Extension-Guide` (the reference extension).
|
|
12
17
|
|
|
13
18
|
> Read this file before changing code. It defines the architecture and the non-negotiable rules.
|
|
14
19
|
> `README.md` is the user/product-facing version of the same story; this is the engineering contract.
|
|
@@ -170,6 +175,12 @@ real codec that moves tensors lives with the model runner.
|
|
|
170
175
|
|
|
171
176
|
- **Typed, strict.** `pyright` in strict mode (`[tool.pyright]`, `typeCheckingMode = "strict"`), all of
|
|
172
177
|
`src` + `tests`. No silent `Any` leaks across component/graph boundaries.
|
|
178
|
+
- **Comments are short.** One or two lines, and only for the **why** a reader can't infer from the
|
|
179
|
+
code - a non-obvious constraint, a rejected alternative, an ordering that matters. Module
|
|
180
|
+
docstrings: 1-3 sentences. Function docstrings: one line, or none when the signature says it.
|
|
181
|
+
Never narrate what the code does, never write an essay in a docstring, never leave a comment that
|
|
182
|
+
restates the line below it. If the reasoning genuinely needs paragraphs, it belongs in a doc, not
|
|
183
|
+
in the source.
|
|
173
184
|
- **Lint.** `ruff` with `select = ["E", "F", "I", "UP", "B"]`, line length 100, target `py311`.
|
|
174
185
|
- **Typed graph, validated before run.** Never execute an unvalidated graph. Edge type-checking
|
|
175
186
|
(`graph/validate.py` + `port_satisfies`) rejects bad wiring at submit. New port kinds go in
|
|
@@ -19,6 +19,23 @@ def data_dir() -> Path:
|
|
|
19
19
|
return Path(env).expanduser() if env else Path(".inline")
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
def extensions_dir() -> Path:
|
|
23
|
+
"""Community extensions root. `INLINE_EXTENSIONS_DIR`, else `./extensions` (so a dev checkout
|
|
24
|
+
keeps it beside `./models` and `./.inline`). Holds `state.json`, the host constraint snapshot,
|
|
25
|
+
and one directory per installed extension."""
|
|
26
|
+
env = os.environ.get("INLINE_EXTENSIONS_DIR")
|
|
27
|
+
return Path(env).expanduser() if env else Path("extensions")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def registry_url() -> str:
|
|
31
|
+
"""Where the Available tab fetches its extension index. `INLINE_EXTENSION_REGISTRY`, else the
|
|
32
|
+
public registry. Point it at a fork or a file:// path to test a registry change."""
|
|
33
|
+
return os.environ.get(
|
|
34
|
+
"INLINE_EXTENSION_REGISTRY",
|
|
35
|
+
"https://raw.githubusercontent.com/inlineresearch/Inline-Registry/main/index.json",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
22
39
|
def server_host() -> str:
|
|
23
40
|
"""Address the /v1 server binds. `INLINE_HOST`, else loopback (`127.0.0.1`)."""
|
|
24
41
|
return os.environ.get("INLINE_HOST", "127.0.0.1")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Community extensions: installable node extensions from a git repo.
|
|
2
|
+
|
|
3
|
+
A **extension** is one repo, installed at one pinned commit, holding N independently toggleable
|
|
4
|
+
**modules**, each owning node types, optional model requirements, and optional UI.
|
|
5
|
+
|
|
6
|
+
Authors import only ``inline_core.extensions.api``.
|
|
7
|
+
|
|
8
|
+
Dependencies get private resolution with install-time conflict detection - not runtime isolation.
|
|
9
|
+
See ``importer`` for why the distinction matters.
|
|
10
|
+
"""
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""The extension author's public surface. Everything an extension imports lives here.
|
|
2
|
+
|
|
3
|
+
An extension ships one ``register(reg: ExtensionRegistrar)`` entry point that registers
|
|
4
|
+
``@inline_node``-decorated ``NodeRunner`` classes.
|
|
5
|
+
|
|
6
|
+
The decorator has no import-time side effect - it only attaches a descriptor. That is what lets the
|
|
7
|
+
same code load into a scratch registry during install validation and the live one on activation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable, Sequence
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..device.policy import DevicePolicy
|
|
18
|
+
from ..graph.descriptor import NodeDescriptor, ParamField, Port
|
|
19
|
+
from ..graph.registry import Registry
|
|
20
|
+
from ..graph.runners import NodeRunner
|
|
21
|
+
from ..graph.schema import PortKind
|
|
22
|
+
from ..media import MediaKind
|
|
23
|
+
from ..models.requirements import RequirementsProvider, RequirementsRegistry
|
|
24
|
+
from ..runtime.store import TakeStore
|
|
25
|
+
|
|
26
|
+
#: Attribute the decorator attaches to a runner class.
|
|
27
|
+
DESCRIPTOR_ATTR = "__inline_descriptor__"
|
|
28
|
+
|
|
29
|
+
#: ``inline_node`` takes a keyword argument named ``type`` to mirror ``NodeDescriptor.type``, which
|
|
30
|
+
#: shadows the builtin inside its body. Captured here so the runtime class check still works.
|
|
31
|
+
_type = type
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ExtensionError(RuntimeError):
|
|
35
|
+
"""An extension broke the contract. Fails that extension, never the server."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def inline_node(
|
|
39
|
+
*,
|
|
40
|
+
type: str, # noqa: A002 - mirrors NodeDescriptor.type
|
|
41
|
+
title: str,
|
|
42
|
+
category: str,
|
|
43
|
+
inputs: Sequence[Port] = (),
|
|
44
|
+
outputs: Sequence[Port] = (),
|
|
45
|
+
params: Sequence[ParamField] = (),
|
|
46
|
+
output_kind: MediaKind | None = None,
|
|
47
|
+
icon: str = "",
|
|
48
|
+
hidden: bool = False,
|
|
49
|
+
) -> Callable[[type[NodeRunner]], type[NodeRunner]]:
|
|
50
|
+
"""Attach a ``NodeDescriptor`` to a ``NodeRunner`` subclass. ``source`` is stamped by the
|
|
51
|
+
registrar, not settable here, so provenance can't be spoofed."""
|
|
52
|
+
|
|
53
|
+
def decorate(cls: type[NodeRunner]) -> type[NodeRunner]:
|
|
54
|
+
# Checked at runtime because the annotation is a promise an author can break.
|
|
55
|
+
if not (isinstance(cls, _type) and issubclass(cls, NodeRunner)): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
56
|
+
raise ExtensionError(f"@inline_node requires a NodeRunner subclass, got {cls!r}")
|
|
57
|
+
setattr(
|
|
58
|
+
cls,
|
|
59
|
+
DESCRIPTOR_ATTR,
|
|
60
|
+
NodeDescriptor(
|
|
61
|
+
type=type,
|
|
62
|
+
title=title,
|
|
63
|
+
category=category,
|
|
64
|
+
inputs=tuple(inputs),
|
|
65
|
+
outputs=tuple(outputs),
|
|
66
|
+
params=tuple(params),
|
|
67
|
+
output_kind=output_kind,
|
|
68
|
+
icon=icon,
|
|
69
|
+
hidden=hidden,
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
return cls
|
|
73
|
+
|
|
74
|
+
return decorate
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def descriptor_of(cls: type[NodeRunner]) -> NodeDescriptor | None:
|
|
78
|
+
descriptor = getattr(cls, DESCRIPTOR_ATTR, None)
|
|
79
|
+
return descriptor if isinstance(descriptor, NodeDescriptor) else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ExtensionRegistrar:
|
|
83
|
+
"""Handed to an extension's ``register()``. The entire v1 capability surface.
|
|
84
|
+
|
|
85
|
+
Namespacing is enforced here, not by convention: channels carry the ``ext:<id>:`` prefix, node
|
|
86
|
+
types must be manifest-declared, and ``source`` is stamped."""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
registry: Registry,
|
|
91
|
+
extension_id: str,
|
|
92
|
+
*,
|
|
93
|
+
store: TakeStore,
|
|
94
|
+
policy: DevicePolicy,
|
|
95
|
+
requirements: RequirementsRegistry,
|
|
96
|
+
data_root: Path,
|
|
97
|
+
declared_nodes: Sequence[str] = (),
|
|
98
|
+
enabled_nodes: Sequence[str] | None = None,
|
|
99
|
+
rpc: Any = None,
|
|
100
|
+
events: Any = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
self._registry = registry
|
|
103
|
+
self._extension_id = extension_id
|
|
104
|
+
self._store = store
|
|
105
|
+
self._policy = policy
|
|
106
|
+
self._requirements = requirements
|
|
107
|
+
self._data_root = data_root
|
|
108
|
+
self._declared = frozenset(declared_nodes)
|
|
109
|
+
#: None means "register everything declared" (install-time validation).
|
|
110
|
+
self._enabled = frozenset(enabled_nodes) if enabled_nodes is not None else None
|
|
111
|
+
self._rpc = rpc
|
|
112
|
+
self._events = events
|
|
113
|
+
#: What actually landed in the registry, so disabling can be undone precisely.
|
|
114
|
+
self.registered_nodes: list[str] = []
|
|
115
|
+
self.registered_channels: list[str] = []
|
|
116
|
+
#: Declared and offered by the author but switched off by the user.
|
|
117
|
+
self.skipped_nodes: list[str] = []
|
|
118
|
+
|
|
119
|
+
# --- nodes -----------------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def node(self, cls: type[NodeRunner], runner: NodeRunner | None = None) -> None:
|
|
122
|
+
"""Register one ``@inline_node``-decorated runner class.
|
|
123
|
+
|
|
124
|
+
A node the user has switched off is validated and then skipped, so toggling it back on is
|
|
125
|
+
just another ``register()`` call - never a restart.
|
|
126
|
+
"""
|
|
127
|
+
descriptor = descriptor_of(cls)
|
|
128
|
+
if descriptor is None:
|
|
129
|
+
raise ExtensionError(f"{cls.__name__} is missing the @inline_node decorator")
|
|
130
|
+
if self._declared and descriptor.type not in self._declared:
|
|
131
|
+
raise ExtensionError(
|
|
132
|
+
f"node type {descriptor.type!r} is not declared in the manifest's nodes[]"
|
|
133
|
+
)
|
|
134
|
+
_check_ports(descriptor)
|
|
135
|
+
if self._enabled is not None and descriptor.type not in self._enabled:
|
|
136
|
+
self.skipped_nodes.append(descriptor.type)
|
|
137
|
+
return
|
|
138
|
+
existing = self._registry.has(descriptor.type)
|
|
139
|
+
if existing and not self._registry.get(descriptor.type).source.startswith("ext:"):
|
|
140
|
+
raise ExtensionError(
|
|
141
|
+
f"node type {descriptor.type!r} is already provided by Core and cannot be replaced"
|
|
142
|
+
)
|
|
143
|
+
stamped = replace(descriptor, source=f"ext:{self._extension_id}")
|
|
144
|
+
self._registry.register(stamped, runner if runner is not None else cls())
|
|
145
|
+
self.registered_nodes.append(descriptor.type)
|
|
146
|
+
|
|
147
|
+
def nodes(self, *classes: type[NodeRunner]) -> None:
|
|
148
|
+
for cls in classes:
|
|
149
|
+
self.node(cls)
|
|
150
|
+
|
|
151
|
+
# --- model requirements -----------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def models(self, node_type: str, provider: RequirementsProvider) -> None:
|
|
154
|
+
"""Declare what a node needs on disk, so it flows through the existing download popup."""
|
|
155
|
+
self._requirements.register(node_type, provider)
|
|
156
|
+
|
|
157
|
+
# --- backend channels ------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def rpc_channel(self, method: str, fn: Callable[..., Any]) -> None:
|
|
160
|
+
"""Register ``ext:<id>:<method>``. The prefix is forced, so an author cannot shadow a
|
|
161
|
+
core channel like ``project:open``."""
|
|
162
|
+
if self._rpc is None:
|
|
163
|
+
return
|
|
164
|
+
import inspect
|
|
165
|
+
|
|
166
|
+
channel = self.channel(method)
|
|
167
|
+
|
|
168
|
+
async def handler(args: list[Any]) -> Any:
|
|
169
|
+
result = fn(*args)
|
|
170
|
+
if inspect.isawaitable(result):
|
|
171
|
+
result = await result
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
self._rpc.register(channel, handler)
|
|
175
|
+
self.registered_channels.append(channel)
|
|
176
|
+
|
|
177
|
+
def emit(self, event: str, payload: Any) -> None:
|
|
178
|
+
"""Broadcast ``ext:<extension>:<event>`` to connected clients."""
|
|
179
|
+
if self._events is not None:
|
|
180
|
+
self._events.broadcast(self.channel(event), payload)
|
|
181
|
+
|
|
182
|
+
def channel(self, name: str) -> str:
|
|
183
|
+
if not name or ":" in name:
|
|
184
|
+
raise ExtensionError(f"channel name {name!r} must be a bare method name")
|
|
185
|
+
return f"ext:{self._extension_id}:{name}"
|
|
186
|
+
|
|
187
|
+
# --- engine handles --------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def takes(self) -> TakeStore:
|
|
191
|
+
return self._store
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def device(self) -> DevicePolicy:
|
|
195
|
+
"""Placement policy. Never pick a device yourself - ask ``device.placement(role)``."""
|
|
196
|
+
return self._policy
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def data_dir(self) -> Path:
|
|
200
|
+
"""This extension's private scratch dir. Writing elsewhere is a CRITICAL scanner finding."""
|
|
201
|
+
path = self._data_root / self._extension_id
|
|
202
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
203
|
+
return path
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _check_ports(descriptor: NodeDescriptor) -> None:
|
|
207
|
+
"""Reject custom port kinds: ``port_satisfies`` must stay total, or graph validation could no
|
|
208
|
+
longer decide edge legality without running extension code."""
|
|
209
|
+
for port in (*descriptor.inputs, *descriptor.outputs):
|
|
210
|
+
if not isinstance(port.kind, PortKind): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
211
|
+
raise ExtensionError(
|
|
212
|
+
f"port {port.id!r} on {descriptor.type!r} uses an unsupported kind "
|
|
213
|
+
f"{port.kind!r}; extensions must use the built-in PortKind values"
|
|
214
|
+
)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Pins the host's packages so an extension can never replace them.
|
|
2
|
+
|
|
3
|
+
An extension may depend on torch/diffusers/transformers but never install its own - two copies in
|
|
4
|
+
one process is a corrupted CUDA context, not a version skew.
|
|
5
|
+
|
|
6
|
+
The constraint file pins every installed distribution. A requirement needing a different version
|
|
7
|
+
fails to resolve, and that failure *is* the host-override signal.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import importlib.metadata
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .paths import write_atomic
|
|
19
|
+
|
|
20
|
+
#: Packages an extension may never provide, even at the host's exact version. Anything here is
|
|
21
|
+
#: pruned from an extension's private site/ after install, so the finder cannot route it.
|
|
22
|
+
HOST_PROTECTED: frozenset[str] = frozenset(
|
|
23
|
+
{
|
|
24
|
+
"torch",
|
|
25
|
+
"torchvision",
|
|
26
|
+
"torchaudio",
|
|
27
|
+
"torchao",
|
|
28
|
+
"diffusers",
|
|
29
|
+
"transformers",
|
|
30
|
+
"accelerate",
|
|
31
|
+
"safetensors",
|
|
32
|
+
"tokenizers",
|
|
33
|
+
"huggingface-hub",
|
|
34
|
+
"numpy",
|
|
35
|
+
"scipy",
|
|
36
|
+
"fastapi",
|
|
37
|
+
"uvicorn",
|
|
38
|
+
"starlette",
|
|
39
|
+
"pydantic",
|
|
40
|
+
"pydantic-core",
|
|
41
|
+
"psutil",
|
|
42
|
+
"xfuser",
|
|
43
|
+
"nvidia-ml-py",
|
|
44
|
+
"inline-core",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def canonical(name: str) -> str:
|
|
52
|
+
"""PEP 503 normalization, so ``Huggingface_Hub`` and ``huggingface-hub`` compare equal."""
|
|
53
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Conflict:
|
|
58
|
+
"""One dependency an extension wants that the host already owns at another version."""
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
host_version: str
|
|
62
|
+
wanted: str
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def protected(self) -> bool:
|
|
66
|
+
return canonical(self.name) in HOST_PROTECTED
|
|
67
|
+
|
|
68
|
+
def message(self) -> str:
|
|
69
|
+
if self.protected:
|
|
70
|
+
return (
|
|
71
|
+
f"{self.name} is part of the shared Inline runtime and cannot be replaced "
|
|
72
|
+
f"(installed: {self.host_version}, extension wants: {self.wanted})"
|
|
73
|
+
)
|
|
74
|
+
return (
|
|
75
|
+
f"{self.name} {self.wanted} conflicts with the installed {self.name} "
|
|
76
|
+
f"{self.host_version}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def to_json(self) -> dict[str, object]:
|
|
80
|
+
return {
|
|
81
|
+
"name": self.name,
|
|
82
|
+
"hostVersion": self.host_version,
|
|
83
|
+
"wanted": self.wanted,
|
|
84
|
+
"protected": self.protected,
|
|
85
|
+
"message": self.message(),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def host_distributions() -> dict[str, str]:
|
|
90
|
+
"""Canonical name -> version for every distribution in the running interpreter."""
|
|
91
|
+
found: dict[str, str] = {}
|
|
92
|
+
for dist in importlib.metadata.distributions():
|
|
93
|
+
name = dist.metadata["Name"] if dist.metadata else None
|
|
94
|
+
if not name:
|
|
95
|
+
continue # a malformed dist-info in site-packages must not break installs
|
|
96
|
+
version = dist.version
|
|
97
|
+
if version:
|
|
98
|
+
found[canonical(name)] = version
|
|
99
|
+
return found
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def fingerprint(host: dict[str, str] | None = None) -> str:
|
|
103
|
+
"""Digest of the host's package set, so the constraint file is regenerated after an upgrade."""
|
|
104
|
+
dists = host if host is not None else host_distributions()
|
|
105
|
+
payload = ";".join(f"{name}=={version}" for name, version in sorted(dists.items()))
|
|
106
|
+
return hashlib.sha256(payload.encode()).hexdigest()[:16]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def render(host: dict[str, str] | None = None) -> str:
|
|
110
|
+
dists = host if host is not None else host_distributions()
|
|
111
|
+
lines = [
|
|
112
|
+
"# Generated by Inline Core - do not edit.",
|
|
113
|
+
"# Every package installed in the host interpreter, pinned. An extension that needs a",
|
|
114
|
+
"# different version of any of these fails to resolve rather than silently replacing it.",
|
|
115
|
+
f"# host-fingerprint: {fingerprint(dists)}",
|
|
116
|
+
]
|
|
117
|
+
lines.extend(f"{name}=={version}" for name, version in sorted(dists.items()))
|
|
118
|
+
return "\n".join(lines) + "\n"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def write_constraints(path: Path, host: dict[str, str] | None = None) -> Path:
|
|
122
|
+
"""Write the constraint file, refreshing it only when the host's packages changed."""
|
|
123
|
+
dists = host if host is not None else host_distributions()
|
|
124
|
+
wanted = fingerprint(dists)
|
|
125
|
+
try:
|
|
126
|
+
if f"# host-fingerprint: {wanted}" in path.read_text(encoding="utf-8"):
|
|
127
|
+
return path
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
|
130
|
+
write_atomic(path, render(dists))
|
|
131
|
+
return path
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def requirement_name(requirement: str) -> str:
|
|
135
|
+
"""The distribution name from a PEP 508 requirement string, canonicalized."""
|
|
136
|
+
head = requirement.split(";", 1)[0].split("@", 1)[0].strip()
|
|
137
|
+
match = _NAME_RE.match(head)
|
|
138
|
+
return canonical(match.group(1)) if match else ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def protected_requirements(requirements: tuple[str, ...]) -> list[str]:
|
|
142
|
+
"""Requirements naming a host-protected package. Runs in the security scan, before any
|
|
143
|
+
resolution, so listing ``torch`` is blocked outright rather than diagnosed later."""
|
|
144
|
+
return [req for req in requirements if requirement_name(req) in HOST_PROTECTED]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def parse_lock(lock_text: str) -> dict[str, str]:
|
|
148
|
+
"""Canonical name -> version from a fully pinned ``uv pip compile`` lockfile."""
|
|
149
|
+
pinned: dict[str, str] = {}
|
|
150
|
+
for raw in lock_text.splitlines():
|
|
151
|
+
line = raw.split("#", 1)[0].strip()
|
|
152
|
+
if not line or line.startswith("-"):
|
|
153
|
+
continue
|
|
154
|
+
name, sep, version = line.partition("==")
|
|
155
|
+
if not sep:
|
|
156
|
+
continue
|
|
157
|
+
cleaned = version.split(";", 1)[0].split("--", 1)[0].strip()
|
|
158
|
+
if cleaned:
|
|
159
|
+
pinned[canonical(name.strip())] = cleaned
|
|
160
|
+
return pinned
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def conflicts(lock_text: str, host: dict[str, str] | None = None) -> list[Conflict]:
|
|
164
|
+
"""Pinned packages in an unconstrained lock that disagree with the host. Run only after a
|
|
165
|
+
constrained resolve failed, to name the culprit instead of dumping a resolver trace."""
|
|
166
|
+
installed = host if host is not None else host_distributions()
|
|
167
|
+
found: list[Conflict] = []
|
|
168
|
+
for name, version in sorted(parse_lock(lock_text).items()):
|
|
169
|
+
current = installed.get(name)
|
|
170
|
+
if current is not None and current != version:
|
|
171
|
+
found.append(Conflict(name=name, host_version=current, wanted=f"=={version}"))
|
|
172
|
+
return found
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def prunable(site_dists: dict[str, str], host: dict[str, str] | None = None) -> list[str]:
|
|
176
|
+
"""Private-site distributions that duplicate a host package. Constraints already pinned them to
|
|
177
|
+
the host version, so removing them guarantees the finder can't route e.g. numpy privately."""
|
|
178
|
+
installed = host if host is not None else host_distributions()
|
|
179
|
+
return sorted(name for name in site_dists if name in installed or name in HOST_PROTECTED)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Fetching an extension repo at a pinned commit.
|
|
2
|
+
|
|
3
|
+
A bare mirror under ``.cache/git`` is kept so re-fetching a tag is cheap. The working copy is
|
|
4
|
+
extracted with ``git archive``, which produces no ``.git`` directory and no hooks - nothing in a
|
|
5
|
+
fetched repo can execute before the scanner has seen it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import tarfile
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .tools import FETCH_TIMEOUT, GIT
|
|
18
|
+
|
|
19
|
+
#: https/ssh for real repos, file:// for the extension-author dev loop (install your own checkout).
|
|
20
|
+
#: A bare path is still rejected, so nothing can be read as a git option.
|
|
21
|
+
_URL_RE = re.compile(r"^(https://|git@|file:///)[A-Za-z0-9]")
|
|
22
|
+
_REF_RE = re.compile(r"^[A-Za-z0-9._/-]{1,128}$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FetchError(RuntimeError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Fetched:
|
|
31
|
+
source: Path
|
|
32
|
+
sha: str
|
|
33
|
+
ref: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fetch(url: str, ref: str, *, mirror: Path, dest: Path) -> Fetched:
|
|
37
|
+
"""Clone/refresh ``url`` into ``mirror`` and extract ``ref`` into ``dest``."""
|
|
38
|
+
GIT.require()
|
|
39
|
+
_validate(url, ref)
|
|
40
|
+
_sync_mirror(url, mirror)
|
|
41
|
+
sha = _resolve(mirror, ref)
|
|
42
|
+
_extract(mirror, sha, dest)
|
|
43
|
+
return Fetched(source=dest, sha=sha, ref=ref)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def remote_sha(url: str, ref: str) -> str | None:
|
|
47
|
+
"""The commit ``ref`` currently points at upstream, without cloning. None when unreachable -
|
|
48
|
+
an update check must never fail the dialog."""
|
|
49
|
+
try:
|
|
50
|
+
_validate(url, ref)
|
|
51
|
+
done = _git("ls-remote", url, ref, check=False)
|
|
52
|
+
except (FetchError, OSError, subprocess.SubprocessError):
|
|
53
|
+
return None
|
|
54
|
+
if done.returncode != 0:
|
|
55
|
+
return None
|
|
56
|
+
line = done.stdout.strip().split("\n")[0]
|
|
57
|
+
sha = line.split()[0] if line else ""
|
|
58
|
+
return sha or None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _validate(url: str, ref: str) -> None:
|
|
62
|
+
if not _URL_RE.match(url):
|
|
63
|
+
raise FetchError(f"{url!r} is not an https or ssh git URL")
|
|
64
|
+
if not _REF_RE.match(ref):
|
|
65
|
+
raise FetchError(f"{ref!r} is not a valid tag, branch, or commit")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _sync_mirror(url: str, mirror: Path) -> None:
|
|
69
|
+
if (mirror / "HEAD").is_file():
|
|
70
|
+
# Re-point at the URL in case a registry entry moved the repo.
|
|
71
|
+
_git("remote", "set-url", "origin", url, cwd=mirror)
|
|
72
|
+
_git("fetch", "--prune", "--tags", "origin", "+refs/heads/*:refs/heads/*", cwd=mirror)
|
|
73
|
+
return
|
|
74
|
+
mirror.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
_git("clone", "--bare", "--quiet", url, str(mirror))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _resolve(mirror: Path, ref: str) -> str:
|
|
79
|
+
"""The full commit sha for ``ref``. Pinning by sha is what makes a version reproducible even
|
|
80
|
+
if the tag is later moved."""
|
|
81
|
+
for candidate in (f"refs/tags/{ref}", f"refs/heads/{ref}", ref):
|
|
82
|
+
args = ("rev-parse", "--verify", "--quiet", f"{candidate}^{{commit}}")
|
|
83
|
+
done = _git(*args, cwd=mirror, check=False)
|
|
84
|
+
sha = done.stdout.strip()
|
|
85
|
+
if done.returncode == 0 and sha:
|
|
86
|
+
return sha
|
|
87
|
+
raise FetchError(f"{ref!r} was not found in the repository")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _extract(mirror: Path, sha: str, dest: Path) -> None:
|
|
91
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
done = subprocess.run( # noqa: S603 - fixed argv, no shell
|
|
93
|
+
[GIT.require(), "archive", "--format=tar", sha],
|
|
94
|
+
cwd=mirror,
|
|
95
|
+
capture_output=True,
|
|
96
|
+
timeout=FETCH_TIMEOUT,
|
|
97
|
+
check=False,
|
|
98
|
+
)
|
|
99
|
+
if done.returncode != 0:
|
|
100
|
+
raise FetchError(f"could not read {sha[:7]} from the repository")
|
|
101
|
+
with tarfile.open(fileobj=io.BytesIO(done.stdout), mode="r|") as archive:
|
|
102
|
+
for member in archive:
|
|
103
|
+
if not _safe_member(member):
|
|
104
|
+
raise FetchError(f"the repository contains an unsafe path: {member.name!r}")
|
|
105
|
+
archive.extract(member, dest)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _safe_member(member: tarfile.TarInfo) -> bool:
|
|
109
|
+
"""Belt and braces over git's own tree rules: no absolute paths, no traversal, no links."""
|
|
110
|
+
if member.issym() or member.islnk() or member.isdev():
|
|
111
|
+
return False
|
|
112
|
+
path = Path(member.name)
|
|
113
|
+
return not path.is_absolute() and ".." not in path.parts
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _git(
|
|
117
|
+
*args: str, cwd: Path | None = None, check: bool = True
|
|
118
|
+
) -> subprocess.CompletedProcess[str]:
|
|
119
|
+
done = subprocess.run( # noqa: S603 - fixed argv, no shell
|
|
120
|
+
[GIT.require(), *args],
|
|
121
|
+
cwd=cwd,
|
|
122
|
+
capture_output=True,
|
|
123
|
+
text=True,
|
|
124
|
+
timeout=FETCH_TIMEOUT,
|
|
125
|
+
check=False,
|
|
126
|
+
)
|
|
127
|
+
if check and done.returncode != 0:
|
|
128
|
+
raise FetchError(_tail(done.stderr) or "git failed")
|
|
129
|
+
return done
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _tail(text: str, lines: int = 4) -> str:
|
|
133
|
+
kept = [line for line in text.strip().splitlines() if line.strip()][-lines:]
|
|
134
|
+
return "\n".join(kept)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
#: `v1.2.3`, `1.2.3`, `v1.2.3-beta.1`. Anything else is not a release tag and is ignored.
|
|
138
|
+
_SEMVER_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def version_key(tag: str) -> tuple[int, int, int, int, str] | None:
|
|
142
|
+
"""Sort key for a release tag, or None when the tag isn't one.
|
|
143
|
+
|
|
144
|
+
A prerelease sorts below the same release (`1.2.0-rc.1` < `1.2.0`), so floating to the newest
|
|
145
|
+
tag never picks a release candidate over the finished version.
|
|
146
|
+
"""
|
|
147
|
+
match = _SEMVER_RE.match(tag.strip())
|
|
148
|
+
if match is None:
|
|
149
|
+
return None
|
|
150
|
+
major, minor, patch, pre = match.groups()
|
|
151
|
+
return (int(major), int(minor), int(patch), 0 if pre else 1, pre or "")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def latest_tag(url: str, *, prereleases: bool = False) -> str | None:
|
|
155
|
+
"""The newest stable release tag upstream, or None when there is none (or it's unreachable).
|
|
156
|
+
|
|
157
|
+
This is how a listing floats: the registry names the repository, and the newest tag is resolved
|
|
158
|
+
here, so an author publishes by tagging rather than by opening a registry PR.
|
|
159
|
+
|
|
160
|
+
Prereleases are skipped by default. ``v2.0.0-rc.1`` is semver-newer than ``v1.10.0``, but
|
|
161
|
+
floating a user onto a release candidate they never asked for is not; install one by naming
|
|
162
|
+
its tag explicitly.
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
_validate(url, "HEAD")
|
|
166
|
+
done = _git("ls-remote", "--tags", "--refs", url, check=False)
|
|
167
|
+
except (FetchError, OSError, subprocess.SubprocessError):
|
|
168
|
+
return None
|
|
169
|
+
if done.returncode != 0:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
best: tuple[tuple[int, int, int, int, str], str] | None = None
|
|
173
|
+
for line in done.stdout.splitlines():
|
|
174
|
+
parts = line.split()
|
|
175
|
+
if len(parts) != 2:
|
|
176
|
+
continue
|
|
177
|
+
tag = parts[1].removeprefix("refs/tags/")
|
|
178
|
+
key = version_key(tag)
|
|
179
|
+
if key is None or (not prereleases and key[3] == 0):
|
|
180
|
+
continue
|
|
181
|
+
if best is None or key > best[0]:
|
|
182
|
+
best = (key, tag)
|
|
183
|
+
return best[1] if best else None
|