induscode 0.1.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.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Addon loader — the SINGLE importlib-backed :class:`ModuleLoader`.
|
|
2
|
+
|
|
3
|
+
This module turns a path on disk into a loaded :class:`AddonManifest`. It is
|
|
4
|
+
the one place the app dynamically imports user code.
|
|
5
|
+
|
|
6
|
+
Port note — the jiti / virtual-module machinery is DROPPED
|
|
7
|
+
----------------------------------------------------------
|
|
8
|
+
The TS ``sandbox.ts`` existed to solve two Node-shaped problems:
|
|
9
|
+
|
|
10
|
+
1. **TypeScript on the fly** — addons shipped as ``.ts`` and needed jiti to
|
|
11
|
+
transpile them at load time. Python addons are plain ``.py`` source; the
|
|
12
|
+
standard ``importlib`` machinery loads them directly.
|
|
13
|
+
2. **A compiled binary has no ``node_modules``** — an addon's
|
|
14
|
+
``import "indusagi/agent"`` could not resolve against the filesystem, so
|
|
15
|
+
jiti bridged the ``BUNDLED_NAMESPACES`` as virtual modules (or resolved
|
|
16
|
+
aliases under Node). In Python the ``indusagi`` framework (and
|
|
17
|
+
``induscode`` itself) are ordinary installed packages on ``sys.path``, so
|
|
18
|
+
a plain ``import indusagi.agent`` inside an addon **just works** — no
|
|
19
|
+
bridge, no alias map, no namespace objects. ``BUNDLED_NAMESPACES`` is kept
|
|
20
|
+
in the contract as vestigial vocabulary only.
|
|
21
|
+
|
|
22
|
+
What survives verbatim is the loader's input-hygiene story: the
|
|
23
|
+
:func:`scrub_invisible` invisible-codepoint scrub (the explicit, auditable
|
|
24
|
+
list — not an opaque regex class), :func:`expand_path` home expansion, and
|
|
25
|
+
the :func:`resolve_path` normalizer every loader entry funnels through.
|
|
26
|
+
|
|
27
|
+
Cache behavior parity: jiti ran with ``moduleCache: false`` so re-loading an
|
|
28
|
+
edited addon during a session picked up the new source. The importlib loader
|
|
29
|
+
mirrors that by executing every load under a **fresh synthetic module name**
|
|
30
|
+
(a process-wide counter), so a repeated ``load`` of the same path re-executes
|
|
31
|
+
the file instead of returning a stale cached module. The synthetic module is
|
|
32
|
+
left registered in ``sys.modules`` (modules must be importable by name while
|
|
33
|
+
executing — dataclasses, ``__package__`` machinery — and unregistering would
|
|
34
|
+
break later introspection of live objects).
|
|
35
|
+
|
|
36
|
+
The loader is deliberately injectable: :func:`create_module_loader` is the
|
|
37
|
+
default :class:`ModuleLoader`, but every consumer takes the Protocol, so a
|
|
38
|
+
test can inject a scripted fake that returns a manifest with no import and no
|
|
39
|
+
disk.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import importlib.util
|
|
45
|
+
import itertools
|
|
46
|
+
import os
|
|
47
|
+
import re
|
|
48
|
+
import sys
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from types import ModuleType
|
|
51
|
+
|
|
52
|
+
from .contract import AddonManifest, ModuleLoader
|
|
53
|
+
from .manifest import PACKAGE_ENTRY
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"create_module_loader",
|
|
57
|
+
"expand_path",
|
|
58
|
+
"resolve_path",
|
|
59
|
+
"scrub_invisible",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Path hygiene — invisible-whitespace scrub + home expansion
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
#: Code points that render as whitespace (or nothing) yet are not the plain
|
|
68
|
+
#: ASCII space/tab/newline a path is expected to contain. A path string copied
|
|
69
|
+
#: out of a rich text source, a chat message, or a terminal can carry these
|
|
70
|
+
#: invisibly, which makes an otherwise-correct path fail to resolve. This set
|
|
71
|
+
#: is kept as an explicit, auditable list (verbatim from the TS lineage)
|
|
72
|
+
#: rather than a single opaque regex class.
|
|
73
|
+
#:
|
|
74
|
+
#: - ``U+00A0`` no-break space
|
|
75
|
+
#: - ``U+200B``–``U+200D`` zero-width space / non-joiner / joiner
|
|
76
|
+
#: - ``U+200E`` / ``U+200F`` left-to-right / right-to-left marks
|
|
77
|
+
#: - ``U+2060`` word joiner
|
|
78
|
+
#: - ``U+FEFF`` zero-width no-break space (BOM)
|
|
79
|
+
#: - the ``U+2000``–``U+200A`` range of fixed-width typographic spaces
|
|
80
|
+
#: - ``U+202F`` narrow no-break space, ``U+205F`` medium mathematical space
|
|
81
|
+
#: - ``U+3000`` ideographic space
|
|
82
|
+
_INVISIBLE_CODE_POINTS: tuple[int, ...] = (
|
|
83
|
+
0x00A0, 0x200B, 0x200C, 0x200D, 0x200E, 0x200F, 0x2060, 0xFEFF, 0x202F,
|
|
84
|
+
0x205F, 0x3000,
|
|
85
|
+
# U+2000 … U+200A inclusive
|
|
86
|
+
0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008,
|
|
87
|
+
0x2009, 0x200A,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
#: The invisible code points as a fast-membership set, computed once.
|
|
91
|
+
_INVISIBLE_SET: frozenset[int] = frozenset(_INVISIBLE_CODE_POINTS)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def scrub_invisible(raw: str) -> str:
|
|
95
|
+
"""Strip invisible / non-standard whitespace from a path string and trim
|
|
96
|
+
ordinary leading and trailing whitespace.
|
|
97
|
+
|
|
98
|
+
Walks the string by code point (so astral characters survive intact),
|
|
99
|
+
dropping any member of the invisible set, then trims the edges. Written
|
|
100
|
+
as an explicit scan rather than a regex so the exact handled set is
|
|
101
|
+
visible at the call site.
|
|
102
|
+
|
|
103
|
+
:param raw: the path as supplied (possibly carrying invisible characters)
|
|
104
|
+
"""
|
|
105
|
+
out = "".join(char for char in raw if ord(char) not in _INVISIBLE_SET)
|
|
106
|
+
return out.strip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def expand_path(raw: str, home: str | None = None) -> str:
|
|
110
|
+
"""Expand a leading ``~`` (or ``~/``) to the user's home directory.
|
|
111
|
+
|
|
112
|
+
Only a ``~`` that begins the (already-scrubbed) string is treated as the
|
|
113
|
+
home marker; a ``~`` anywhere else is an ordinary character. ``~`` alone
|
|
114
|
+
becomes the home directory; ``~/x`` becomes ``<home>/x``. A ``home``
|
|
115
|
+
override is accepted for tests and embedding so the function never reads
|
|
116
|
+
the environment implicitly when one is supplied.
|
|
117
|
+
|
|
118
|
+
:param raw: the path, possibly beginning with a home marker
|
|
119
|
+
:param home: the home directory to expand against; defaults to the user's
|
|
120
|
+
"""
|
|
121
|
+
resolved_home = home if home is not None else os.path.expanduser("~")
|
|
122
|
+
cleaned = scrub_invisible(raw)
|
|
123
|
+
if cleaned == "~":
|
|
124
|
+
return resolved_home
|
|
125
|
+
if cleaned.startswith("~/") or cleaned.startswith("~\\"):
|
|
126
|
+
return resolved_home + cleaned[1:]
|
|
127
|
+
return cleaned
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def resolve_path(raw: str, base: str | None = None, home: str | None = None) -> str:
|
|
131
|
+
"""Resolve a path to an absolute, invisible-free path.
|
|
132
|
+
|
|
133
|
+
Scrubs invisible whitespace, expands a leading home marker, then anchors
|
|
134
|
+
the result against ``base`` (the working directory) when it is not
|
|
135
|
+
already absolute. The single normalizer every loader entry funnels
|
|
136
|
+
through, so input hygiene is applied in exactly one place.
|
|
137
|
+
|
|
138
|
+
:param raw: the path as supplied
|
|
139
|
+
:param base: the directory a relative path resolves against; defaults to
|
|
140
|
+
the process working directory
|
|
141
|
+
:param home: the home directory ``~`` expands against; defaults to the user's
|
|
142
|
+
"""
|
|
143
|
+
expanded = expand_path(raw, home)
|
|
144
|
+
if os.path.isabs(expanded):
|
|
145
|
+
return os.path.normpath(expanded)
|
|
146
|
+
base_dir = base if base is not None else os.getcwd()
|
|
147
|
+
return os.path.abspath(os.path.join(base_dir, expanded))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# importlib spec loading
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
#: Process-wide counter minting a fresh synthetic module name per load — the
|
|
155
|
+
#: Python analogue of jiti's ``moduleCache: false`` (an edited addon re-loads
|
|
156
|
+
#: from its new source instead of a stale ``sys.modules`` hit).
|
|
157
|
+
_LOAD_SEQ = itertools.count()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _module_name_for(absolute: str) -> str:
|
|
161
|
+
"""A unique, importable synthetic module name for one load of ``absolute``.
|
|
162
|
+
|
|
163
|
+
Carries the sanitized file stem (or the enclosing package name for an
|
|
164
|
+
``__init__.py`` entry) for readable tracebacks, plus the load sequence so
|
|
165
|
+
repeated loads never collide.
|
|
166
|
+
"""
|
|
167
|
+
stem = os.path.splitext(os.path.basename(absolute))[0]
|
|
168
|
+
if stem == "__init__":
|
|
169
|
+
stem = os.path.basename(os.path.dirname(absolute))
|
|
170
|
+
safe = re.sub(r"[^0-9A-Za-z_]", "_", stem) or "addon"
|
|
171
|
+
return f"_induscode_addon_{next(_LOAD_SEQ)}_{safe}"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _import_module(absolute: str) -> ModuleType:
|
|
175
|
+
"""Spec-load the module file at ``absolute`` under a fresh synthetic name.
|
|
176
|
+
|
|
177
|
+
A :data:`PACKAGE_ENTRY` (``__init__.py``) is loaded as a package (its
|
|
178
|
+
directory becomes the submodule search path, so intra-package imports
|
|
179
|
+
work); any other ``.py`` file is a plain module. Raises on any import
|
|
180
|
+
failure — the host converts the raise into a ``load`` fault.
|
|
181
|
+
"""
|
|
182
|
+
name = _module_name_for(absolute)
|
|
183
|
+
if os.path.basename(absolute) == PACKAGE_ENTRY:
|
|
184
|
+
spec = importlib.util.spec_from_file_location(
|
|
185
|
+
name,
|
|
186
|
+
absolute,
|
|
187
|
+
submodule_search_locations=[os.path.dirname(absolute)],
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
spec = importlib.util.spec_from_file_location(name, absolute)
|
|
191
|
+
if spec is None or spec.loader is None:
|
|
192
|
+
raise ImportError(f"cannot build an import spec for {absolute}")
|
|
193
|
+
module = importlib.util.module_from_spec(spec)
|
|
194
|
+
# Modules must be reachable by name during execution (dataclasses,
|
|
195
|
+
# relative-import machinery); register before exec, unwind on failure.
|
|
196
|
+
sys.modules[name] = module
|
|
197
|
+
try:
|
|
198
|
+
spec.loader.exec_module(module)
|
|
199
|
+
except BaseException:
|
|
200
|
+
sys.modules.pop(name, None)
|
|
201
|
+
raise
|
|
202
|
+
return module
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# Manifest extraction
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
211
|
+
class _LoadedManifest:
|
|
212
|
+
"""The :class:`AddonManifest`-shaped record the loader extracts from an
|
|
213
|
+
imported module: the resolved ``register`` callable plus the optional
|
|
214
|
+
string ``id`` / ``version`` it declared."""
|
|
215
|
+
|
|
216
|
+
register: object # Callable[[AddonSurface], None | Awaitable[None]]
|
|
217
|
+
id: str | None = None
|
|
218
|
+
version: str | None = None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _pick_manifest_object(imported: object) -> object | None:
|
|
222
|
+
"""Unwrap the candidate manifest object from an imported module.
|
|
223
|
+
|
|
224
|
+
Accepts, in order: the module itself when it exposes a module-level
|
|
225
|
+
callable ``register``; otherwise a ``manifest`` attribute; otherwise a
|
|
226
|
+
``default`` attribute (the TS default-export idiom) — whichever first
|
|
227
|
+
carries a callable ``register``. Returns ``None`` when none is usable.
|
|
228
|
+
|
|
229
|
+
:param imported: the module object importlib returned for the addon
|
|
230
|
+
"""
|
|
231
|
+
if callable(getattr(imported, "register", None)):
|
|
232
|
+
return imported
|
|
233
|
+
for attr in ("manifest", "default"):
|
|
234
|
+
candidate = getattr(imported, attr, None)
|
|
235
|
+
if candidate is not None and callable(getattr(candidate, "register", None)):
|
|
236
|
+
return candidate
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _to_manifest(imported: object) -> AddonManifest | None:
|
|
241
|
+
"""Narrow an arbitrary imported module to an :class:`AddonManifest`.
|
|
242
|
+
|
|
243
|
+
An addon module is accepted when it (or its ``manifest``/``default``
|
|
244
|
+
object) exposes a callable ``register``. The optional ``id`` / ``version``
|
|
245
|
+
are copied through only when they are strings, so a malformed field
|
|
246
|
+
cannot poison the loaded manifest.
|
|
247
|
+
|
|
248
|
+
:param imported: the module object importlib returned for the addon
|
|
249
|
+
"""
|
|
250
|
+
candidate = _pick_manifest_object(imported)
|
|
251
|
+
if candidate is None:
|
|
252
|
+
return None
|
|
253
|
+
register = getattr(candidate, "register")
|
|
254
|
+
raw_id = getattr(candidate, "id", None)
|
|
255
|
+
raw_version = getattr(candidate, "version", None)
|
|
256
|
+
loaded = _LoadedManifest(
|
|
257
|
+
register=register,
|
|
258
|
+
id=raw_id if isinstance(raw_id, str) else None,
|
|
259
|
+
version=raw_version if isinstance(raw_version, str) else None,
|
|
260
|
+
)
|
|
261
|
+
# _LoadedManifest carries `register` as a plain callable field, which
|
|
262
|
+
# satisfies the structural AddonManifest Protocol at runtime.
|
|
263
|
+
return loaded # type: ignore[return-value]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
# The default loader
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class _ImportlibLoader:
|
|
272
|
+
"""The default importlib-backed :class:`ModuleLoader`.
|
|
273
|
+
|
|
274
|
+
Normalizes the supplied path (scrub + ``~`` + base anchoring), spec-loads
|
|
275
|
+
it under a fresh synthetic name, and extracts the
|
|
276
|
+
:class:`AddonManifest`. A module that does not expose a callable
|
|
277
|
+
``register`` is rejected, so the host converts the raise into a load
|
|
278
|
+
fault.
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
def __init__(self, base: str | None, home: str | None) -> None:
|
|
282
|
+
self._base = base
|
|
283
|
+
self._home = home
|
|
284
|
+
|
|
285
|
+
async def load(self, path: str) -> AddonManifest:
|
|
286
|
+
absolute = resolve_path(path, self._base, self._home)
|
|
287
|
+
module = _import_module(absolute)
|
|
288
|
+
manifest = _to_manifest(module)
|
|
289
|
+
if manifest is None:
|
|
290
|
+
raise ValueError(
|
|
291
|
+
f"addon at {absolute} does not export a register() entry point"
|
|
292
|
+
)
|
|
293
|
+
return manifest
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def create_module_loader(
|
|
297
|
+
*, base: str | None = None, home: str | None = None
|
|
298
|
+
) -> ModuleLoader:
|
|
299
|
+
"""Construct the default importlib-backed :class:`ModuleLoader`.
|
|
300
|
+
|
|
301
|
+
All options default to the live runtime, but each is overridable so a
|
|
302
|
+
test can pin the working directory or home without touching the process.
|
|
303
|
+
These options only steer how a supplied path is normalized before it is
|
|
304
|
+
imported.
|
|
305
|
+
|
|
306
|
+
This is the production loader; tests inject a fake implementing the same
|
|
307
|
+
Protocol, exercising the registry/host without any real import or disk.
|
|
308
|
+
|
|
309
|
+
:param base: directory a relative addon path resolves against; defaults
|
|
310
|
+
to the process working directory
|
|
311
|
+
:param home: home directory a leading ``~`` expands against; defaults to
|
|
312
|
+
the user's
|
|
313
|
+
"""
|
|
314
|
+
return _ImportlibLoader(base, home)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Addon discovery — scanning a workspace for addon entry modules.
|
|
2
|
+
|
|
3
|
+
Where ``loader.py`` answers "how do I load *this* path", this module answers
|
|
4
|
+
"*which* paths are there to load". It walks the per-workspace
|
|
5
|
+
:data:`ADDONS_DIR` (``.indus/addons`` — the dirname is kept verbatim from the
|
|
6
|
+
TS lineage) one level deep and yields the absolute entry-module paths a
|
|
7
|
+
:class:`ModuleLoader` will resolve.
|
|
8
|
+
|
|
9
|
+
Python addon convention (locked, plan §3) — two candidate shapes are
|
|
10
|
+
recognised inside that directory:
|
|
11
|
+
|
|
12
|
+
1. **A bare module file** — ``something.py`` sitting directly in the addons
|
|
13
|
+
directory *is* its own entry.
|
|
14
|
+
2. **A package directory** — a subdirectory holding an ``__init__.py``; that
|
|
15
|
+
``__init__.py`` is the entry. (The TS ``package.json`` ``indusAddon``
|
|
16
|
+
pointer and ``index.*`` probing collapse to this one Python-native rule.)
|
|
17
|
+
|
|
18
|
+
Discovery is filesystem-only and never imports a module: it produces *paths*,
|
|
19
|
+
leaving the actual import to the loader. Hidden entries (dot-files and
|
|
20
|
+
dot-directories) are skipped so editor and VCS detritus is never treated as
|
|
21
|
+
an addon (``__pycache__`` directories fall out naturally — they hold no
|
|
22
|
+
``__init__.py``). The scan is resilient: a missing addons directory yields an
|
|
23
|
+
empty list rather than an error, and an unreadable entry is silently skipped.
|
|
24
|
+
|
|
25
|
+
Two entry points are exposed:
|
|
26
|
+
|
|
27
|
+
- :func:`discover_addons` — the low-level "scan one directory, return paths".
|
|
28
|
+
- :func:`discover_sources` — folds an :class:`AddonDiscovery` config
|
|
29
|
+
(workspace + explicit paths) into a deduplicated, id-stamped
|
|
30
|
+
:class:`AddonSource` list the host feeds to the loader.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
from typing import Literal
|
|
37
|
+
|
|
38
|
+
from .contract import ADDONS_DIR, AddonDiscovery, AddonId, AddonSource, addon_id
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"ENTRY_EXTENSIONS",
|
|
42
|
+
"PACKAGE_ENTRY",
|
|
43
|
+
"discover_addons",
|
|
44
|
+
"discover_sources",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Recognised module shapes
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
#: File extensions a loadable addon entry can carry. The TS list spanned the
|
|
53
|
+
#: jiti-transpilable set (.ts/.tsx/.mts/.cts/.js/.mjs/.cjs); Python addons are
|
|
54
|
+
#: plain source modules, so the set collapses to ``.py`` — kept data-sourced
|
|
55
|
+
#: so widening it stays a one-line edit.
|
|
56
|
+
ENTRY_EXTENSIONS: tuple[str, ...] = (".py",)
|
|
57
|
+
|
|
58
|
+
#: The conventional entry file a package directory must hold to be an addon.
|
|
59
|
+
#: Replaces the TS ``package.json`` ``indusAddon`` pointer + ``index.*``
|
|
60
|
+
#: probing with the one Python-native marker.
|
|
61
|
+
PACKAGE_ENTRY = "__init__.py"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_hidden(name: str) -> bool:
|
|
65
|
+
"""Whether ``name`` is a hidden entry (begins with a dot) that discovery
|
|
66
|
+
skips."""
|
|
67
|
+
return name.startswith(".")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _has_entry_extension(name: str) -> bool:
|
|
71
|
+
"""Whether ``name`` carries one of the recognised :data:`ENTRY_EXTENSIONS`."""
|
|
72
|
+
return os.path.splitext(name)[1].lower() in ENTRY_EXTENSIONS
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Filesystem probes (total — never raise)
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _entry_kind(path: str) -> Literal["file", "directory"] | None:
|
|
81
|
+
"""The kind of a filesystem entry, or ``None`` when it cannot be stat'd.
|
|
82
|
+
|
|
83
|
+
Wraps the stat probes so a transient/permission error during a scan
|
|
84
|
+
degrades to "skip this entry" instead of aborting the whole discovery
|
|
85
|
+
pass.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
if os.path.isdir(path):
|
|
89
|
+
return "directory"
|
|
90
|
+
if os.path.isfile(path):
|
|
91
|
+
return "file"
|
|
92
|
+
except OSError:
|
|
93
|
+
return None
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _list_names(dir: str) -> list[str]:
|
|
98
|
+
"""The names directly inside ``dir``, or an empty list when ``dir`` is
|
|
99
|
+
absent or unreadable. Total so a missing addons directory is a no-op, not
|
|
100
|
+
a failure."""
|
|
101
|
+
try:
|
|
102
|
+
return os.listdir(dir)
|
|
103
|
+
except OSError:
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Per-candidate resolution
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _resolve_package_entry(package_dir: str) -> str | None:
|
|
113
|
+
"""Resolve a package directory's entry module to an absolute path.
|
|
114
|
+
|
|
115
|
+
A directory is an addon package exactly when it holds a
|
|
116
|
+
:data:`PACKAGE_ENTRY` (``__init__.py``). Returns ``None`` otherwise.
|
|
117
|
+
|
|
118
|
+
:param package_dir: the candidate package directory
|
|
119
|
+
"""
|
|
120
|
+
candidate = os.path.join(package_dir, PACKAGE_ENTRY)
|
|
121
|
+
return candidate if _entry_kind(candidate) == "file" else None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _resolve_top_level_entry(dir: str, name: str) -> str | None:
|
|
125
|
+
"""Resolve a single top-level entry of the addons directory to its addon
|
|
126
|
+
entry path, or ``None`` when it is not a recognised addon shape.
|
|
127
|
+
|
|
128
|
+
- a non-hidden file with a recognised extension is its own entry;
|
|
129
|
+
- a non-hidden directory resolves through :func:`_resolve_package_entry`.
|
|
130
|
+
|
|
131
|
+
:param dir: the addons directory being scanned
|
|
132
|
+
:param name: a top-level entry name within it
|
|
133
|
+
"""
|
|
134
|
+
if _is_hidden(name):
|
|
135
|
+
return None
|
|
136
|
+
path = os.path.join(dir, name)
|
|
137
|
+
kind = _entry_kind(path)
|
|
138
|
+
if kind == "file":
|
|
139
|
+
return path if _has_entry_extension(name) else None
|
|
140
|
+
if kind == "directory":
|
|
141
|
+
return _resolve_package_entry(path)
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# Public discovery
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def discover_addons(dir: str) -> list[str]:
|
|
151
|
+
"""Scan one addons directory and return the absolute entry-module paths it
|
|
152
|
+
holds.
|
|
153
|
+
|
|
154
|
+
Walks ``dir`` a single level deep, resolving each non-hidden child through
|
|
155
|
+
:func:`_resolve_top_level_entry`. The result is sorted by path for a
|
|
156
|
+
stable, deterministic load order, and is empty when ``dir`` is missing or
|
|
157
|
+
holds no recognised addon. This is the low-level primitive;
|
|
158
|
+
:func:`discover_sources` layers the config (workspace + explicit paths)
|
|
159
|
+
and id-stamping on top.
|
|
160
|
+
|
|
161
|
+
:param dir: absolute path to an addons directory (typically
|
|
162
|
+
``<workspace>/.indus/addons``)
|
|
163
|
+
"""
|
|
164
|
+
entries: list[str] = []
|
|
165
|
+
for name in _list_names(dir):
|
|
166
|
+
entry = _resolve_top_level_entry(dir, name)
|
|
167
|
+
if entry is not None:
|
|
168
|
+
entries.append(entry)
|
|
169
|
+
return sorted(entries)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _derive_addon_id(entry_path: str) -> AddonId:
|
|
173
|
+
"""Derive a stable :data:`AddonId` for a discovered entry path.
|
|
174
|
+
|
|
175
|
+
Uses the entry's enclosing folder name when the file is the conventional
|
|
176
|
+
package entry (so ``<ws>/.indus/addons/foo/__init__.py`` is identified as
|
|
177
|
+
``foo``), otherwise the file's own base name without extension. The full
|
|
178
|
+
path is the ultimate uniqueness guarantee; this id is the human-facing
|
|
179
|
+
label used in diagnostics.
|
|
180
|
+
|
|
181
|
+
:param entry_path: the absolute entry-module path
|
|
182
|
+
"""
|
|
183
|
+
file = os.path.basename(entry_path)
|
|
184
|
+
stem = os.path.splitext(file)[0]
|
|
185
|
+
if file == PACKAGE_ENTRY or stem == "__init__":
|
|
186
|
+
label = os.path.basename(os.path.dirname(entry_path))
|
|
187
|
+
else:
|
|
188
|
+
label = stem
|
|
189
|
+
return addon_id(label if len(label) > 0 else entry_path)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _addons_dir_for(workspace: str, dir: str | None = None) -> str:
|
|
193
|
+
"""The default addons directory for a workspace, joined from
|
|
194
|
+
:data:`ADDONS_DIR`.
|
|
195
|
+
|
|
196
|
+
:param workspace: absolute workspace root
|
|
197
|
+
:param dir: directory relative to the workspace; defaults to :data:`ADDONS_DIR`
|
|
198
|
+
"""
|
|
199
|
+
return os.path.abspath(os.path.join(workspace, dir if dir is not None else ADDONS_DIR))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def discover_sources(discovery: AddonDiscovery) -> list[AddonSource]:
|
|
203
|
+
"""Fold an :class:`AddonDiscovery` config into the ordered, deduplicated
|
|
204
|
+
list of :class:`AddonSource` entries the host hands to the loader.
|
|
205
|
+
|
|
206
|
+
Concatenates, in this order: the entries discovered under the workspace's
|
|
207
|
+
addons directory (when a ``workspace`` is given), then any
|
|
208
|
+
:attr:`AddonDiscovery.explicit_paths` (resolved to absolute paths;
|
|
209
|
+
already-absolute paths pass through verbatim, exactly as in TS — they are
|
|
210
|
+
the fake-loader table keys in tests). Duplicate paths are collapsed — the
|
|
211
|
+
first occurrence wins — and each surviving path is stamped with a derived
|
|
212
|
+
:data:`AddonId`. Discovery touches the filesystem only to enumerate; it
|
|
213
|
+
never imports a module.
|
|
214
|
+
|
|
215
|
+
:param discovery: the discovery configuration (workspace, dir, explicit paths)
|
|
216
|
+
"""
|
|
217
|
+
paths: list[str] = []
|
|
218
|
+
|
|
219
|
+
if discovery.workspace is not None:
|
|
220
|
+
paths.extend(discover_addons(_addons_dir_for(discovery.workspace, discovery.dir)))
|
|
221
|
+
|
|
222
|
+
for explicit in discovery.explicit_paths or ():
|
|
223
|
+
paths.append(explicit if os.path.isabs(explicit) else os.path.abspath(explicit))
|
|
224
|
+
|
|
225
|
+
seen: set[str] = set()
|
|
226
|
+
sources: list[AddonSource] = []
|
|
227
|
+
for path in paths:
|
|
228
|
+
if path in seen:
|
|
229
|
+
continue
|
|
230
|
+
seen.add(path)
|
|
231
|
+
sources.append(AddonSource(id=_derive_addon_id(path), path=path))
|
|
232
|
+
return sources
|