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.
Files changed (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. 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