forked from molecule-ai/molecule-core
Ryan's bug report (#2934) walked through ~45 min of debugging a stock external-runtime install. This PR fixes the four items he flagged that have a small surface, and stubs out the larger ones for follow-up. Fixed in this PR ================ #1 — Python floor disclosure (README in publish bundle) Add an explicit "Requires Python ≥3.11" section that calls out the cryptic "Could not find a version that satisfies the requirement" failure mode; recommend `pipx install` over `pip install` so the binary lands on PATH automatically; show the explicit `pip install --user` alternative with the PATH caveat. #3 — MOLECULE_WORKSPACE_TOKEN_FILE support (mcp_workspace_resolver.py) Add a third resolution step between the inline env var and the in-container CONFIGS_DIR fallback. Operators can write the bearer to a 0600 file (e.g. ~/.config/molecule/token) and point MOLECULE_WORKSPACE_TOKEN_FILE at it, keeping the secret out of ~/.zsh_history and out of plaintext in MCP-host configs like ~/.claude.json. Inline TOKEN still wins on conflict so rotation flows are predictable. README documents the safer option as the recommended path. 6 new tests pin every leg (file resolves, inline wins, missing/empty file falls through, blank env unset-equivalent, help text advertises it). #4 — Push delivery 3-condition gating (README in publish bundle) Document that real-time push on Claude Code requires (a) the server to declare experimental.claude/channel (we do), (b) the server to be marketplace-plugin-sourced (operators must scaffold their own until the official marketplace lands — see #2934 follow-up), and (c) the --dangerously-load-development-channels flag on the claude invocation. Until any of the three is in place, delivery silently falls back to poll mode with no diagnostic. The README now says all of this explicitly so a new operator doesn't grep the binary for channel_enable to figure it out. #8 — serverInfo.name mismatch (a2a_mcp_server.py) The server reported `serverInfo.name = "a2a-delegation"` while operators register it as `molecule` (the name in `claude mcp add molecule …`). Harmless on tool routing today but matters for any future Claude Code allowlist that gates push by hardcoded server name. Renamed to "molecule" with an inline comment explaining the invariant. Deferred (separate issues to track) =================================== #2 — covered transitively by #1's pipx recommendation; no separate fix. #5 — `moleculesai/claude-code-plugin` marketplace repo (substantial new repo work; the README references it as a documented follow-up). #6 — `molecule-mcp doctor` subcommand (substantial new CLI surface; mentioned in the README's push-vs-poll section as the planned diagnostic for silent push fallback). #7 — `--dangerously-load-development-channels` rename — not in our control; that's Claude Code's flag. Tests ===== 164/164 mcp_cli + a2a_mcp_server tests pass locally (WORKSPACE_ID=00000000-0000-0000-0000-000000000001 pytest …) including 6 new TestTokenFileEnv cases. Wheel builds successfully via scripts/build_runtime_package.py with the new README markers verified in the output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
504 lines
19 KiB
Python
Executable File
504 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Build the molecule-ai-workspace-runtime PyPI package from monorepo workspace/.
|
||
|
||
Monorepo workspace/ is the single source-of-truth for runtime code. The PyPI
|
||
package is a publish-time mirror produced by this script, NOT a parallel
|
||
editable copy. Anyone editing the runtime should edit workspace/, never the
|
||
sibling molecule-ai-workspace-runtime repo.
|
||
|
||
What this does
|
||
--------------
|
||
1. Copies workspace/ source into build/molecule_runtime/ (note the rename:
|
||
bare modules become a real Python package).
|
||
2. Rewrites top-level imports so e.g. `from a2a_client import X` becomes
|
||
`from molecule_runtime.a2a_client import X`. The rewrite is regex-based
|
||
on a closed allowlist of modules — third-party imports like `from a2a.X`
|
||
(the a2a-sdk package) are left alone because the regex is anchored on
|
||
exact module names.
|
||
3. Writes a pyproject.toml with the requested version + the README + the
|
||
py.typed marker.
|
||
4. Leaves the build dir ready for `python -m build` to produce a wheel/sdist.
|
||
|
||
Usage
|
||
-----
|
||
scripts/build_runtime_package.py --version 0.1.6 --out /tmp/runtime-build
|
||
cd /tmp/runtime-build && python -m build
|
||
python -m twine upload dist/*
|
||
|
||
The publish workflow (.github/workflows/publish-runtime.yml) drives this
|
||
on every `runtime-v*` tag push.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import re
|
||
import shutil
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
# Top-level Python modules in workspace/ that become molecule_runtime.X.
|
||
# Anything imported as `from <name> import` or `import <name>` (where <name>
|
||
# matches one of these) gets rewritten to use the package prefix.
|
||
#
|
||
# Closed list (not "every .py we copy") because a typo in workspace/ would
|
||
# otherwise leak into a wrong rewrite. The set is asserted against
|
||
# `workspace/*.py` at build time — if the disk contents drift from this
|
||
# list (new module added, old one removed), the build fails loud instead
|
||
# of silently shipping unrewritten imports. That gap caused 0.1.16 to
|
||
# ship `from transcript_auth import ...` (unrewritten — module added
|
||
# without updating this set), which broke every workspace startup with
|
||
# `ModuleNotFoundError: No module named 'transcript_auth'`.
|
||
TOP_LEVEL_MODULES = {
|
||
"a2a_cli",
|
||
"a2a_client",
|
||
"a2a_executor",
|
||
"a2a_mcp_server",
|
||
"a2a_tools",
|
||
"a2a_tools_delegation",
|
||
"a2a_tools_memory",
|
||
"a2a_tools_rbac",
|
||
"adapter_base",
|
||
"agent",
|
||
"agents_md",
|
||
"boot_routes",
|
||
"card_helpers",
|
||
"config",
|
||
"configs_dir",
|
||
"consolidation",
|
||
"coordinator",
|
||
"event_log",
|
||
"events",
|
||
"executor_helpers",
|
||
"heartbeat",
|
||
"inbox",
|
||
"inbox_uploads",
|
||
"initial_prompt",
|
||
"internal_chat_uploads",
|
||
"internal_file_read",
|
||
"main",
|
||
"mcp_cli",
|
||
"mcp_heartbeat",
|
||
"mcp_inbox_pollers",
|
||
"mcp_workspace_resolver",
|
||
"molecule_ai_status",
|
||
"not_configured_handler",
|
||
"platform_auth",
|
||
"platform_inbound_auth",
|
||
"plugins",
|
||
"preflight",
|
||
"prompt",
|
||
"runtime_wedge",
|
||
"secret_redactor",
|
||
"shared_runtime",
|
||
"smoke_mode",
|
||
"transcript_auth",
|
||
"watcher",
|
||
}
|
||
|
||
# Subdirectory packages — these are already real packages (they have or will
|
||
# have __init__.py) so the rewrite is `from <pkg>` → `from molecule_runtime.<pkg>`.
|
||
SUBPACKAGES = {
|
||
"adapters",
|
||
"builtin_tools",
|
||
"lib",
|
||
"platform_tools",
|
||
"plugins_registry",
|
||
"policies",
|
||
"skill_loader",
|
||
}
|
||
|
||
# Files in workspace/ NOT included in the published package. These are
|
||
# build artifacts, dev scripts, or monorepo-only scaffolding.
|
||
EXCLUDE_FILES = {
|
||
"Dockerfile",
|
||
"build-all.sh",
|
||
"rebuild-runtime-images.sh",
|
||
"entrypoint.sh",
|
||
"pytest.ini",
|
||
"requirements.txt",
|
||
# Note: adapter_base.py, agents_md.py, hermes_executor.py, shared_runtime.py
|
||
# are kept (referenced by adapters/__init__.py and other modules); they get
|
||
# their imports rewritten via TOP_LEVEL_MODULES. Excluding them broke the
|
||
# smoke-test install with `ModuleNotFoundError: adapter_base`.
|
||
}
|
||
|
||
EXCLUDE_DIRS = {
|
||
"__pycache__",
|
||
"tests",
|
||
"molecule_audit", # only used by tests; not on production import path
|
||
"scripts",
|
||
}
|
||
|
||
|
||
def build_import_rewriter() -> re.Pattern:
|
||
"""Compile a single regex matching all import statements that need
|
||
rewriting. The match groups capture the keyword + module name so the
|
||
replacement preserves whitespace and trailing punctuation.
|
||
|
||
Modules included: TOP_LEVEL_MODULES ∪ SUBPACKAGES.
|
||
|
||
The negative-lookahead on `\\.` in the suffix prevents matching
|
||
`from a2a.server.X import Y` against bare `a2a` (which isn't in our
|
||
set, but the principle matters for any future short module name that
|
||
happens to be a prefix of a real package name).
|
||
"""
|
||
names = sorted(TOP_LEVEL_MODULES | SUBPACKAGES)
|
||
alt = "|".join(re.escape(n) for n in names)
|
||
# Matches:
|
||
# from <name>(\.|\s|import)
|
||
# import <name>(\s|$|,)
|
||
# And captures the keyword + name so we can re-emit with prefix.
|
||
pattern = (
|
||
r"(?m)^(?P<indent>\s*)" # leading whitespace (preserved)
|
||
r"(?P<kw>from|import)\s+" # 'from' or 'import'
|
||
r"(?P<mod>" + alt + r")" # the module name
|
||
r"(?P<rest>[\s.,]|$)" # what follows: '.subpath', ' import …', ',', whitespace, EOL
|
||
)
|
||
return re.compile(pattern)
|
||
|
||
|
||
def rewrite_imports(text: str, regex: re.Pattern) -> str:
|
||
"""Replace bare imports with package-prefixed ones.
|
||
|
||
`import X` → `import molecule_runtime.X as X` (preserve binding)
|
||
`from X import Y` → `from molecule_runtime.X import Y`
|
||
`from X.sub import Y` → `from molecule_runtime.X.sub import Y`
|
||
|
||
Rejects `import X as Y` because the rewrite would produce
|
||
`import molecule_runtime.X as X as Y`, a syntax error. The PR #2433
|
||
incident shipped this exact pattern past `Python Lint & Test` (which
|
||
runs against pre-rewrite source) but blew up the wheel-smoke gate.
|
||
Detecting it here turns the silent build failure into a build-time
|
||
error with a clear path: use `from X import …` or plain `import X`.
|
||
"""
|
||
def repl(m: re.Match) -> str:
|
||
indent, kw, mod, rest = m.group("indent"), m.group("kw"), m.group("mod"), m.group("rest")
|
||
if kw == "from":
|
||
# `from X` or `from X.sub` — always safe to prefix.
|
||
return f"{indent}from molecule_runtime.{mod}{rest}"
|
||
# `import X` — preserve the binding name `X` (callers do `X.foo`)
|
||
# by aliasing. `import X.sub` is uncommon for our modules and would
|
||
# need a different binding form, but isn't used in workspace/ today.
|
||
if rest.startswith("."):
|
||
# `import X.sub` — rewrite as `import molecule_runtime.X.sub` and
|
||
# leave the trailing dot pattern intact for the rest of the line.
|
||
return f"{indent}import molecule_runtime.{mod}{rest}"
|
||
# Detect `import X as Y` — the regex's `rest` group captures only
|
||
# the immediate following char (whitespace, comma, or EOL), so we
|
||
# have to peek at the surrounding line context. The match start is
|
||
# at the line's `import` keyword; everything after the matched
|
||
# name on the same line is what the source author wrote.
|
||
line_start = text.rfind("\n", 0, m.start()) + 1
|
||
line_end = text.find("\n", m.end())
|
||
if line_end == -1:
|
||
line_end = len(text)
|
||
line_after = text[m.end() - len(rest):line_end]
|
||
# Strip comments from consideration so `import X # noqa` doesn't trip.
|
||
line_after_no_comment = line_after.split("#", 1)[0]
|
||
if re.search(r"^\s*as\s+\w+", line_after_no_comment):
|
||
raise ValueError(
|
||
f"rewrite_imports: cannot rewrite 'import {mod} as <alias>' on a "
|
||
f"workspace module — the regex would produce "
|
||
f"'import molecule_runtime.{mod} as {mod} as <alias>', invalid syntax. "
|
||
f"Use 'from {mod} import …' or plain 'import {mod}' instead. "
|
||
f"Offending line: {text[line_start:line_end]!r}"
|
||
)
|
||
# Plain `import X` — alias preserves the local name.
|
||
return f"{indent}import molecule_runtime.{mod} as {mod}{rest}"
|
||
return regex.sub(repl, text)
|
||
|
||
|
||
def copy_tree_filtered(src: Path, dst: Path) -> list[Path]:
|
||
"""Copy src/ → dst/ skipping EXCLUDE_FILES + EXCLUDE_DIRS. Returns the
|
||
list of .py files copied so the caller can run the import rewrite over
|
||
them in one pass."""
|
||
py_files: list[Path] = []
|
||
if dst.exists():
|
||
shutil.rmtree(dst)
|
||
dst.mkdir(parents=True)
|
||
for entry in src.iterdir():
|
||
if entry.is_dir():
|
||
if entry.name in EXCLUDE_DIRS:
|
||
continue
|
||
sub_py = copy_tree_filtered(entry, dst / entry.name)
|
||
py_files.extend(sub_py)
|
||
else:
|
||
if entry.name in EXCLUDE_FILES:
|
||
continue
|
||
shutil.copy2(entry, dst / entry.name)
|
||
if entry.suffix == ".py":
|
||
py_files.append(dst / entry.name)
|
||
return py_files
|
||
|
||
|
||
PYPROJECT_TEMPLATE = """\
|
||
[build-system]
|
||
requires = ["setuptools>=68.0", "wheel"]
|
||
build-backend = "setuptools.build_meta"
|
||
|
||
[project]
|
||
name = "molecule-ai-workspace-runtime"
|
||
version = "{version}"
|
||
description = "Molecule AI workspace runtime — shared infrastructure for all agent adapters"
|
||
requires-python = ">=3.11"
|
||
license = {{text = "BSL-1.1"}}
|
||
readme = "README.md"
|
||
dependencies = [
|
||
"a2a-sdk[http-server]>=1.0.0,<2.0",
|
||
"httpx>=0.27.0",
|
||
"uvicorn>=0.30.0",
|
||
"starlette>=0.38.0",
|
||
"websockets>=12.0",
|
||
"pyyaml>=6.0",
|
||
"langchain-core>=0.3.0",
|
||
"opentelemetry-api>=1.24.0",
|
||
"opentelemetry-sdk>=1.24.0",
|
||
"opentelemetry-exporter-otlp-proto-http>=1.24.0",
|
||
"temporalio>=1.7.0",
|
||
]
|
||
|
||
[project.scripts]
|
||
molecule-runtime = "molecule_runtime.main:main_sync"
|
||
molecule-mcp = "molecule_runtime.mcp_cli:main"
|
||
|
||
[tool.setuptools.packages.find]
|
||
where = ["."]
|
||
include = ["molecule_runtime*"]
|
||
|
||
[tool.setuptools.package-data]
|
||
"molecule_runtime" = ["py.typed"]
|
||
"""
|
||
|
||
|
||
README_TEMPLATE = """\
|
||
# molecule-ai-workspace-runtime
|
||
|
||
Shared workspace runtime for [Molecule AI](https://github.com/Molecule-AI/molecule-core)
|
||
agent adapters. Installed by every workspace template image
|
||
(`workspace-template-claude-code`, `-langgraph`, `-hermes`, etc.) to provide
|
||
A2A delegation, heartbeat, memory, plugin loading, and skill management.
|
||
|
||
This package is **published from the molecule-core monorepo `workspace/`
|
||
directory** by the `publish-runtime` GitHub Actions workflow on every
|
||
`runtime-v*` tag push. **Do not edit this package directly** — edit
|
||
`workspace/` in the monorepo.
|
||
|
||
## External-runtime MCP server (`molecule-mcp`)
|
||
|
||
Operators running an agent outside the platform's container fleet
|
||
(any runtime that supports MCP stdio — Claude Code, hermes, codex,
|
||
etc.) can install this wheel and run the universal MCP server
|
||
locally.
|
||
|
||
### Requirements
|
||
|
||
* **Python ≥3.11.** The wheel sets `requires-python = ">=3.11"`. On
|
||
older interpreters `pip install` returns the cryptic
|
||
`Could not find a version that satisfies the requirement` — that
|
||
message is pip filtering this wheel out, NOT the package missing
|
||
from PyPI. Upgrade with `brew install python@3.12` /
|
||
`apt install python3.12` / `pyenv install 3.12` first.
|
||
* **`pipx` recommended over `pip`.** `pipx install` puts
|
||
`molecule-mcp` on PATH automatically and isolates the runtime's
|
||
deps from your system Python. Plain `pip install --user` works
|
||
but the binary lands in `~/.local/bin` (Linux) or
|
||
`~/Library/Python/3.X/bin` (macOS) which is often not on PATH on
|
||
a fresh shell — `claude mcp add molecule -- molecule-mcp` then
|
||
fails with "command not found" at first use.
|
||
|
||
### Install
|
||
|
||
```sh
|
||
# Recommended:
|
||
pipx install molecule-ai-workspace-runtime
|
||
|
||
# Alternative (manage PATH yourself):
|
||
pip install --user molecule-ai-workspace-runtime
|
||
```
|
||
|
||
### Run
|
||
|
||
```sh
|
||
WORKSPACE_ID=<uuid> \\
|
||
PLATFORM_URL=https://<tenant>.staging.moleculesai.app \\
|
||
MOLECULE_WORKSPACE_TOKEN=<bearer> \\
|
||
molecule-mcp
|
||
```
|
||
|
||
That exposes the same 8 platform tools (`delegate_task`, `list_peers`,
|
||
`send_message_to_user`, `commit_memory`, etc.) that container-bound
|
||
runtimes already get via the workspace's auto-spawned MCP. Register
|
||
the binary in your agent's MCP config (e.g. Claude Code's
|
||
`claude mcp add molecule -- molecule-mcp` with the env above).
|
||
|
||
### Keeping the token out of shell history
|
||
|
||
Inline `MOLECULE_WORKSPACE_TOKEN=<bearer>` ends up in `~/.zsh_history`
|
||
and (when registered via `claude mcp add`) plaintext in
|
||
`~/.claude.json`. To avoid that, write the token to a 0600 file and
|
||
point `MOLECULE_WORKSPACE_TOKEN_FILE` at it:
|
||
|
||
```sh
|
||
umask 077
|
||
printf '%s' "<bearer>" > ~/.config/molecule/token
|
||
WORKSPACE_ID=<uuid> \\
|
||
PLATFORM_URL=https://<tenant>.staging.moleculesai.app \\
|
||
MOLECULE_WORKSPACE_TOKEN_FILE=$HOME/.config/molecule/token \\
|
||
molecule-mcp
|
||
```
|
||
|
||
Token resolution order: `MOLECULE_WORKSPACE_TOKEN` (inline env) →
|
||
`MOLECULE_WORKSPACE_TOKEN_FILE` (path) → `${CONFIGS_DIR}/.auth_token`
|
||
(in-container default).
|
||
|
||
The token comes from the canvas → Tokens tab. Restarting an external
|
||
workspace from the canvas no longer revokes the token (PR #2412), so
|
||
operator tokens persist across status nudges.
|
||
|
||
### Push vs poll delivery (Claude Code specifics)
|
||
|
||
By default the inbox runs in **poll mode** — every turn the agent
|
||
calls `wait_for_message`, which blocks up to ~60s on
|
||
`/activity?since_id=…`. Real-time push delivery is also supported,
|
||
but on Claude Code it requires THREE conditions, ALL of which must
|
||
hold:
|
||
|
||
1. **The MCP server declares `experimental.claude/channel`** — this
|
||
wheel does (see `_build_initialize_result`). Nothing for you to
|
||
do.
|
||
2. **Claude Code installs the server as a marketplace plugin** — a
|
||
plain `claude mcp add molecule -- molecule-mcp` produces a
|
||
non-plugin-sourced server, which Claude Code rejects with
|
||
`channel_enable requires a marketplace plugin`. Until the
|
||
official `moleculesai/claude-code-plugin` marketplace lands
|
||
(issue #2934 follow-up), operators who want push must scaffold
|
||
their own local marketplace under
|
||
`~/.claude/marketplaces/molecule-local/` containing a
|
||
`marketplace.json` + `plugin.json` that points at this wheel.
|
||
3. **Claude Code is launched with the dev-channels flag** — pass
|
||
`--dangerously-load-development-channels plugin:molecule@<marketplace>`
|
||
on the `claude` invocation. Without this flag the channel
|
||
capability is silently ignored.
|
||
|
||
Symptom of any condition failing: messages arrive but only via the
|
||
poll path (every ~1–60s), not real-time. There's currently no
|
||
diagnostic surfaced — `molecule-mcp doctor` (issue #2934 follow-up)
|
||
is planned.
|
||
|
||
If you don't need real-time push, the default poll path works
|
||
universally with no extra setup; both modes converge on the same
|
||
`inbox_pop` ack so messages never duplicate.
|
||
|
||
See [`docs/workspace-runtime-package.md`](https://github.com/Molecule-AI/molecule-core/blob/main/docs/workspace-runtime-package.md)
|
||
for the publish flow and architecture.
|
||
"""
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--version", required=True, help="Package version, e.g. 0.1.6")
|
||
parser.add_argument("--out", required=True, type=Path, help="Build output directory (will be wiped)")
|
||
parser.add_argument("--source", type=Path, default=Path(__file__).resolve().parent.parent / "workspace",
|
||
help="Path to monorepo workspace/ directory (default: ../workspace from this script)")
|
||
args = parser.parse_args()
|
||
|
||
src = args.source.resolve()
|
||
out = args.out.resolve()
|
||
if not src.is_dir():
|
||
print(f"error: source not a directory: {src}", file=sys.stderr)
|
||
return 2
|
||
|
||
# Drift gate: assert TOP_LEVEL_MODULES matches workspace/*.py.
|
||
# Without this, a new top-level module added to workspace/ ships
|
||
# with unrewritten `from <name> import` statements that explode at
|
||
# runtime with ModuleNotFoundError. (See 0.1.16 transcript_auth
|
||
# incident — closed list silently went stale.)
|
||
on_disk_modules = {
|
||
f.stem for f in src.glob("*.py")
|
||
if f.stem not in {"__init__", "conftest"}
|
||
}
|
||
missing = on_disk_modules - TOP_LEVEL_MODULES
|
||
stale = TOP_LEVEL_MODULES - on_disk_modules
|
||
if missing or stale:
|
||
print("error: TOP_LEVEL_MODULES drifted from workspace/*.py contents:", file=sys.stderr)
|
||
if missing:
|
||
print(f" in workspace/ but NOT in TOP_LEVEL_MODULES (will ship un-rewritten): {sorted(missing)}", file=sys.stderr)
|
||
if stale:
|
||
print(f" in TOP_LEVEL_MODULES but NOT in workspace/ (no-op, but misleading): {sorted(stale)}", file=sys.stderr)
|
||
print(" Edit scripts/build_runtime_package.py:TOP_LEVEL_MODULES to match.", file=sys.stderr)
|
||
return 3
|
||
|
||
# Same drift gate for SUBPACKAGES — catches the inverse class of
|
||
# bug where a workspace/ subdirectory is referenced by main.py
|
||
# (`from lib.pre_stop import ...`) but is either missing from
|
||
# SUBPACKAGES (so the rewriter doesn't qualify the import) or
|
||
# accidentally listed in EXCLUDE_DIRS (so the directory itself
|
||
# isn't shipped). 0.1.16-0.1.19 had `lib` in EXCLUDE_DIRS while
|
||
# main.py imported from it — `ModuleNotFoundError: No module
|
||
# named 'lib'` at every workspace startup.
|
||
on_disk_subpkgs = {
|
||
d.name for d in src.iterdir()
|
||
if d.is_dir()
|
||
and d.name not in EXCLUDE_DIRS
|
||
and d.name not in {"__pycache__"}
|
||
and (d / "__init__.py").exists()
|
||
}
|
||
sub_missing = on_disk_subpkgs - SUBPACKAGES
|
||
sub_stale = SUBPACKAGES - on_disk_subpkgs
|
||
if sub_missing or sub_stale:
|
||
print("error: SUBPACKAGES drifted from workspace/ subdirectories:", file=sys.stderr)
|
||
if sub_missing:
|
||
print(f" in workspace/ but NOT in SUBPACKAGES (will ship un-rewritten or be excluded): {sorted(sub_missing)}", file=sys.stderr)
|
||
if sub_stale:
|
||
print(f" in SUBPACKAGES but NOT in workspace/ (no-op, but misleading): {sorted(sub_stale)}", file=sys.stderr)
|
||
print(" Edit scripts/build_runtime_package.py:SUBPACKAGES + EXCLUDE_DIRS to match.", file=sys.stderr)
|
||
return 3
|
||
|
||
pkg_dir = out / "molecule_runtime"
|
||
print(f"[build] source: {src}")
|
||
print(f"[build] output: {out}")
|
||
print(f"[build] package: {pkg_dir}")
|
||
|
||
if out.exists():
|
||
shutil.rmtree(out)
|
||
out.mkdir(parents=True)
|
||
|
||
py_files = copy_tree_filtered(src, pkg_dir)
|
||
print(f"[build] copied {len(py_files)} .py files")
|
||
|
||
# Ensure top-level package marker exists. workspace/ doesn't have one
|
||
# (it's not a package in monorepo), but the published artifact must.
|
||
init = pkg_dir / "__init__.py"
|
||
if not init.exists():
|
||
init.write_text('"""Molecule AI workspace runtime."""\n')
|
||
|
||
# Touch py.typed so type-checkers in adapter consumers see the package
|
||
# as typed. Empty file is the convention.
|
||
(pkg_dir / "py.typed").touch()
|
||
|
||
# Rewrite imports in every .py file we copied + the new __init__.py.
|
||
regex = build_import_rewriter()
|
||
rewrites = 0
|
||
for f in [*py_files, init]:
|
||
original = f.read_text()
|
||
rewritten = rewrite_imports(original, regex)
|
||
if rewritten != original:
|
||
f.write_text(rewritten)
|
||
rewrites += 1
|
||
print(f"[build] rewrote imports in {rewrites} files")
|
||
|
||
# Emit pyproject.toml + README at build root.
|
||
(out / "pyproject.toml").write_text(PYPROJECT_TEMPLATE.format(version=args.version))
|
||
(out / "README.md").write_text(README_TEMPLATE)
|
||
|
||
print(f"[build] done. To publish:")
|
||
print(f" cd {out}")
|
||
print(f" python -m build")
|
||
print(f" python -m twine upload dist/*")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|