inline-core 1.2.0__tar.gz → 1.2.2__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.
Files changed (152) hide show
  1. {inline_core-1.2.0 → inline_core-1.2.2}/.gitignore +4 -0
  2. {inline_core-1.2.0 → inline_core-1.2.2}/CLAUDE.md +14 -3
  3. {inline_core-1.2.0 → inline_core-1.2.2}/PKG-INFO +1 -1
  4. {inline_core-1.2.0 → inline_core-1.2.2}/pyproject.toml +1 -1
  5. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/config.py +17 -0
  6. inline_core-1.2.2/src/inline_core/extensions/__init__.py +10 -0
  7. inline_core-1.2.2/src/inline_core/extensions/api.py +214 -0
  8. inline_core-1.2.2/src/inline_core/extensions/constraints.py +179 -0
  9. inline_core-1.2.2/src/inline_core/extensions/fetch.py +190 -0
  10. inline_core-1.2.2/src/inline_core/extensions/handlers.py +144 -0
  11. inline_core-1.2.2/src/inline_core/extensions/importer.py +91 -0
  12. inline_core-1.2.2/src/inline_core/extensions/install.py +669 -0
  13. inline_core-1.2.2/src/inline_core/extensions/loader.py +285 -0
  14. inline_core-1.2.2/src/inline_core/extensions/manifest.py +361 -0
  15. inline_core-1.2.2/src/inline_core/extensions/models.py +58 -0
  16. inline_core-1.2.2/src/inline_core/extensions/paths.py +235 -0
  17. inline_core-1.2.2/src/inline_core/extensions/resolve.py +298 -0
  18. inline_core-1.2.2/src/inline_core/extensions/scanner.py +448 -0
  19. inline_core-1.2.2/src/inline_core/extensions/state.py +168 -0
  20. inline_core-1.2.2/src/inline_core/extensions/tools.py +138 -0
  21. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/graph/registry.py +22 -3
  22. inline_core-1.2.2/src/inline_core/models/requirements.py +87 -0
  23. inline_core-1.2.2/src/inline_core/models/zimage/provider.py +61 -0
  24. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/models/zimage/requirements.py +21 -19
  25. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/runtime/context.py +7 -0
  26. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/server/__main__.py +23 -4
  27. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/server/app.py +34 -8
  28. inline_core-1.2.2/src/inline_core/server/bootstrap.py +89 -0
  29. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/server/manager.py +4 -0
  30. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/server/rpc.py +10 -0
  31. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/models.py +38 -42
  32. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_cache.py +1 -0
  33. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_config.py +1 -0
  34. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_executor.py +1 -0
  35. inline_core-1.2.2/tests/test_extension_api.py +210 -0
  36. inline_core-1.2.2/tests/test_extension_install.py +534 -0
  37. inline_core-1.2.2/tests/test_extension_manifest.py +184 -0
  38. inline_core-1.2.2/tests/test_extension_resolve.py +298 -0
  39. inline_core-1.2.2/tests/test_extension_scanner.py +291 -0
  40. inline_core-1.2.2/tests/test_extension_spine.py +98 -0
  41. inline_core-1.2.2/tests/test_extension_state.py +114 -0
  42. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_file_store.py +1 -0
  43. inline_core-1.2.2/tests/test_model_requirements.py +147 -0
  44. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_primitives.py +1 -0
  45. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_run_store.py +1 -0
  46. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_schema.py +1 -0
  47. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_server.py +1 -0
  48. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_take_bytes.py +1 -0
  49. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_topo.py +1 -0
  50. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_validate.py +1 -0
  51. inline_core-1.2.0/src/inline_core/server/bootstrap.py +0 -28
  52. {inline_core-1.2.0 → inline_core-1.2.2}/.python-version +0 -0
  53. {inline_core-1.2.0 → inline_core-1.2.2}/README.md +0 -0
  54. {inline_core-1.2.0 → inline_core-1.2.2}/main.py +0 -0
  55. {inline_core-1.2.0 → inline_core-1.2.2}/scripts/reference.py +0 -0
  56. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/__init__.py +0 -0
  57. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/components/__init__.py +0 -0
  58. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/components/conditioning.py +0 -0
  59. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/components/interfaces.py +0 -0
  60. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/device/__init__.py +0 -0
  61. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/device/auto.py +0 -0
  62. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/device/detect.py +0 -0
  63. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/device/memory.py +0 -0
  64. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/device/policy.py +0 -0
  65. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/device/types.py +0 -0
  66. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/errors.py +0 -0
  67. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/graph/__init__.py +0 -0
  68. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/graph/cache.py +0 -0
  69. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/graph/descriptor.py +0 -0
  70. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/graph/executor.py +0 -0
  71. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/graph/loader_runners.py +0 -0
  72. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/graph/primitives.py +0 -0
  73. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/graph/runners.py +0 -0
  74. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/graph/schema.py +0 -0
  75. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/graph/topo.py +0 -0
  76. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/graph/validate.py +0 -0
  77. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/importer/__init__.py +0 -0
  78. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/importer/comfy.py +0 -0
  79. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/media.py +0 -0
  80. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/models/__init__.py +0 -0
  81. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/models/catalog.py +0 -0
  82. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/models/loaders.py +0 -0
  83. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/models/sampling.py +0 -0
  84. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/models/zimage/__init__.py +0 -0
  85. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/models/zimage/primitives.py +0 -0
  86. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/models/zimage/runner.py +0 -0
  87. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/parallel/__init__.py +0 -0
  88. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/parallel/config.py +0 -0
  89. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/parallel/group.py +0 -0
  90. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/parallel/launch.py +0 -0
  91. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/parallel/protocol.py +0 -0
  92. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/parallel/registry.py +0 -0
  93. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/parallel/worker.py +0 -0
  94. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/runtime/__init__.py +0 -0
  95. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/runtime/file_store.py +0 -0
  96. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/runtime/progress.py +0 -0
  97. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/runtime/run.py +0 -0
  98. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/runtime/store.py +0 -0
  99. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/sampling/__init__.py +0 -0
  100. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/sampling/batch.py +0 -0
  101. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/server/__init__.py +0 -0
  102. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/server/assets.py +0 -0
  103. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/server/frontend.py +0 -0
  104. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/server/run_store.py +0 -0
  105. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/server/serialize.py +0 -0
  106. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/__init__.py +0 -0
  107. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/assets.py +0 -0
  108. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/config.py +0 -0
  109. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/fal.py +0 -0
  110. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/frames.py +0 -0
  111. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/generation.py +0 -0
  112. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/graph_build.py +0 -0
  113. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/handlers.py +0 -0
  114. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/moodboard.py +0 -0
  115. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/peaks.py +0 -0
  116. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/schema.py +0 -0
  117. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/store.py +0 -0
  118. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/timeline/__init__.py +0 -0
  119. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/timeline/compose.py +0 -0
  120. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/timeline/ffmpeg.py +0 -0
  121. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/timeline/render.py +0 -0
  122. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/studio/timeline/resolve.py +0 -0
  123. {inline_core-1.2.0 → inline_core-1.2.2}/src/inline_core/takes.py +0 -0
  124. {inline_core-1.2.0 → inline_core-1.2.2}/tests/helpers.py +0 -0
  125. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_catalog.py +0 -0
  126. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_comfy_import.py +0 -0
  127. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_device_detect.py +0 -0
  128. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_frontend_serving.py +0 -0
  129. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_hidden_nodes.py +0 -0
  130. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_loader_runners.py +0 -0
  131. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_loaders.py +0 -0
  132. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_memory_policy.py +0 -0
  133. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_parallel_group.py +0 -0
  134. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_rpc_bridge.py +0 -0
  135. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_sampling.py +0 -0
  136. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_studio_assets.py +0 -0
  137. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_studio_fal.py +0 -0
  138. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_studio_frames.py +0 -0
  139. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_studio_generation.py +0 -0
  140. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_studio_models.py +0 -0
  141. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_studio_moodboard.py +0 -0
  142. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_studio_peaks.py +0 -0
  143. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_studio_rpc.py +0 -0
  144. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_studio_schema.py +0 -0
  145. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_studio_store.py +0 -0
  146. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_studio_timeline.py +0 -0
  147. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_xfuser_sampler.py +0 -0
  148. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_zimage_primitives.py +0 -0
  149. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_zimage_resolve.py +0 -0
  150. {inline_core-1.2.0 → inline_core-1.2.2}/tests/test_zimage_runner.py +0 -0
  151. {inline_core-1.2.0 → inline_core-1.2.2}/uv.lock +0 -0
  152. {inline_core-1.2.0 → inline_core-1.2.2}/webui.sh +0 -0
@@ -6,6 +6,10 @@
6
6
  # Engine working data: the run DB and generated takes (also grows to gigabytes).
7
7
  /.inline/
8
8
 
9
+ # Installed community extensions - `extensions_dir()` defaults to ./extensions (state.json, the
10
+ # per-extension source/venv, and the .cache/git mirrors). Runtime data, never committed.
11
+ /extensions/
12
+
9
13
  # Studio app data + project workspace (when created under the repo) and local server logs.
10
14
  /.inline-studio-server/
11
15
  /InlineStudioProjects/
@@ -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 / Storyline** repo (`inline-studio`, an Electron
10
- > app). It drives this engine over the `/v1` HTTP + websocket API. Inline Core is headless and knows
11
- > nothing about the UI.
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inline-core
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: The generation engine behind Inline Studio.
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: numpy>=1.26
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  # PyPI name; the import package is `inline_core` (src/inline_core).
3
3
  name = "inline-core"
4
- version = "1.2.0"
4
+ version = "1.2.2"
5
5
  description = "The generation engine behind Inline Studio."
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.11"
@@ -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,190 @@
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 shutil
13
+ import subprocess
14
+ import tarfile
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from .tools import FETCH_TIMEOUT, GIT
19
+
20
+ #: https/ssh for real repos, file:// for the extension-author dev loop (install your own checkout).
21
+ #: A bare path is still rejected, so nothing can be read as a git option.
22
+ _URL_RE = re.compile(r"^(https://|git@|file:///)[A-Za-z0-9]")
23
+ _REF_RE = re.compile(r"^[A-Za-z0-9._/-]{1,128}$")
24
+
25
+
26
+ class FetchError(RuntimeError):
27
+ pass
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Fetched:
32
+ source: Path
33
+ sha: str
34
+ ref: str
35
+
36
+
37
+ def fetch(url: str, ref: str, *, mirror: Path, dest: Path) -> Fetched:
38
+ """Clone/refresh ``url`` into ``mirror`` and extract ``ref`` into ``dest``."""
39
+ GIT.require()
40
+ _validate(url, ref)
41
+ _sync_mirror(url, mirror)
42
+ sha = _resolve(mirror, ref)
43
+ _extract(mirror, sha, dest)
44
+ return Fetched(source=dest, sha=sha, ref=ref)
45
+
46
+
47
+ def remote_sha(url: str, ref: str) -> str | None:
48
+ """The commit ``ref`` currently points at upstream, without cloning. None when unreachable -
49
+ an update check must never fail the dialog."""
50
+ try:
51
+ _validate(url, ref)
52
+ done = _git("ls-remote", url, ref, check=False)
53
+ except (FetchError, OSError, subprocess.SubprocessError):
54
+ return None
55
+ if done.returncode != 0:
56
+ return None
57
+ line = done.stdout.strip().split("\n")[0]
58
+ sha = line.split()[0] if line else ""
59
+ return sha or None
60
+
61
+
62
+ def _validate(url: str, ref: str) -> None:
63
+ if not _URL_RE.match(url):
64
+ raise FetchError(f"{url!r} is not an https or ssh git URL")
65
+ if not _REF_RE.match(ref):
66
+ raise FetchError(f"{ref!r} is not a valid tag, branch, or commit")
67
+
68
+
69
+ def _sync_mirror(url: str, mirror: Path) -> None:
70
+ # A valid bare mirror has HEAD at its top; a clone interrupted mid-write leaves objects/refs but
71
+ # no HEAD, and `git clone` would then refuse the non-empty directory ("already exists").
72
+ if (mirror / "HEAD").is_file():
73
+ try:
74
+ # Re-point at the URL in case a registry entry moved the repo.
75
+ _git("remote", "set-url", "origin", url, cwd=mirror)
76
+ _git("fetch", "--prune", "--tags", "origin", "+refs/heads/*:refs/heads/*", cwd=mirror)
77
+ return
78
+ except FetchError:
79
+ pass # A wedged mirror must not strand reinstall; drop it and re-clone below.
80
+ shutil.rmtree(mirror, ignore_errors=True)
81
+ mirror.parent.mkdir(parents=True, exist_ok=True)
82
+ _git("clone", "--bare", "--quiet", url, str(mirror))
83
+
84
+
85
+ def _resolve(mirror: Path, ref: str) -> str:
86
+ """The full commit sha for ``ref``. Pinning by sha is what makes a version reproducible even
87
+ if the tag is later moved."""
88
+ for candidate in (f"refs/tags/{ref}", f"refs/heads/{ref}", ref):
89
+ args = ("rev-parse", "--verify", "--quiet", f"{candidate}^{{commit}}")
90
+ done = _git(*args, cwd=mirror, check=False)
91
+ sha = done.stdout.strip()
92
+ if done.returncode == 0 and sha:
93
+ return sha
94
+ raise FetchError(f"{ref!r} was not found in the repository")
95
+
96
+
97
+ def _extract(mirror: Path, sha: str, dest: Path) -> None:
98
+ dest.mkdir(parents=True, exist_ok=True)
99
+ done = subprocess.run( # noqa: S603 - fixed argv, no shell
100
+ [GIT.require(), "archive", "--format=tar", sha],
101
+ cwd=mirror,
102
+ capture_output=True,
103
+ timeout=FETCH_TIMEOUT,
104
+ check=False,
105
+ )
106
+ if done.returncode != 0:
107
+ raise FetchError(f"could not read {sha[:7]} from the repository")
108
+ with tarfile.open(fileobj=io.BytesIO(done.stdout), mode="r|") as archive:
109
+ for member in archive:
110
+ if not _safe_member(member):
111
+ raise FetchError(f"the repository contains an unsafe path: {member.name!r}")
112
+ archive.extract(member, dest)
113
+
114
+
115
+ def _safe_member(member: tarfile.TarInfo) -> bool:
116
+ """Belt and braces over git's own tree rules: no absolute paths, no traversal, no links."""
117
+ if member.issym() or member.islnk() or member.isdev():
118
+ return False
119
+ path = Path(member.name)
120
+ return not path.is_absolute() and ".." not in path.parts
121
+
122
+
123
+ def _git(
124
+ *args: str, cwd: Path | None = None, check: bool = True
125
+ ) -> subprocess.CompletedProcess[str]:
126
+ done = subprocess.run( # noqa: S603 - fixed argv, no shell
127
+ [GIT.require(), *args],
128
+ cwd=cwd,
129
+ capture_output=True,
130
+ text=True,
131
+ timeout=FETCH_TIMEOUT,
132
+ check=False,
133
+ )
134
+ if check and done.returncode != 0:
135
+ raise FetchError(_tail(done.stderr) or "git failed")
136
+ return done
137
+
138
+
139
+ def _tail(text: str, lines: int = 4) -> str:
140
+ kept = [line for line in text.strip().splitlines() if line.strip()][-lines:]
141
+ return "\n".join(kept)
142
+
143
+
144
+ #: `v1.2.3`, `1.2.3`, `v1.2.3-beta.1`. Anything else is not a release tag and is ignored.
145
+ _SEMVER_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$")
146
+
147
+
148
+ def version_key(tag: str) -> tuple[int, int, int, int, str] | None:
149
+ """Sort key for a release tag, or None when the tag isn't one.
150
+
151
+ A prerelease sorts below the same release (`1.2.0-rc.1` < `1.2.0`), so floating to the newest
152
+ tag never picks a release candidate over the finished version.
153
+ """
154
+ match = _SEMVER_RE.match(tag.strip())
155
+ if match is None:
156
+ return None
157
+ major, minor, patch, pre = match.groups()
158
+ return (int(major), int(minor), int(patch), 0 if pre else 1, pre or "")
159
+
160
+
161
+ def latest_tag(url: str, *, prereleases: bool = False) -> str | None:
162
+ """The newest stable release tag upstream, or None when there is none (or it's unreachable).
163
+
164
+ This is how a listing floats: the registry names the repository, and the newest tag is resolved
165
+ here, so an author publishes by tagging rather than by opening a registry PR.
166
+
167
+ Prereleases are skipped by default. ``v2.0.0-rc.1`` is semver-newer than ``v1.10.0``, but
168
+ floating a user onto a release candidate they never asked for is not; install one by naming
169
+ its tag explicitly.
170
+ """
171
+ try:
172
+ _validate(url, "HEAD")
173
+ done = _git("ls-remote", "--tags", "--refs", url, check=False)
174
+ except (FetchError, OSError, subprocess.SubprocessError):
175
+ return None
176
+ if done.returncode != 0:
177
+ return None
178
+
179
+ best: tuple[tuple[int, int, int, int, str], str] | None = None
180
+ for line in done.stdout.splitlines():
181
+ parts = line.split()
182
+ if len(parts) != 2:
183
+ continue
184
+ tag = parts[1].removeprefix("refs/tags/")
185
+ key = version_key(tag)
186
+ if key is None or (not prereleases and key[3] == 0):
187
+ continue
188
+ if best is None or key > best[0]:
189
+ best = (key, tag)
190
+ return best[1] if best else None