diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e4c6c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +!py/build/ +!py/build/** +py/build/**/__pycache__/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d697079 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# AGENTS.md + +## Repo Overview + +- Module: `dappco.re/go/py` +- Purpose: Python binding for Core primitives, with a Go-backed Tier 1 bootstrap runtime and a CPython package surface under `py/core/`. +- Main surfaces: `bindings/`, `runtime/`, `py/core/`, `py/tests/`, and `examples/`. + +## First Reads + +- Read `README.md` first for the current layout, supported modules, and validation commands. +- Read `CLAUDE.md` for the architecture split between Tier 1 and Tier 2. +- Read `runtime/interpreter.go` before changing import behaviour, module registration, or the Tier 1 execution contract. +- Read `py/tests/test_core.py` before changing observable Python behaviour, since it documents the current public surface well. +- The README references an RFC outside this repo; if that spec is unavailable, trust the current implementation and tests. + +## Working Rules + +- Keep changes narrow and directly related to the task. +- Preserve parity between the Go bindings/runtime contract and the Python package surface when changing primitive names, signatures, or import paths. +- Prefer current code patterns over broad refactors or speculative abstractions. +- Update nearby tests and docs when behaviour changes. +- Avoid adding new Go or Python dependencies unless they are clearly required. +- Preserve any user changes already present in the worktree. +- Do not commit or rewrite history unless the user asks. + +## Project Layout + +- `bindings/`: Go-backed primitive bindings such as `config`, `data`, `echo`, `err`, `fs`, `json`, `log`, `math`, `medium`, `options`, `path`, `process`, `service`, and `strings`. +- `bindings/typemap/`: Go-to-Python value conversion helpers used by the binding layer. +- `runtime/`: Tier 1 bootstrap interpreter used to validate module registration, import shape, and simple execution. +- `py/core/`: Python package surface for `core.*`, including docstrings and CPython fallbacks. +- `py/core/math/`: Math submodules that must remain importable as `core.math.kdtree`, `core.math.knn`, and `core.math.signal`. +- `py/tests/`: CPython validation for the package surface. +- `examples/`: Example CorePy programs. + +## Coding Conventions + +- Match the style of the files you touch; keep Go and Python code straightforward and local. +- Keep public import paths stable: Python users import `core` and `core.*`. +- Maintain the existing module shape where both object-oriented and module-level helpers are exposed, such as in `config`, `data`, `medium`, `options`, and `service`. +- Prefer example-driven doc comments and docstrings where the surrounding file already uses them. +- Keep the Python package typed and consistent with the `pyproject.toml` target of Python 3.12+. +- Avoid unrelated renames, formatting-only churn, and comment rewrites. +- Leave the local `replace dappco.re/go/core => ../go` setup in `go.mod` alone unless the task explicitly requires changing dependency wiring. + +## Testing + +- Start with the narrowest relevant test surface, then widen scope. +- Run `GOWORK=off go test ./...` after Go-side changes in `bindings/` or `runtime/`. +- Run `PYTHONPATH=py python3 -m unittest discover -s py/tests -v` after Python package changes. +- Run both validation commands when changing cross-surface behaviour or public module contracts. +- Use `py/tests/test_core.py` as the reference for expected package parity and import behaviour. + +## Notes for Agents + +- This repo is intentionally bootstrap-oriented: the runtime validates the binding contract before the full embedded Python story lands. +- When docs and code disagree, trust the current implementation and tests, then update the docs if needed. +- If you discover a stable repo convention while working, update this file so future agents inherit it. diff --git a/README.md b/README.md index efa129d..7ff6d8c 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,61 @@ # core/py — Python Binding for Core Primitives -The fourth corner of the polyglot primitive stack. Python code imports `core` the -way Go code imports `core/go`. Same primitives, same shape, same tests, different -syntax surface. - -Two tiers: - -- **Tier 1 (gpython-embedded):** ships inside any CoreGO binary that imports - `dappco.re/go/py`. Pure-Go Python interpreter, no host CPython required. -- **Tier 2 (CPython-via-uv):** managed CPython subprocess for code that needs - C extensions or 3.14 features beyond gpython's coverage. +The fourth corner of the polyglot primitive stack. Python code imports `core` +the way Go code imports `core/go`: same primitive names, same import paths, +different syntax surface. + +## Current Implementation + +- `runtime/` contains a bootstrap Tier 1 interpreter that validates the CorePy + module contract, import shape, and Python-style list/dict type mapping + without waiting on the gpython dependency. +- `runtime/tier2/` contains the CPython subprocess runner used for the Tier 2 + escape hatch, with timeout, stdout/stderr streaming, and structured exit + results. +- Tier 1 registers small stdlib-shaped shadows for common imports + (`json`, `os`, `os.path`, `subprocess`, `logging`, `hashlib`, `base64`, and + `socket`) so gpython scripts can use familiar import names while still + calling Core-backed primitives. +- `corepy run -tier2-fallback` retries scripts in Tier 2 CPython when Tier 1 + reports an unsupported import, preserving the explicit two-tier boundary. +- `corepy repl` opens a stateful Tier 1 line session for quick primitive + experiments without leaving the CorePy runtime. +- `bindings/` contains Go-backed bindings for the RFC v1 module surface: + `core.echo`, `core.fs`, `core.json`, `core.medium`, `core.options`, + `core.path`, `core.process`, `core.config`, `core.data`, `core.service`, + `core.log`, `core.err`, `core.strings`, `core.array`, `core.registry`, + `core.info`, `core.entitlement`, `core.action`, `core.task`, `core.i18n`, + the first `core.math` surface, plus initial RFC coverage for `core.cache`, + `core.crypto`, `core.dns`, and `core.scm`, and importable planned stubs for + `core.api`, `core.ws`, `core.store`, `core.container`, `core.agent`, and + `core.mcp` + (`mean`, `median`, `variance`, `stdev`, sorting, scaling, signal helpers, + and the `core.math.kdtree` / `core.math.knn` / `core.math.signal` + import paths). +- `py/core/` contains the Python package surface for the RFC v1 modules, + including docstrings, concrete fallbacks for CPython validation, and + module-level helpers that mirror the Tier 1 binding shape. + +## Validation + +```bash +GOWORK=off go mod tidy +GOWORK=off go vet ./... +GOWORK=off go test ./... +GOWORK=off go test -tags gpython ./... +python3 -m unittest discover -s py/tests -v +``` ## Layout | Path | Purpose | |------|---------| -| `bindings/` | Go-side primitive bindings (fs, json, medium, options, process, service, math, typemap) | -| `runtime/` | gpython host integration | -| `py/core/` | Python-side package (installable via uv) | -| `py/tests/` | Python test suite | -| `examples/` | Polyglot example programs | +| `bindings/` | Go-side primitive bindings and type conversion helpers | +| `runtime/` | Tier 1 bootstrap interpreter and integration tests | +| `py/core/` | Python package surface for `core.*` modules | +| `py/tests/` | Python package validation | +| `examples/` | Example CorePy programs | ## Spec -`plans/code/core/py/RFC.md` in the spec tree — read first. - -## Status - -Bootstrap. Empty skeleton ready for factory dispatches. +`/home/claude/Code/core/plans/code/core/py/RFC.md` diff --git a/RFC.md b/RFC.md new file mode 100644 index 0000000..2d61af2 --- /dev/null +++ b/RFC.md @@ -0,0 +1,488 @@ +--- +module: core/py +repo: core/py +lang: python +tier: consumer +depends: + - code/core/go + - code/rfc/core/RFC-CORE-008-AGENT-EXPERIENCE +tags: + - python + - polyglot + - gpython + - core-primitive + - embedded-interpreter + - uv + - poindexter +--- + +# core/py RFC — Python Binding for Core Primitives + +> The fourth corner of the polyglot primitive stack. Python code imports `core` +> the way Go code imports `core/go`. Same primitives, same shape, same tests, +> different syntax surface. Under the hood, Python runs in **gpython** (a pure-Go +> Python interpreter) embedded inside CoreGO — one binary, no host Python +> interpreter required. + +**Python package:** `core` (distributed as `dappco.re/py/core` source tree) +**Go host:** `dappco.re/go/py` (embeds gpython + exposes primitives as Python modules) +**Upstream gpython:** `github.com/go-python/gpython` → forked to `LetheanNetwork/gpython` (dev branch) +**Repo:** `core/py` (polyglot — Go host + Python userland) + +--- + +## 1. Summary + +CorePy is the Python binding layer of the Core primitive stack, matching +CoreGO (Go), CorePHP (PHP), and CoreTS (TypeScript) as the fourth +language-native surface over the same underlying primitives. + +The architectural innovation is that CorePy does **not** embed CPython. +Instead, it embeds **gpython** — a pure-Go implementation of the Python +interpreter maintained by the `go-python` organisation. This means: + +- **Single binary distribution** — CoreGO with CorePy embedded is one + executable. No host Python interpreter, no pip, no venv, no dependency + hell, no `python3` vs `python3.12` confusion. +- **Goroutine-native concurrency** — Python code running in gpython can + participate in Go's concurrency model directly. No GIL to fight. +- **CoreGO primitives exposed as Python modules** — `from core import fs` + calls `c.Fs()`, `from core import json` calls `core.JSONMarshal`. + Python import paths mirror Go package paths (AX principle 3: *path is + documentation*). Core's battle-tested Go implementations back every + Python import. +- **Symmetry with CorePHP** — CorePHP embeds PHP inside CoreGO. CorePy + embeds Python inside CoreGO. Same pattern, same distribution story, + same architecture. + +CPython's C-extension stdlib modules (`os`, `io`, `json`, `re`, `socket`, +`hashlib`, `subprocess`, `threading`, etc.) are not available in gpython +because they are C, not Python. **This is not a limitation for CorePy +because CoreGO already has Go-native equivalents for all of them, +exposed to Python code via the import binding layer.** + +Heavy numerical / ML work (numpy, torch, mlx, transformers) is out of scope +for Tier 1 and runs in a separate host CPython process managed by +`core.Process`. See §4 (Two-Tier Python). + +--- + +## 2. Goals + +1. **First-class Python developer experience** — Python devs write + regular-looking Python against `core.*` primitives and the code feels + idiomatic. No cgo wrappers visible, no ctypes, no "this is not real + Python". + +2. **Single-binary distribution** — every Core service that embeds CorePy + can ship Python tooling without the host machine having Python, + pip, venvs, or any interpreter-management machinery. Critical for + Lethean Network edge workers on mixed community hardware. + +3. **Zero primitive drift** — a bug fix to `core.Fs()` in Go automatically + propagates to CorePy users because there is only one implementation + (Go); CorePy is a thin binding layer, not a reimplementation. + +4. **Python version target: 3.14** — modern Python syntax and features + (pattern matching, `|` union types, walrus operator, `typing.Protocol`, + full dataclasses, async/await) must be supported. gpython as upstream + is Python 3.4-ish; upgrading gpython's syntax and runtime coverage to + 3.14 compat is **in-scope** for core/py and will be maintained at + `LetheanNetwork/gpython`. + +5. **Two-tier Python split** — Tier 1 (gpython inside CoreGO) for + application-layer code, config, data transformation, service glue, + and mathematical work backed by Poindexter. Tier 2 (host CPython via + `core.Process` subprocess) for heavy ML ecosystem (torch, mlx, + transformers, training). Both tiers use `import core` for primitive + bindings; Tier 1 gets them from gpython's extension mechanism, Tier 2 + gets them via a CPython extension module generated with `gopy`. + +--- + +## 3. Non-Goals + +- **Not a CPython replacement.** CorePy does not try to run arbitrary + third-party Python packages from PyPI that depend on C extensions. + numpy, torch, mlx, transformers, pandas, scipy — these run in Tier 2 + CPython, not Tier 1 gpython. +- **Not a port of CPython's C-extension stdlib.** `os`, `io`, `json`, + `socket`, `hashlib`, `subprocess`, etc. are not reimplemented in pure + Python. They are replaced by CoreGO primitives exposed as Python + modules. +- **Not a single monolithic `core` module.** Each primitive binds as a + distinct submodule: `core.fs`, `core.json`, `core.process`, `core.medium`, + `core.service`, `core.options`, `core.config`, `core.data`, etc. + Matches the structure of `core/go` packages exactly. +- **Not an ML framework.** CorePy is infrastructure, not inference. + Lemma/LEM tooling uses CorePy for its Core-side plumbing and calls + Tier 2 CPython subprocess for the actual model work. + +--- + +## 4. Two-Tier Python Architecture + +``` +┌──────────────────────────────────────────────────────────────┐ +│ CoreGO (Lethean core) │ +│ Options, Config, Data, Service, Medium, Process, Fs, │ +│ JSON, Net, DB, WebSocket, MCP, Agent, ... + Poindexter │ +└──────────┬───────────────────────────────┬───────────────────┘ + │ embed │ subprocess + ↓ ↓ + ┌────────────────────┐ ┌────────────────────────┐ + │ Tier 1 (CorePy) │ │ Tier 2 (host CPython) │ + │ │ │ │ + │ gpython │ │ uv-managed CPython │ + │ + core.fs │ │ + numpy, torch, mlx │ + │ + core.json │ │ + transformers, peft │ + │ + core.medium │ │ + LEM training │ + │ + core.process │ │ + heavy ML eval │ + │ + core.service │ │ │ + │ + core.math │ │ Optionally calls back │ + │ (Poindexter) │ │ into CoreGO via gopy │ + │ │ │ for primitive access. │ + │ Single binary. │ │ │ + │ No host Python. │ │ Needs CPython host. │ + │ Pure Go substrate.│ │ Managed by core.Process│ + └────────────────────┘ └────────────────────────┘ +``` + +**Tier 1** handles 80% of Lethean's Python-use cases: config loading, +data transformation, service definitions, KNN over embeddings +(Poindexter), stats, signal processing, Medium-backed I/O, HTTP +services, scripts that Core services invoke at runtime. Zero host +dependencies beyond a CoreGO binary. + +**Tier 2** handles the remaining 20%: anything that imports numpy, +torch, mlx, or transformers. Runs as a subprocess managed by +`core.Process`, in a uv-managed venv, with output streamed back +through `core.Medium`. LEM training, MLX inference, HF transformers, +benchmarking harnesses all live in Tier 2. + +The boundary is clean because Tier 2 dependencies are obvious at the +`import` level — if your script `import torch`, it's Tier 2; if it +doesn't, it's likely Tier 1. + +--- + +## 5. Primitive Bindings + +Each Core primitive is exposed as a Python submodule under the `core` +package. Python import paths mirror Go package paths (`code/core/go/fs` +→ `core.fs`, `code/core/go/process` → `core.process`, etc.). All +bindings are implemented in Go via gpython's extension mechanism +(§7.1) and backed by the canonical CoreGO implementations. + +### 5.1 Primitive Coverage Map + +| Python module | Go source | CPython equivalent | Purpose | +|---|---|---|---| +| `core.options` | `core/go` Options | — | Typed-option primitive (With*, Must*, For[T] replacement) | +| `core.config` | `core/go/config` | `configparser`, `os.environ` | .core/ config backing, env integration | +| `core.data` | `core/go` Data | — | Fractal DTO primitive | +| `core.service` | `core/go` Service | — | Service lifecycle / handler protocol | +| `core.medium` | `core/go/io` Medium | `io`, `fsspec` | Universal transport (local/S3/cube/memory) | +| `core.fs` | `core/go/fs` | `os`, `os.path`, `pathlib` | Filesystem primitives | +| `core.process` | `core/go/process` | `subprocess`, `os.exec` | Process management (mockable) | +| `core.json` | `core/go` JSONMarshal/Unmarshal | `json` | JSON encode/decode | +| `core.strings` | `core/go` string ops | `str` methods, `re` | String matching, trimming, contains | +| `core.path` | `core/go` JoinPath/PathBase | `os.path`, `pathlib` | Path manipulation | +| `core.log` | `core/go` Print/Error | `logging` | Structured logging | +| `core.err` | `core/go` E() | Exception hierarchy | Scoped error construction | +| `core.api` | `core/api` Gin host | `http.server`, `flask`, `fastapi` | REST server + client | +| `core.ws` | `core/go/ws` | `websockets` | WebSocket primitives | +| `core.store` | `core/go/store` | `sqlite3`, `shelve` | SQLite KV + DuckDB workspace | +| `core.dns` | `go-dns` | `dns.resolver` | DNS resolution | +| `core.cache` | `go-cache` | `functools.lru_cache`, `cachetools` | Caching primitives | +| `core.container` | `go-container` | — | Container orchestration | +| `core.scm` | `go-scm` | `git` (via subprocess) | Git operations | +| `core.math` | `Poindexter` | `numpy` (subset), `scipy.stats` | Sort, search, KDTree, KNN, stats, signal | +| `core.crypto` | `snider/Borg` | `hashlib`, `hmac`, `ssl` | Hashing, HMAC, signing, encryption | +| `core.agent` | `core/agent` | — | Agent dispatch + fleet primitives | +| `core.mcp` | `core/mcp` | — | MCP tool protocol | + +### 5.2 Binding Conventions + +Each binding follows the same shape: + +```go +// core/py/bindings/fs/fs.go +package fs + +import ( + "github.com/go-python/gpython/py" + corefs "dappco.re/go/fs" +) + +func init() { + py.RegisterModule(&py.ModuleImpl{ + Info: py.ModuleInfo{ + Name: "core.fs", + Doc: "Filesystem primitives backed by core/go/fs", + }, + Methods: []*py.Method{ + {Name: "read_file", Method: readFile, Flags: py.METH_VARARGS}, + {Name: "write_file", Method: writeFile, Flags: py.METH_VARARGS}, + // ... + }, + }) +} + +func readFile(self py.Object, args py.Tuple) (py.Object, error) { + var path py.String + if err := py.ParseTuple(args, "s", &path); err != nil { + return nil, err + } + data, err := corefs.ReadFile(string(path)) // <-- CoreGO call + if err != nil { + return nil, py.ExceptionNewf(py.OSError, "%s", err.Error()) + } + return py.Bytes(data), nil +} +``` + +Pattern: +1. `init()` registers a Python module with gpython +2. Each Python-callable function parses its args, calls the + corresponding CoreGO function, converts the result to a Python + object, and returns it +3. CoreGO errors map to Python exceptions via `py.ExceptionNewf` +4. Python types map to Go types via gpython's type conversion layer + +Binding coverage target for v1: Options, Config, Data, Service, +Medium, Fs, Process, JSON, log, err. Everything else follows the same +pattern and gets added incrementally. + +--- + +## 6. Math via Poindexter + +Poindexter (github.com/Snider/Poindexter) provides pure-Go +implementations of the mathematical primitives CorePy needs at Tier 1: + +- **Sorting** (ints, floats, strings, custom comparators) — replaces + `sorted()`, `list.sort` +- **Binary search** — replaces `bisect` +- **KDTree** (Euclidean, Manhattan, Chebyshev, Cosine metrics) — + replaces `scipy.spatial.KDTree`, `sklearn.neighbors.NearestNeighbors` +- **Generic KNN** — replaces typical embedding-search loops +- **Statistics** — means, medians, variance, distributions +- **Signal processing** — basic filters, transforms +- **Epsilon comparisons** — stable float equality checks +- **Scale operations** — normalisation, rescaling + +`core.math` exposes these as a Python module. Example: + +```python +from core.math import kdtree, knn, mean, stdev + +# Build a KDTree over 1M embeddings +tree = kdtree.build(embeddings, metric="cosine") + +# Find 10 nearest neighbours +neighbours = tree.nearest(query_embedding, k=10) + +# Descriptive stats +m = mean(scores) +s = stdev(scores) +``` + +All operations run in pure Go inside gpython. No numpy, no scipy, no +CPython required. For a 2B-parameter model doing embedding search over +its own KV cache or a RAG corpus, this is the entire math surface you +need. + +**Out of scope for Poindexter + CorePy:** dense linear algebra (BLAS +operations, matrix multiplication, SVD, eigendecomposition), FFT, +differential equation solvers, autograd. Anything that wants `A @ B` +on large tensors runs in Tier 2 (numpy/torch/mlx). + +--- + +## 7. gpython Fork — Upgrading to Python 3.14 + +Upstream gpython targets Python 3.4-ish. That is not acceptable for +CorePy. Modern Python features that must work: + +- **Pattern matching** (`match`/`case`, Python 3.10+) +- **Union syntax** (`int | str`, Python 3.10+) +- **Walrus operator** (`:=`, Python 3.8+) +- **f-string improvements** (3.12+) +- **Type hints** (full `typing` module: `Protocol`, `TypedDict`, `Generic`, etc.) +- **Dataclasses** (`@dataclass`, Python 3.7+) +- **Async/await** (full coroutine support, 3.5+) +- **`asyncio` semantics** mapped to Go goroutines where possible + +**Scope:** fork gpython to `LetheanNetwork/gpython`, work on `dev` +branch, upstream non-Lethean-specific fixes back to +`github.com/go-python/gpython`. Follow the Lethean fork-and-maintain +pattern established with torchax, optax, CommonLoopUtils, and the rest +of the Google ML stack. + +**Milestones:** + +- **gpy-0.1:** gpython dev branch fork exists, builds, tests pass. + Python 3.4 baseline confirmed working inside CoreGO. +- **gpy-0.2:** Pattern matching (`match`/`case`) and union syntax + (`int | str`) working. Enough for typed primitive bindings. +- **gpy-0.3:** Full `typing` module (`Protocol`, `Generic`, `TypedDict`, + `dataclass` decorator). +- **gpy-0.4:** Async/await with goroutine-backed event loop. +- **gpy-1.0:** Python 3.14 syntax parity. All CorePy primitive + bindings testable from gpython without syntax-compat warnings. + +### 7.1 Extension Mechanism + +gpython's extension mechanism is C-API-like but Go-native. Each binding +module registers with `py.RegisterModule()` and provides `METH_VARARGS` +/ `METH_KEYWORDS` / `METH_NOARGS` methods. CoreGO types convert to +Python objects via a type-mapping layer defined in +`core/py/bindings/typemap/`. + +The type-mapping layer handles: +- Go primitives (`int`, `float64`, `string`, `bool`, `[]byte`) ↔ Python primitives +- Go slices/maps ↔ Python lists/dicts +- Go structs ↔ Python classes (via dataclass-style wrapping) +- Go errors ↔ Python exceptions (scoped via `core.E`) +- Go channels ↔ Python async generators / queues +- Go interfaces ↔ Python protocols + +--- + +## 8. Distribution & Packaging + +### 8.1 Source Tree + +``` +core/py/ +├── RFC.md (this file) +├── bindings/ (Go-side primitive bindings) +│ ├── fs/ +│ ├── json/ +│ ├── medium/ +│ ├── options/ +│ ├── process/ +│ ├── service/ +│ ├── math/ (Poindexter wrappers) +│ └── typemap/ (Go ↔ Python type conversion) +├── runtime/ (gpython host integration) +│ └── interpreter.go +├── py/ (Python-side package; installable via uv) +│ ├── pyproject.toml +│ ├── core/ +│ │ ├── __init__.py +│ │ ├── fs.py (type stubs + docstrings for IDE support) +│ │ ├── json.py +│ │ └── ... +│ └── tests/ +├── examples/ +└── README.md +``` + +### 8.2 Tier 1 Distribution (gpython-embedded) + +Tier 1 CorePy ships as part of any CoreGO binary that imports +`dappco.re/go/py`. There is no separate install step — the Python +package is embedded in the binary at build time. + +Running Python code: + +```go +// Go host +interp := py.New() +interp.Run(` + from core import fs, json + data = fs.read_file("/etc/config.yaml") + print(json.loads(data)) +`) +``` + +### 8.3 Tier 2 Distribution (CPython-via-uv) + +For Tier 2 use, `core` is installable as a regular Python package via +uv using the `#subdirectory` pip URL trick: + +```bash +uv pip install "core @ git+ssh://git@forge.lthn.ai:2223/core#subdirectory=py/core" +``` + +This installs a CPython extension module built with `gopy` that wraps +the same Go primitives. The Python-side API is identical to Tier 1, +so code written for Tier 1 runs on Tier 2 without changes (modulo Tier +2 also being able to import numpy/torch/mlx if it needs them). + +**This is the key symmetry:** the same `import core` works in both +tiers. Tier 1 is faster for pure-Core work because there's no +subprocess / IPC boundary; Tier 2 is needed when you want numpy +alongside `core`. + +--- + +## 9. First-Test Milestone + +Before building any primitive bindings, validate the embedding itself: + +1. Clone gpython to `LetheanNetwork/gpython`, dev branch. +2. Create `core/py/runtime/interpreter.go` that embeds gpython in + CoreGO via `py.New()`. +3. Expose ONE trivial Go function as a Python callable: `core.echo(s)` + returns `s` unchanged. +4. Write a Go integration test that: + - Creates a gpython interpreter + - Runs Python code: `from core import echo; print(echo("hello"))` + - Asserts the output is `"hello"` +5. Commit and push. + +**That's proof of life.** Once the round-trip works, the next primitive +is `Options` (pure data, no I/O, easiest to bind). Each subsequent +primitive follows the same pattern and adds test coverage +(`TestFilename_Function_{Good,Bad,Ugly}` per AX principle 10). + +--- + +## 10. Risks & Mitigations + +| Risk | Mitigation | +|---|---| +| gpython's Python version is too old for modern syntax | Fork to `LetheanNetwork/gpython`, upgrade incrementally, upstream fixes. Owned scope (§7). | +| gpython doesn't support some bindings pattern we need | Extension mechanism is sufficient for primitive wrapping (verified by `go-python/gopy` design). Worst case: patch gpython. | +| Small upstream community → bus factor | Maintain `LetheanNetwork/gpython` as the Lethean-authoritative fork. Same pattern as torchax, optax, CommonLoopUtils. | +| Tier 1 users hit a wall where they need numpy | Tier 2 path is clean — rewrite the script for Tier 2 via `core.Process`. Boundary is obvious at the import level. | +| Embedding overhead (Python parse/compile at init) | Bytecode cache + pre-compiled marshal file for frequently-invoked scripts. Same technique CPython uses. | +| Python developers unfamiliar with Core primitives | Type stubs (§8.1 `py/core/*.py`) provide full IDE completion and docstrings. Documentation matches core/go naming by convention. | + +--- + +## 11. Status & Next Steps + +| Item | Status | +|---|---| +| RFC draft | 🚧 This document — v0.1 | +| gpython fork | Planned: `LetheanNetwork/gpython` dev branch | +| First-test (echo round-trip) | Planned: milestone gpy-0.1 | +| Primitive binding: Options | Planned: gpy-0.2 | +| Python 3.14 parity | Planned: gpy-1.0 | +| Integration with core/agent | Planned: post gpy-1.0 | +| LEM tooling migration to CorePy | Planned: post gpy-1.0 | + +**Next step after this RFC lands:** fork gpython, stand up the +interpreter embedding in a CoreGO binary, do the echo round-trip, commit. +Everything else follows once proof-of-life is green. + +--- + +## 12. References + +- **gpython upstream:** https://github.com/go-python/gpython +- **go-python organisation:** https://github.com/go-python (gpython, gopy, + cpy3, py, setuptools-golang, go2py) +- **Poindexter:** https://github.com/Snider/Poindexter (math primitives) +- **CoreGO:** `~/Code/core/go` (primitive source of truth) +- **CorePHP:** `code/core/php/` (architectural precedent for embedded + interpreter pattern) +- **CoreTS:** `code/core/ts/` (architectural precedent for polyglot + primitive exposure) +- **AX principles:** `rfc/core/RFC-CORE-008-AGENT-EXPERIENCE.md` +- **uv:** https://docs.astral.sh/uv/ (Python packaging + venv manager, + used for Tier 2 distribution) diff --git a/bindings/action/action.go b/bindings/action/action.go new file mode 100644 index 0000000..bebcf66 --- /dev/null +++ b/bindings/action/action.go @@ -0,0 +1,262 @@ +package action + +import ( + "fmt" // AX-6-exception: reflection-backed bootstrap call diagnostics need formatted type output. + "reflect" + + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Handle is a named action with an optional handler. +type Handle struct { + Name string + Handler any + Description string + Schema map[string]any + Enabled bool +} + +// Registry stores actions in registration order. +type Registry = core.Registry[*Handle] + +// Register exposes Core action helpers. +// +// action.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.action", + Documentation: "Named action helpers for CorePy", + Functions: map[string]runtime.Function{ + "new": newAction, + "new_registry": newRegistry, + "register": registerAction, + "get": getAction, + "names": names, + "run": run, + "exists": exists, + "disable": disable, + "enable": enable, + }, + }) +} + +func newAction(arguments ...any) (any, error) { + item := &Handle{Enabled: true, Schema: map[string]any{}} + if len(arguments) > 0 { + name, err := typemap.ExpectString(arguments, 0, "core.action.new") + if err != nil { + return nil, err + } + item.Name = name + } + if len(arguments) > 1 { + item.Handler = arguments[1] + } + if len(arguments) > 2 { + description, err := typemap.ExpectString(arguments, 2, "core.action.new") + if err != nil { + return nil, err + } + item.Description = description + } + if len(arguments) > 3 { + schema, err := typemap.ExpectMap(arguments, 3, "core.action.new") + if err != nil { + return nil, err + } + item.Schema = schema + } + return item, nil +} + +func newRegistry(arguments ...any) (any, error) { + return core.NewRegistry[*Handle](), nil +} + +func registerAction(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.action.register") + if err != nil { + return nil, err + } + name, err := typemap.ExpectString(arguments, 1, "core.action.register") + if err != nil { + return nil, err + } + if len(arguments) < 3 { + return nil, fmt.Errorf("core.action.register expected argument 2") + } + + item := &Handle{ + Name: name, + Handler: arguments[2], + Enabled: true, + Schema: map[string]any{}, + } + if len(arguments) > 3 { + description, err := typemap.ExpectString(arguments, 3, "core.action.register") + if err != nil { + return nil, err + } + item.Description = description + } + if len(arguments) > 4 { + schema, err := typemap.ExpectMap(arguments, 4, "core.action.register") + if err != nil { + return nil, err + } + item.Schema = schema + } + + if _, err := typemap.ResultValue(registryValue.Set(name, item), "core.action.register"); err != nil { + return nil, err + } + return item, nil +} + +func getAction(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.action.get") + if err != nil { + return nil, err + } + name, err := typemap.ExpectString(arguments, 1, "core.action.get") + if err != nil { + return nil, err + } + result := registryValue.Get(name) + if !result.OK { + return &Handle{Name: name, Enabled: true, Schema: map[string]any{}}, nil + } + return result.Value, nil +} + +func names(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.action.names") + if err != nil { + return nil, err + } + return registryValue.Names(), nil +} + +func run(arguments ...any) (any, error) { + item, err := expectHandle(arguments, 0, "core.action.run") + if err != nil { + return nil, err + } + options := map[string]any{} + if len(arguments) > 1 { + options, err = typemap.ExpectMap(arguments, 1, "core.action.run") + if err != nil { + return nil, err + } + } + return RunHandle(item, options) +} + +func exists(arguments ...any) (any, error) { + item, err := expectHandle(arguments, 0, "core.action.exists") + if err != nil { + return nil, err + } + return item.Handler != nil, nil +} + +func disable(arguments ...any) (any, error) { + item, err := expectHandle(arguments, 0, "core.action.disable") + if err != nil { + return nil, err + } + item.Enabled = false + return item, nil +} + +func enable(arguments ...any) (any, error) { + item, err := expectHandle(arguments, 0, "core.action.enable") + if err != nil { + return nil, err + } + item.Enabled = true + return item, nil +} + +// RunHandle executes an action handle with map-based options. +func RunHandle(item *Handle, options map[string]any) (any, error) { + if item == nil || item.Handler == nil { + name := "" + if item != nil && item.Name != "" { + name = item.Name + } + return nil, fmt.Errorf("action not registered: %s", name) + } + if !item.Enabled { + return nil, fmt.Errorf("action disabled: %s", item.Name) + } + + switch typed := item.Handler.(type) { + case runtime.Function: + return typed(options) + } + + handlerValue := reflect.ValueOf(item.Handler) + if !handlerValue.IsValid() || handlerValue.Kind() != reflect.Func { + return nil, fmt.Errorf("action handler is not callable: %T", item.Handler) + } + + handlerType := handlerValue.Type() + callArguments := []reflect.Value{} + if handlerType.IsVariadic() || handlerType.NumIn() > 1 { + return nil, fmt.Errorf("action handler signature is not supported: %T", item.Handler) + } + if handlerType.NumIn() == 1 { + argumentType := handlerType.In(0) + if argumentType.Kind() == reflect.Interface { + callArguments = append(callArguments, reflect.ValueOf(options)) + } else if reflect.TypeOf(options).AssignableTo(argumentType) { + callArguments = append(callArguments, reflect.ValueOf(options)) + } else { + return nil, fmt.Errorf("action handler parameter is not supported: %s", argumentType) + } + } + + returnValues := handlerValue.Call(callArguments) + switch len(returnValues) { + case 0: + return nil, nil + case 1: + if errValue, ok := returnValues[0].Interface().(error); ok { + return nil, errValue + } + return returnValues[0].Interface(), nil + case 2: + var err error + if !returnValues[1].IsNil() { + err = returnValues[1].Interface().(error) + } + return returnValues[0].Interface(), err + default: + return nil, fmt.Errorf("action handler returned unsupported arity: %d", len(returnValues)) + } +} + +func expectRegistry(arguments []any, index int, functionName string) (*Registry, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(*Registry) + if !ok { + return nil, fmt.Errorf("%s expected action registry, got %T", functionName, arguments[index]) + } + return value, nil +} + +func expectHandle(arguments []any, index int, functionName string) (*Handle, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(*Handle) + if !ok { + return nil, fmt.Errorf("%s expected action handle, got %T", functionName, arguments[index]) + } + return value, nil +} diff --git a/bindings/agent/agent.go b/bindings/agent/agent.go new file mode 100644 index 0000000..5522b9b --- /dev/null +++ b/bindings/agent/agent.go @@ -0,0 +1,20 @@ +package agent + +import "dappco.re/go/py/runtime" + +// Register exposes the planned Agent module surface. +// +// agent.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.agent", + Documentation: "Agent dispatch helpers for CorePy; native binding pending", + Functions: map[string]runtime.Function{ + "available": available, + }, + }) +} + +func available(arguments ...any) (any, error) { + return false, nil +} diff --git a/bindings/api/api.go b/bindings/api/api.go new file mode 100644 index 0000000..6853e45 --- /dev/null +++ b/bindings/api/api.go @@ -0,0 +1,20 @@ +package api + +import "dappco.re/go/py/runtime" + +// Register exposes the planned API module surface. +// +// api.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.api", + Documentation: "REST server and client helpers for CorePy; native binding pending", + Functions: map[string]runtime.Function{ + "available": available, + }, + }) +} + +func available(arguments ...any) (any, error) { + return false, nil +} diff --git a/bindings/array/array.go b/bindings/array/array.go new file mode 100644 index 0000000..a87e565 --- /dev/null +++ b/bindings/array/array.go @@ -0,0 +1,152 @@ +package array + +import ( + "fmt" // AX-6-exception: bootstrap handle validation reports dynamic Go types. + "reflect" + + "dappco.re/go/py/runtime" +) + +type handle struct { + items []any +} + +// Register exposes Core array helpers. +// +// array.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.array", + Documentation: "Array helpers for CorePy", + Functions: map[string]runtime.Function{ + "new": newArray, + "add": add, + "add_unique": addUnique, + "contains": contains, + "remove": remove, + "deduplicate": deduplicate, + "len": length, + "clear": clear, + "as_list": asList, + }, + }) +} + +func newArray(arguments ...any) (any, error) { + items := make([]any, 0, len(arguments)) + items = append(items, arguments...) + return &handle{items: items}, nil +} + +func add(arguments ...any) (any, error) { + arrayValue, err := expectHandle(arguments, 0, "core.array.add") + if err != nil { + return nil, err + } + arrayValue.items = append(arrayValue.items, arguments[1:]...) + return arrayValue, nil +} + +func addUnique(arguments ...any) (any, error) { + arrayValue, err := expectHandle(arguments, 0, "core.array.add_unique") + if err != nil { + return nil, err + } + for _, value := range arguments[1:] { + if !containsValue(arrayValue.items, value) { + arrayValue.items = append(arrayValue.items, value) + } + } + return arrayValue, nil +} + +func contains(arguments ...any) (any, error) { + arrayValue, err := expectHandle(arguments, 0, "core.array.contains") + if err != nil { + return nil, err + } + if len(arguments) < 2 { + return nil, fmt.Errorf("core.array.contains expected argument 1") + } + return containsValue(arrayValue.items, arguments[1]), nil +} + +func remove(arguments ...any) (any, error) { + arrayValue, err := expectHandle(arguments, 0, "core.array.remove") + if err != nil { + return nil, err + } + if len(arguments) < 2 { + return nil, fmt.Errorf("core.array.remove expected argument 1") + } + for index, value := range arrayValue.items { + if reflect.DeepEqual(value, arguments[1]) { + arrayValue.items = append(arrayValue.items[:index], arrayValue.items[index+1:]...) + break + } + } + return arrayValue, nil +} + +func deduplicate(arguments ...any) (any, error) { + arrayValue, err := expectHandle(arguments, 0, "core.array.deduplicate") + if err != nil { + return nil, err + } + result := make([]any, 0, len(arrayValue.items)) + for _, value := range arrayValue.items { + if containsValue(result, value) { + continue + } + result = append(result, value) + } + arrayValue.items = result + return arrayValue, nil +} + +func length(arguments ...any) (any, error) { + arrayValue, err := expectHandle(arguments, 0, "core.array.len") + if err != nil { + return nil, err + } + return len(arrayValue.items), nil +} + +func clear(arguments ...any) (any, error) { + arrayValue, err := expectHandle(arguments, 0, "core.array.clear") + if err != nil { + return nil, err + } + arrayValue.items = nil + return arrayValue, nil +} + +func asList(arguments ...any) (any, error) { + arrayValue, err := expectHandle(arguments, 0, "core.array.as_list") + if err != nil { + return nil, err + } + result := make([]any, len(arrayValue.items)) + copy(result, arrayValue.items) + return result, nil +} + +func expectHandle(arguments []any, index int, functionName string) (*handle, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(*handle) + if !ok { + return nil, fmt.Errorf("%s expected array handle, got %T", functionName, arguments[index]) + } + return value, nil +} + +func containsValue(items []any, target any) bool { + for _, item := range items { + if reflect.DeepEqual(item, target) { + return true + } + } + return false +} diff --git a/bindings/cache/cache.go b/bindings/cache/cache.go new file mode 100644 index 0000000..fbce849 --- /dev/null +++ b/bindings/cache/cache.go @@ -0,0 +1,434 @@ +package cache + +import ( + "encoding/json" // AX-6-exception: cache decoding uses Decoder.UseNumber to preserve Python int/float shape. + "fmt" // AX-6-exception: file-backed cache diagnostics preserve wrapped OS errors during bootstrap. + "io/fs" + "os" + "path/filepath" // AX-6-exception: cache key walking needs WalkDir, Rel, and ToSlash. + "slices" + "strings" // AX-6-exception: cache normalization needs Reader, ReplaceAll, and ContainsAny. + "time" + + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +const defaultTTL = time.Hour + +type handle struct { + baseDir string + ttl time.Duration +} + +type entry struct { + Data any `json:"data"` + CachedAt time.Time `json:"cached_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// Register exposes file-backed cache helpers. +// +// cache.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.cache", + Documentation: "JSON cache helpers for CorePy", + Functions: map[string]runtime.Function{ + "new": newCache, + "path": pathForKey, + "set": setValue, + "set_with_ttl": setWithTTL, + "get": getValue, + "has": hasValue, + "delete": deleteValue, + "clear": clearValues, + "keys": keys, + }, + }) +} + +func newCache(arguments ...any) (any, error) { + baseDir := "" + ttl := defaultTTL + + if len(arguments) > 0 && arguments[0] != nil { + value, err := typemap.ExpectString(arguments, 0, "core.cache.new") + if err != nil { + return nil, err + } + baseDir = value + } + if len(arguments) > 1 { + ttlSeconds, err := expectSeconds(arguments[1], "core.cache.new") + if err != nil { + return nil, err + } + ttl = ttlFromSeconds(ttlSeconds) + } + + if baseDir == "" { + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("core.cache.new failed to resolve current directory: %w", err) + } + baseDir = filepath.Join(cwd, ".core", "cache") + } + if err := os.MkdirAll(baseDir, 0755); err != nil { + return nil, fmt.Errorf("core.cache.new failed to create cache directory: %w", err) + } + return &handle{baseDir: baseDir, ttl: ttl}, nil +} + +func pathForKey(arguments ...any) (any, error) { + cacheHandle, err := expectHandle(arguments, 0, "core.cache.path") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.cache.path") + if err != nil { + return nil, err + } + return cacheHandle.path(key) +} + +func setValue(arguments ...any) (any, error) { + cacheHandle, err := expectHandle(arguments, 0, "core.cache.set") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.cache.set") + if err != nil { + return nil, err + } + if len(arguments) < 3 { + return nil, fmt.Errorf("core.cache.set expected argument 2") + } + path, err := cacheHandle.set(key, arguments[2], cacheHandle.ttl) + if err != nil { + return nil, err + } + return path, nil +} + +func setWithTTL(arguments ...any) (any, error) { + cacheHandle, err := expectHandle(arguments, 0, "core.cache.set_with_ttl") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.cache.set_with_ttl") + if err != nil { + return nil, err + } + if len(arguments) < 4 { + return nil, fmt.Errorf("core.cache.set_with_ttl expected argument 3") + } + ttlSeconds, err := expectSeconds(arguments[3], "core.cache.set_with_ttl") + if err != nil { + return nil, err + } + path, err := cacheHandle.set(key, arguments[2], ttlFromSeconds(ttlSeconds)) + if err != nil { + return nil, err + } + return path, nil +} + +func getValue(arguments ...any) (any, error) { + cacheHandle, err := expectHandle(arguments, 0, "core.cache.get") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.cache.get") + if err != nil { + return nil, err + } + value, found, err := cacheHandle.get(key) + if err != nil { + return nil, err + } + if found { + return value, nil + } + if len(arguments) > 2 { + return arguments[2], nil + } + return nil, nil +} + +func hasValue(arguments ...any) (any, error) { + cacheHandle, err := expectHandle(arguments, 0, "core.cache.has") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.cache.has") + if err != nil { + return nil, err + } + _, found, err := cacheHandle.get(key) + if err != nil { + return nil, err + } + return found, nil +} + +func deleteValue(arguments ...any) (any, error) { + cacheHandle, err := expectHandle(arguments, 0, "core.cache.delete") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.cache.delete") + if err != nil { + return nil, err + } + path, err := cacheHandle.path(key) + if err != nil { + return nil, err + } + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return nil, fmt.Errorf("core.cache.delete failed to remove %q: %w", path, err) + } + return true, nil +} + +func clearValues(arguments ...any) (any, error) { + cacheHandle, err := expectHandle(arguments, 0, "core.cache.clear") + if err != nil { + return nil, err + } + prefix := "" + if len(arguments) > 1 { + prefix, err = typemap.ExpectString(arguments, 1, "core.cache.clear") + if err != nil { + return nil, err + } + } + keys, err := cacheHandle.keys(prefix) + if err != nil { + return nil, err + } + removed := 0 + for _, key := range keys { + path, err := cacheHandle.path(key) + if err != nil { + return nil, err + } + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("core.cache.clear failed to remove %q: %w", path, err) + } + removed++ + } + return removed, nil +} + +func keys(arguments ...any) (any, error) { + cacheHandle, err := expectHandle(arguments, 0, "core.cache.keys") + if err != nil { + return nil, err + } + prefix := "" + if len(arguments) > 1 { + prefix, err = typemap.ExpectString(arguments, 1, "core.cache.keys") + if err != nil { + return nil, err + } + } + return cacheHandle.keys(prefix) +} + +func expectHandle(arguments []any, index int, functionName string) (*handle, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(*handle) + if !ok { + return nil, fmt.Errorf("%s expected cache handle, got %T", functionName, arguments[index]) + } + return value, nil +} + +func expectSeconds(value any, functionName string) (int, error) { + seconds, ok := value.(int) + if !ok { + return 0, fmt.Errorf("%s expected ttl seconds to be int, got %T", functionName, value) + } + if seconds < 0 { + return 0, fmt.Errorf("%s expected ttl seconds to be zero or positive", functionName) + } + return seconds, nil +} + +func ttlFromSeconds(seconds int) time.Duration { + if seconds == 0 { + return defaultTTL + } + return time.Duration(seconds) * time.Second +} + +func (cacheHandle *handle) set(key string, value any, ttl time.Duration) (string, error) { + path, err := cacheHandle.path(key) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return "", fmt.Errorf("core.cache.set failed to create directory: %w", err) + } + + now := time.Now() + content, err := json.MarshalIndent(entry{ + Data: value, + CachedAt: now, + ExpiresAt: now.Add(ttl), + }, "", " ") + if err != nil { + return "", fmt.Errorf("core.cache.set failed to marshal entry: %w", err) + } + if err := os.WriteFile(path, content, 0644); err != nil { + return "", fmt.Errorf("core.cache.set failed to write entry: %w", err) + } + return path, nil +} + +func (cacheHandle *handle) get(key string) (any, bool, error) { + path, err := cacheHandle.path(key) + if err != nil { + return nil, false, err + } + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, fmt.Errorf("core.cache.get failed to read entry: %w", err) + } + + var decoded entry + decoder := json.NewDecoder(strings.NewReader(string(content))) + decoder.UseNumber() + if err := decoder.Decode(&decoded); err != nil { + return nil, false, nil + } + if time.Now().After(decoded.ExpiresAt) { + _ = os.Remove(path) + return nil, false, nil + } + return normalizeJSONValue(decoded.Data), true, nil +} + +func (cacheHandle *handle) path(key string) (string, error) { + parts, err := normalizedParts(key, false, "cache key") + if err != nil { + return "", err + } + + path := filepath.Join(append([]string{cacheHandle.baseDir}, parts...)...) + ".json" + return path, nil +} + +func (cacheHandle *handle) keys(prefix string) ([]string, error) { + prefixText, err := normalizedPrefix(prefix) + if err != nil { + return nil, err + } + + result := []string{} + if err := filepath.WalkDir(cacheHandle.baseDir, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || filepath.Ext(path) != ".json" { + return nil + } + + relative, err := filepath.Rel(cacheHandle.baseDir, path) + if err != nil { + return err + } + key := strings.TrimSuffix(filepath.ToSlash(relative), ".json") + if prefixText != "" && key != prefixText && !strings.HasPrefix(key, prefixText+"/") { + return nil + } + result = append(result, key) + return nil + }); err != nil { + if os.IsNotExist(err) { + return []string{}, nil + } + return nil, fmt.Errorf("core.cache.keys failed to walk cache: %w", err) + } + slices.Sort(result) + return result, nil +} + +func normalizedPrefix(prefix string) (string, error) { + parts, err := normalizedParts(prefix, true, "cache prefix") + if err != nil { + return "", err + } + return strings.Join(parts, "/"), nil +} + +func normalizedParts(value string, allowEmpty bool, fieldName string) ([]string, error) { + text := strings.TrimSpace(strings.ReplaceAll(value, "\\", "/")) + if text == "" { + if allowEmpty { + return nil, nil + } + return nil, fmt.Errorf("%s must not be empty", fieldName) + } + if strings.HasPrefix(text, "/") { + return nil, fmt.Errorf("%s must be relative", fieldName) + } + + parts := []string{} + for _, part := range strings.Split(text, "/") { + if part == "" || part == "." { + continue + } + if part == ".." { + return nil, fmt.Errorf("%s must not contain '..'", fieldName) + } + parts = append(parts, part) + } + if len(parts) == 0 && !allowEmpty { + return nil, fmt.Errorf("%s must not be empty", fieldName) + } + return parts, nil +} + +func normalizeJSONValue(value any) any { + switch typed := value.(type) { + case map[string]any: + result := make(map[string]any, len(typed)) + for key, item := range typed { + result[key] = normalizeJSONValue(item) + } + return result + case []any: + result := make([]any, 0, len(typed)) + for _, item := range typed { + result = append(result, normalizeJSONValue(item)) + } + return result + case json.Number: + text := typed.String() + if !strings.ContainsAny(text, ".eE") { + if integerValue, err := typed.Int64(); err == nil { + return int(integerValue) + } + } + floatValue, err := typed.Float64() + if err != nil { + return text + } + return floatValue + default: + return value + } +} diff --git a/bindings/config/config.go b/bindings/config/config.go new file mode 100644 index 0000000..ff5bd96 --- /dev/null +++ b/bindings/config/config.go @@ -0,0 +1,187 @@ +package config + +import ( + "os" + "strconv" + "strings" // AX-6-exception: environment key normalization uses Replacer until core exposes equivalent composition. + + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes Config bindings backed by dappco.re/go/core. +// +// config.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.config", + Documentation: "Runtime settings backed by dappco.re/go/core", + Functions: map[string]runtime.Function{ + "new": newConfig, + "set": setValue, + "get": getValue, + "string": stringValue, + "int": intValue, + "bool": boolValue, + "enable": enableFeature, + "disable": disableFeature, + "enabled": enabledFeature, + "enabled_features": enabledFeatures, + }, + }) +} + +func newConfig(arguments ...any) (any, error) { + return (&core.Config{}).New(), nil +} + +func setValue(arguments ...any) (any, error) { + config, err := typemap.ExpectConfig(arguments, 0, "core.config.set") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.config.set") + if err != nil { + return nil, err + } + if len(arguments) < 3 { + return nil, core.E("core.config.set", "expected value argument", nil) + } + config.Set(key, arguments[2]) + return config, nil +} + +func getValue(arguments ...any) (any, error) { + config, err := typemap.ExpectConfig(arguments, 0, "core.config.get") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.config.get") + if err != nil { + return nil, err + } + result := config.Get(key) + if result.OK { + return result.Value, nil + } + if value, ok := lookupEnvironment(key); ok { + return value, nil + } + return nil, nil +} + +func stringValue(arguments ...any) (any, error) { + config, err := typemap.ExpectConfig(arguments, 0, "core.config.string") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.config.string") + if err != nil { + return nil, err + } + if result := config.Get(key); result.OK { + return config.String(key), nil + } + if value, ok := lookupEnvironment(key); ok { + return value, nil + } + return "", nil +} + +func intValue(arguments ...any) (any, error) { + config, err := typemap.ExpectConfig(arguments, 0, "core.config.int") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.config.int") + if err != nil { + return nil, err + } + if result := config.Get(key); result.OK { + return config.Int(key), nil + } + if value, ok := lookupEnvironment(key); ok { + parsed, err := strconv.Atoi(value) + if err == nil { + return parsed, nil + } + } + return 0, nil +} + +func boolValue(arguments ...any) (any, error) { + config, err := typemap.ExpectConfig(arguments, 0, "core.config.bool") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.config.bool") + if err != nil { + return nil, err + } + if result := config.Get(key); result.OK { + return config.Bool(key), nil + } + if value, ok := lookupEnvironment(key); ok { + parsed, err := strconv.ParseBool(value) + if err == nil { + return parsed, nil + } + } + return false, nil +} + +func enableFeature(arguments ...any) (any, error) { + config, err := typemap.ExpectConfig(arguments, 0, "core.config.enable") + if err != nil { + return nil, err + } + feature, err := typemap.ExpectString(arguments, 1, "core.config.enable") + if err != nil { + return nil, err + } + config.Enable(feature) + return config, nil +} + +func disableFeature(arguments ...any) (any, error) { + config, err := typemap.ExpectConfig(arguments, 0, "core.config.disable") + if err != nil { + return nil, err + } + feature, err := typemap.ExpectString(arguments, 1, "core.config.disable") + if err != nil { + return nil, err + } + config.Disable(feature) + return config, nil +} + +func enabledFeature(arguments ...any) (any, error) { + config, err := typemap.ExpectConfig(arguments, 0, "core.config.enabled") + if err != nil { + return nil, err + } + feature, err := typemap.ExpectString(arguments, 1, "core.config.enabled") + if err != nil { + return nil, err + } + return config.Enabled(feature), nil +} + +func enabledFeatures(arguments ...any) (any, error) { + config, err := typemap.ExpectConfig(arguments, 0, "core.config.enabled_features") + if err != nil { + return nil, err + } + return config.EnabledFeatures(), nil +} + +func lookupEnvironment(key string) (string, bool) { + return os.LookupEnv(environmentKey(key)) +} + +func environmentKey(key string) string { + replacer := strings.NewReplacer(".", "_", "-", "_", "/", "_", " ", "_") + return strings.ToUpper(replacer.Replace(strings.TrimSpace(key))) +} diff --git a/bindings/container/container.go b/bindings/container/container.go new file mode 100644 index 0000000..495440e --- /dev/null +++ b/bindings/container/container.go @@ -0,0 +1,20 @@ +package container + +import "dappco.re/go/py/runtime" + +// Register exposes the planned Container module surface. +// +// container.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.container", + Documentation: "Container orchestration helpers for CorePy; native binding pending", + Functions: map[string]runtime.Function{ + "available": available, + }, + }) +} + +func available(arguments ...any) (any, error) { + return false, nil +} diff --git a/bindings/crypto/crypto.go b/bindings/crypto/crypto.go new file mode 100644 index 0000000..fe6c969 --- /dev/null +++ b/bindings/crypto/crypto.go @@ -0,0 +1,114 @@ +package crypto + +import ( + "crypto/hmac" + cryptorand "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" // AX-6-exception: crypto helpers preserve wrapped stdlib errors from hashing/decoding/randomness. + + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes hashing and encoding helpers. +// +// crypto.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.crypto", + Documentation: "Cryptographic helpers for CorePy", + Functions: map[string]runtime.Function{ + "sha1": sha1Digest, + "sha256": sha256Digest, + "hmac_sha256": hmacSHA256, + "compare_digest": compareDigest, + "base64_encode": base64Encode, + "base64_decode": base64Decode, + "random_bytes": randomBytes, + }, + }) +} + +func sha1Digest(arguments ...any) (any, error) { + value, err := typemap.ExpectBytes(arguments, 0, "core.crypto.sha1") + if err != nil { + return nil, err + } + sum := sha1.Sum(value) + return hex.EncodeToString(sum[:]), nil +} + +func sha256Digest(arguments ...any) (any, error) { + value, err := typemap.ExpectBytes(arguments, 0, "core.crypto.sha256") + if err != nil { + return nil, err + } + sum := sha256.Sum256(value) + return hex.EncodeToString(sum[:]), nil +} + +func hmacSHA256(arguments ...any) (any, error) { + key, err := typemap.ExpectBytes(arguments, 0, "core.crypto.hmac_sha256") + if err != nil { + return nil, err + } + value, err := typemap.ExpectBytes(arguments, 1, "core.crypto.hmac_sha256") + if err != nil { + return nil, err + } + mac := hmac.New(sha256.New, key) + if _, err := mac.Write(value); err != nil { + return nil, fmt.Errorf("core.crypto.hmac_sha256 failed to hash input: %w", err) + } + return hex.EncodeToString(mac.Sum(nil)), nil +} + +func compareDigest(arguments ...any) (any, error) { + left, err := typemap.ExpectBytes(arguments, 0, "core.crypto.compare_digest") + if err != nil { + return nil, err + } + right, err := typemap.ExpectBytes(arguments, 1, "core.crypto.compare_digest") + if err != nil { + return nil, err + } + return hmac.Equal(left, right), nil +} + +func base64Encode(arguments ...any) (any, error) { + value, err := typemap.ExpectBytes(arguments, 0, "core.crypto.base64_encode") + if err != nil { + return nil, err + } + return base64.StdEncoding.EncodeToString(value), nil +} + +func base64Decode(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.crypto.base64_decode") + if err != nil { + return nil, err + } + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return nil, fmt.Errorf("core.crypto.base64_decode failed to decode input: %w", err) + } + return decoded, nil +} + +func randomBytes(arguments ...any) (any, error) { + size, err := typemap.ExpectInt(arguments, 0, "core.crypto.random_bytes") + if err != nil { + return nil, err + } + if size < 0 { + return nil, fmt.Errorf("core.crypto.random_bytes expected a non-negative size") + } + buffer := make([]byte, size) + if _, err := cryptorand.Read(buffer); err != nil { + return nil, fmt.Errorf("core.crypto.random_bytes failed to read randomness: %w", err) + } + return buffer, nil +} diff --git a/bindings/data/data.go b/bindings/data/data.go new file mode 100644 index 0000000..18e8498 --- /dev/null +++ b/bindings/data/data.go @@ -0,0 +1,169 @@ +package data + +import ( + "io/fs" + "os" + "sort" + "strings" // AX-6-exception: bootstrap data path normalization keeps stdlib contains until the binding is gpython-native. + + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes Data bindings backed by dappco.re/go/core. +// +// data.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.data", + Documentation: "Embedded content registry backed by dappco.re/go/core", + Functions: map[string]runtime.Function{ + "new": newData, + "mount": mountPath, + "mount_path": mountPath, + "read_file": readFile, + "read_string": readString, + "list": list, + "list_names": listNames, + "extract": extract, + "mounts": mounts, + }, + }) +} + +func newData(arguments ...any) (any, error) { + return &core.Data{Registry: core.NewRegistry[*core.Embed]()}, nil +} + +func mountPath(arguments ...any) (any, error) { + data, err := typemap.ExpectData(arguments, 0, "core.data.mount_path") + if err != nil { + return nil, err + } + name, err := typemap.ExpectString(arguments, 1, "core.data.mount_path") + if err != nil { + return nil, err + } + sourceDirectory, err := typemap.ExpectString(arguments, 2, "core.data.mount_path") + if err != nil { + return nil, err + } + mountPath := "." + if len(arguments) > 3 { + mountPath, err = typemap.ExpectString(arguments, 3, "core.data.mount_path") + if err != nil { + return nil, err + } + } + + options := core.NewOptions( + core.Option{Key: "name", Value: name}, + core.Option{Key: "source", Value: os.DirFS(sourceDirectory)}, + core.Option{Key: "path", Value: mountPath}, + ) + if _, err := typemap.ResultValue(data.New(options), "core.data.mount_path"); err != nil { + return nil, err + } + return data, nil +} + +func readString(arguments ...any) (any, error) { + data, err := typemap.ExpectData(arguments, 0, "core.data.read_string") + if err != nil { + return nil, err + } + path, err := typemap.ExpectString(arguments, 1, "core.data.read_string") + if err != nil { + return nil, err + } + return typemap.ResultValue(data.ReadString(normalizePath(path)), "core.data.read_string") +} + +func readFile(arguments ...any) (any, error) { + data, err := typemap.ExpectData(arguments, 0, "core.data.read_file") + if err != nil { + return nil, err + } + path, err := typemap.ExpectString(arguments, 1, "core.data.read_file") + if err != nil { + return nil, err + } + return typemap.ResultValue(data.ReadFile(normalizePath(path)), "core.data.read_file") +} + +func list(arguments ...any) (any, error) { + data, err := typemap.ExpectData(arguments, 0, "core.data.list") + if err != nil { + return nil, err + } + path, err := typemap.ExpectString(arguments, 1, "core.data.list") + if err != nil { + return nil, err + } + + value, err := typemap.ResultValue(data.List(normalizePath(path)), "core.data.list") + if err != nil { + return nil, err + } + + entries := value.([]fs.DirEntry) + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + sort.Strings(names) + return names, nil +} + +func listNames(arguments ...any) (any, error) { + data, err := typemap.ExpectData(arguments, 0, "core.data.list_names") + if err != nil { + return nil, err + } + path, err := typemap.ExpectString(arguments, 1, "core.data.list_names") + if err != nil { + return nil, err + } + return typemap.ResultValue(data.ListNames(normalizePath(path)), "core.data.list_names") +} + +func extract(arguments ...any) (any, error) { + data, err := typemap.ExpectData(arguments, 0, "core.data.extract") + if err != nil { + return nil, err + } + path, err := typemap.ExpectString(arguments, 1, "core.data.extract") + if err != nil { + return nil, err + } + targetDirectory, err := typemap.ExpectString(arguments, 2, "core.data.extract") + if err != nil { + return nil, err + } + + var templateData any + if len(arguments) > 3 { + templateData = arguments[3] + } + + if _, err := typemap.ResultValue(data.Extract(normalizePath(path), targetDirectory, templateData), "core.data.extract"); err != nil { + return nil, err + } + return targetDirectory, nil +} + +func mounts(arguments ...any) (any, error) { + data, err := typemap.ExpectData(arguments, 0, "core.data.mounts") + if err != nil { + return nil, err + } + return data.Mounts(), nil +} + +func normalizePath(path string) string { + if path == "" || strings.Contains(path, "/") { + return path + } + return path + "/." +} diff --git a/bindings/dns/dns.go b/bindings/dns/dns.go new file mode 100644 index 0000000..7bf5b22 --- /dev/null +++ b/bindings/dns/dns.go @@ -0,0 +1,91 @@ +package dns + +import ( + "net" + "slices" + + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes DNS resolution helpers. +// +// dns.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.dns", + Documentation: "DNS helpers for CorePy", + Functions: map[string]runtime.Function{ + "lookup_host": lookupHost, + "lookup_ip": lookupIP, + "reverse_lookup": reverseLookup, + "lookup_port": lookupPort, + }, + }) +} + +func lookupHost(arguments ...any) (any, error) { + name, err := typemap.ExpectString(arguments, 0, "core.dns.lookup_host") + if err != nil { + return nil, err + } + values, err := net.LookupHost(name) + if err != nil { + return nil, err + } + return uniqueSorted(values), nil +} + +func lookupIP(arguments ...any) (any, error) { + name, err := typemap.ExpectString(arguments, 0, "core.dns.lookup_ip") + if err != nil { + return nil, err + } + values, err := net.LookupIP(name) + if err != nil { + return nil, err + } + addresses := make([]string, 0, len(values)) + for _, value := range values { + addresses = append(addresses, value.String()) + } + return uniqueSorted(addresses), nil +} + +func reverseLookup(arguments ...any) (any, error) { + address, err := typemap.ExpectString(arguments, 0, "core.dns.reverse_lookup") + if err != nil { + return nil, err + } + values, err := net.LookupAddr(address) + if err != nil { + return nil, err + } + return uniqueSorted(values), nil +} + +func lookupPort(arguments ...any) (any, error) { + network, err := typemap.ExpectString(arguments, 0, "core.dns.lookup_port") + if err != nil { + return nil, err + } + service, err := typemap.ExpectString(arguments, 1, "core.dns.lookup_port") + if err != nil { + return nil, err + } + return net.LookupPort(network, service) +} + +func uniqueSorted(values []string) []string { + seen := map[string]struct{}{} + result := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + slices.Sort(result) + return result +} diff --git a/bindings/echo/echo.go b/bindings/echo/echo.go new file mode 100644 index 0000000..fc72e28 --- /dev/null +++ b/bindings/echo/echo.go @@ -0,0 +1,34 @@ +package echo + +import "dappco.re/go/py/runtime" + +// Register exposes the bootstrap `core.echo` round-trip. +// +// echo.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core", + Documentation: "Root CorePy module", + Functions: map[string]runtime.Function{ + "echo": func(arguments ...any) (any, error) { + if len(arguments) != 1 { + return nil, runtimeError("core.echo", "expected exactly one argument") + } + return arguments[0], nil + }, + }, + }) +} + +func runtimeError(functionName, message string) error { + return &echoError{functionName: functionName, message: message} +} + +type echoError struct { + functionName string + message string +} + +func (err *echoError) Error() string { + return err.functionName + ": " + err.message +} diff --git a/bindings/entitlement/entitlement.go b/bindings/entitlement/entitlement.go new file mode 100644 index 0000000..feaf56c --- /dev/null +++ b/bindings/entitlement/entitlement.go @@ -0,0 +1,112 @@ +package entitlement + +import ( + "fmt" // AX-6-exception: entitlement bootstrap validation reports dynamic Go types. + + core "dappco.re/go/core" + "dappco.re/go/py/runtime" +) + +// Register exposes Core entitlement helpers. +// +// entitlement.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.entitlement", + Documentation: "Entitlement helpers for CorePy", + Functions: map[string]runtime.Function{ + "new": newEntitlement, + "near_limit": nearLimit, + "usage_percent": usagePercent, + }, + }) +} + +func newEntitlement(arguments ...any) (any, error) { + values := core.Entitlement{} + if len(arguments) > 0 { + allowed, ok := arguments[0].(bool) + if !ok { + return nil, fmt.Errorf("core.entitlement.new expected argument 0 to be bool, got %T", arguments[0]) + } + values.Allowed = allowed + } + if len(arguments) > 1 { + unlimited, ok := arguments[1].(bool) + if !ok { + return nil, fmt.Errorf("core.entitlement.new expected argument 1 to be bool, got %T", arguments[1]) + } + values.Unlimited = unlimited + } + if len(arguments) > 2 { + limit, ok := arguments[2].(int) + if !ok { + return nil, fmt.Errorf("core.entitlement.new expected argument 2 to be int, got %T", arguments[2]) + } + values.Limit = limit + } + if len(arguments) > 3 { + used, ok := arguments[3].(int) + if !ok { + return nil, fmt.Errorf("core.entitlement.new expected argument 3 to be int, got %T", arguments[3]) + } + values.Used = used + } + if len(arguments) > 4 { + remaining, ok := arguments[4].(int) + if !ok { + return nil, fmt.Errorf("core.entitlement.new expected argument 4 to be int, got %T", arguments[4]) + } + values.Remaining = remaining + } else { + values.Remaining = values.Limit - values.Used + } + if len(arguments) > 5 { + reason, ok := arguments[5].(string) + if !ok { + return nil, fmt.Errorf("core.entitlement.new expected argument 5 to be string, got %T", arguments[5]) + } + values.Reason = reason + } + return values, nil +} + +func nearLimit(arguments ...any) (any, error) { + value, err := expectEntitlement(arguments, 0, "core.entitlement.near_limit") + if err != nil { + return nil, err + } + if len(arguments) < 2 { + return nil, fmt.Errorf("core.entitlement.near_limit expected argument 1") + } + threshold, ok := arguments[1].(float64) + if !ok { + return nil, fmt.Errorf("core.entitlement.near_limit expected argument 1 to be float, got %T", arguments[1]) + } + return value.NearLimit(threshold), nil +} + +func usagePercent(arguments ...any) (any, error) { + value, err := expectEntitlement(arguments, 0, "core.entitlement.usage_percent") + if err != nil { + return nil, err + } + return value.UsagePercent(), nil +} + +func expectEntitlement(arguments []any, index int, functionName string) (core.Entitlement, error) { + if index >= len(arguments) { + return core.Entitlement{}, fmt.Errorf("%s expected argument %d", functionName, index) + } + switch typed := arguments[index].(type) { + case core.Entitlement: + return typed, nil + case *core.Entitlement: + if typed == nil { + return core.Entitlement{}, fmt.Errorf("%s expected entitlement value, got nil", functionName) + } + return *typed, nil + default: + return core.Entitlement{}, fmt.Errorf("%s expected entitlement value, got %T", functionName, arguments[index]) + } +} diff --git a/bindings/err/err.go b/bindings/err/err.go new file mode 100644 index 0000000..c0814f3 --- /dev/null +++ b/bindings/err/err.go @@ -0,0 +1,113 @@ +package err + +import ( + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes error helpers backed by dappco.re/go/core. +// +// err.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.err", + Documentation: "Structured errors backed by dappco.re/go/core", + Functions: map[string]runtime.Function{ + "e": e, + "wrap": wrap, + "message": message, + "operation": operation, + "error_code": errorCode, + "root": root, + }, + }) +} + +func e(arguments ...any) (any, error) { + operation, err := typemap.ExpectString(arguments, 0, "core.err.e") + if err != nil { + return nil, err + } + message, err := typemap.ExpectString(arguments, 1, "core.err.e") + if err != nil { + return nil, err + } + cause, err := typemap.OptionalError(arguments, 2, "core.err.e") + if len(arguments) > 2 && err != nil { + return nil, err + } + + code := "" + if len(arguments) > 3 { + code, err = typemap.ExpectString(arguments, 3, "core.err.e") + if err != nil { + return nil, err + } + } + + if code != "" { + return core.WrapCode(cause, code, operation, message), nil + } + return core.E(operation, message, cause), nil +} + +func wrap(arguments ...any) (any, error) { + sourceError, err := typemap.OptionalError(arguments, 0, "core.err.wrap") + if err != nil { + return nil, err + } + operation, err := typemap.ExpectString(arguments, 1, "core.err.wrap") + if err != nil { + return nil, err + } + message, err := typemap.ExpectString(arguments, 2, "core.err.wrap") + if err != nil { + return nil, err + } + + code := "" + if len(arguments) > 3 { + code, err = typemap.ExpectString(arguments, 3, "core.err.wrap") + if err != nil { + return nil, err + } + } + + if code != "" { + return core.WrapCode(sourceError, code, operation, message), nil + } + return core.Wrap(sourceError, operation, message), nil +} + +func message(arguments ...any) (any, error) { + sourceError, err := typemap.OptionalError(arguments, 0, "core.err.message") + if err != nil { + return nil, err + } + return core.ErrorMessage(sourceError), nil +} + +func operation(arguments ...any) (any, error) { + sourceError, err := typemap.OptionalError(arguments, 0, "core.err.operation") + if err != nil { + return nil, err + } + return core.Operation(sourceError), nil +} + +func errorCode(arguments ...any) (any, error) { + sourceError, err := typemap.OptionalError(arguments, 0, "core.err.error_code") + if err != nil { + return nil, err + } + return core.ErrorCode(sourceError), nil +} + +func root(arguments ...any) (any, error) { + sourceError, err := typemap.OptionalError(arguments, 0, "core.err.root") + if err != nil { + return nil, err + } + return core.Root(sourceError), nil +} diff --git a/bindings/fs/fs.go b/bindings/fs/fs.go new file mode 100644 index 0000000..227b2a4 --- /dev/null +++ b/bindings/fs/fs.go @@ -0,0 +1,110 @@ +package fs + +import ( + "os" + "path/filepath" // AX-6-exception: byte-write helper needs parent directory resolution for local files. + + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes filesystem bindings backed by core.Fs. +// +// fs.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.fs", + Documentation: "Filesystem primitives backed by dappco.re/go/core", + Functions: map[string]runtime.Function{ + "read_file": readFile, + "read_bytes": readBytes, + "write_file": writeFile, + "write_bytes": writeBytes, + "ensure_dir": ensureDir, + "temp_dir": tempDir, + }, + }) +} + +func readFile(arguments ...any) (any, error) { + path, err := typemap.ExpectString(arguments, 0, "core.fs.read_file") + if err != nil { + return nil, err + } + return typemap.ResultValue(filesystem().Read(path), "core.fs.read_file") +} + +func readBytes(arguments ...any) (any, error) { + path, err := typemap.ExpectString(arguments, 0, "core.fs.read_bytes") + if err != nil { + return nil, err + } + + content, err := os.ReadFile(path) + if err != nil { + return nil, core.Wrap(err, "core.fs.read_bytes", "read failed") + } + return content, nil +} + +func writeFile(arguments ...any) (any, error) { + path, err := typemap.ExpectString(arguments, 0, "core.fs.write_file") + if err != nil { + return nil, err + } + content, err := typemap.ExpectString(arguments, 1, "core.fs.write_file") + if err != nil { + return nil, err + } + if _, err := typemap.ResultValue(filesystem().Write(path, content), "core.fs.write_file"); err != nil { + return nil, err + } + return path, nil +} + +func writeBytes(arguments ...any) (any, error) { + path, err := typemap.ExpectString(arguments, 0, "core.fs.write_bytes") + if err != nil { + return nil, err + } + content, err := typemap.ExpectBytes(arguments, 1, "core.fs.write_bytes") + if err != nil { + return nil, err + } + + if _, err := typemap.ResultValue(filesystem().EnsureDir(filepath.Dir(path)), "core.fs.write_bytes"); err != nil { + return nil, err + } + if err := os.WriteFile(path, content, 0644); err != nil { + return nil, core.Wrap(err, "core.fs.write_bytes", "write failed") + } + return path, nil +} + +func ensureDir(arguments ...any) (any, error) { + path, err := typemap.ExpectString(arguments, 0, "core.fs.ensure_dir") + if err != nil { + return nil, err + } + if _, err := typemap.ResultValue(filesystem().EnsureDir(path), "core.fs.ensure_dir"); err != nil { + return nil, err + } + return path, nil +} + +func tempDir(arguments ...any) (any, error) { + prefix := "corepy-" + if len(arguments) > 0 { + var err error + prefix, err = typemap.ExpectString(arguments, 0, "core.fs.temp_dir") + if err != nil { + return nil, err + } + } + return filesystem().TempDir(prefix), nil +} + +func filesystem() *core.Fs { + return (&core.Fs{}).NewUnrestricted() +} diff --git a/bindings/i18n/i18n.go b/bindings/i18n/i18n.go new file mode 100644 index 0000000..9ebccf9 --- /dev/null +++ b/bindings/i18n/i18n.go @@ -0,0 +1,171 @@ +package i18n + +import ( + "fmt" // AX-6-exception: bootstrap translator validation reports dynamic Go types. + + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +type translator interface { + Translate(messageID string, args ...any) core.Result + SetLanguage(lang string) error + Language() string + AvailableLanguages() []string +} + +type handle struct { + locales []any + locale string + translator translator +} + +// Register exposes Core i18n helpers. +// +// i18n.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.i18n", + Documentation: "Locale and translation helpers for CorePy", + Functions: map[string]runtime.Function{ + "new": newI18n, + "add_locales": addLocales, + "locales": locales, + "set_translator": setTranslator, + "translator": translatorValue, + "translate": translate, + "set_language": setLanguage, + "language": language, + "available_languages": availableLanguages, + }, + }) +} + +func newI18n(arguments ...any) (any, error) { + return &handle{}, nil +} + +func addLocales(arguments ...any) (any, error) { + item, err := expectHandle(arguments, 0, "core.i18n.add_locales") + if err != nil { + return nil, err + } + item.locales = append(item.locales, arguments[1:]...) + return item, nil +} + +func locales(arguments ...any) (any, error) { + item, err := expectHandle(arguments, 0, "core.i18n.locales") + if err != nil { + return nil, err + } + result := make([]any, len(item.locales)) + copy(result, item.locales) + return result, nil +} + +func setTranslator(arguments ...any) (any, error) { + item, err := expectHandle(arguments, 0, "core.i18n.set_translator") + if err != nil { + return nil, err + } + if len(arguments) < 2 || arguments[1] == nil { + item.translator = nil + return item, nil + } + translatorValue, ok := arguments[1].(translator) + if !ok { + return nil, fmt.Errorf("core.i18n.set_translator expected translator, got %T", arguments[1]) + } + item.translator = translatorValue + if item.locale != "" { + if err := item.translator.SetLanguage(item.locale); err != nil { + return nil, err + } + } + return item, nil +} + +func translatorValue(arguments ...any) (any, error) { + item, err := expectHandle(arguments, 0, "core.i18n.translator") + if err != nil { + return nil, err + } + if item.translator == nil { + return nil, nil + } + return item.translator, nil +} + +func translate(arguments ...any) (any, error) { + item, err := expectHandle(arguments, 0, "core.i18n.translate") + if err != nil { + return nil, err + } + messageID, err := typemap.ExpectString(arguments, 1, "core.i18n.translate") + if err != nil { + return nil, err + } + if item.translator == nil { + return messageID, nil + } + return typemap.ResultValue(item.translator.Translate(messageID, arguments[2:]...), "core.i18n.translate") +} + +func setLanguage(arguments ...any) (any, error) { + item, err := expectHandle(arguments, 0, "core.i18n.set_language") + if err != nil { + return nil, err + } + lang, err := typemap.ExpectString(arguments, 1, "core.i18n.set_language") + if err != nil { + return nil, err + } + if lang == "" { + return item, nil + } + item.locale = lang + if item.translator != nil { + if err := item.translator.SetLanguage(lang); err != nil { + return nil, err + } + } + return item, nil +} + +func language(arguments ...any) (any, error) { + item, err := expectHandle(arguments, 0, "core.i18n.language") + if err != nil { + return nil, err + } + if item.locale != "" { + return item.locale, nil + } + if item.translator != nil && item.translator.Language() != "" { + return item.translator.Language(), nil + } + return "en", nil +} + +func availableLanguages(arguments ...any) (any, error) { + item, err := expectHandle(arguments, 0, "core.i18n.available_languages") + if err != nil { + return nil, err + } + if item.translator == nil { + return []string{"en"}, nil + } + return item.translator.AvailableLanguages(), nil +} + +func expectHandle(arguments []any, index int, functionName string) (*handle, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(*handle) + if !ok { + return nil, fmt.Errorf("%s expected i18n handle, got %T", functionName, arguments[index]) + } + return value, nil +} diff --git a/bindings/info/info.go b/bindings/info/info.go new file mode 100644 index 0000000..046e201 --- /dev/null +++ b/bindings/info/info.go @@ -0,0 +1,48 @@ +package info + +import ( + "slices" + + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes Core system information helpers. +// +// info.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.info", + Documentation: "System information helpers for CorePy", + Functions: map[string]runtime.Function{ + "env": env, + "keys": keys, + "snapshot": snapshot, + }, + }) +} + +func env(arguments ...any) (any, error) { + key, err := typemap.ExpectString(arguments, 0, "core.info.env") + if err != nil { + return nil, err + } + return core.Env(key), nil +} + +func keys(arguments ...any) (any, error) { + values := core.EnvKeys() + slices.Sort(values) + return values, nil +} + +func snapshot(arguments ...any) (any, error) { + keys := core.EnvKeys() + slices.Sort(keys) + values := make(map[string]any, len(keys)) + for _, key := range keys { + values[key] = core.Env(key) + } + return values, nil +} diff --git a/bindings/json/json.go b/bindings/json/json.go new file mode 100644 index 0000000..e0b1f8c --- /dev/null +++ b/bindings/json/json.go @@ -0,0 +1,40 @@ +package json + +import ( + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes JSON bindings backed by dappco.re/go/core. +// +// json.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.json", + Documentation: "JSON helpers backed by dappco.re/go/core", + Functions: map[string]runtime.Function{ + "dumps": dumps, + "loads": loads, + }, + }) +} + +func dumps(arguments ...any) (any, error) { + if len(arguments) != 1 { + return nil, core.E("core.json.dumps", "expected exactly one argument", nil) + } + return core.JSONMarshalString(arguments[0]), nil +} + +func loads(arguments ...any) (any, error) { + text, err := typemap.ExpectString(arguments, 0, "core.json.loads") + if err != nil { + return nil, err + } + var value any + if _, err := typemap.ResultValue(core.JSONUnmarshalString(text, &value), "core.json.loads"); err != nil { + return nil, err + } + return value, nil +} diff --git a/bindings/log/log.go b/bindings/log/log.go new file mode 100644 index 0000000..20ade5d --- /dev/null +++ b/bindings/log/log.go @@ -0,0 +1,81 @@ +package log + +import ( + "fmt" // AX-6-exception: log level parser reports unsupported level names during bootstrap. + + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes logging bindings backed by dappco.re/go/core. +// +// log.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.log", + Documentation: "Structured logging backed by dappco.re/go/core", + Functions: map[string]runtime.Function{ + "set_level": setLevel, + "debug": debug, + "info": info, + "warn": warn, + "error": errorMessage, + }, + }) +} + +func setLevel(arguments ...any) (any, error) { + levelName, err := typemap.ExpectString(arguments, 0, "core.log.set_level") + if err != nil { + return nil, err + } + level, err := parseLevel(levelName) + if err != nil { + return nil, err + } + core.SetLevel(level) + return true, nil +} + +func debug(arguments ...any) (any, error) { + return logWith(core.Debug, "core.log.debug", arguments...) +} + +func info(arguments ...any) (any, error) { + return logWith(core.Info, "core.log.info", arguments...) +} + +func warn(arguments ...any) (any, error) { + return logWith(core.Warn, "core.log.warn", arguments...) +} + +func errorMessage(arguments ...any) (any, error) { + return logWith(core.Error, "core.log.error", arguments...) +} + +func logWith(fn func(string, ...any), functionName string, arguments ...any) (any, error) { + message, err := typemap.ExpectString(arguments, 0, functionName) + if err != nil { + return nil, err + } + fn(message, arguments[1:]...) + return true, nil +} + +func parseLevel(levelName string) (core.Level, error) { + switch levelName { + case "quiet": + return core.LevelQuiet, nil + case "error": + return core.LevelError, nil + case "warn": + return core.LevelWarn, nil + case "info": + return core.LevelInfo, nil + case "debug": + return core.LevelDebug, nil + default: + return core.LevelInfo, fmt.Errorf("unknown log level %q", levelName) + } +} diff --git a/bindings/math/math.go b/bindings/math/math.go new file mode 100644 index 0000000..474b5b7 --- /dev/null +++ b/bindings/math/math.go @@ -0,0 +1,859 @@ +package mathbinding + +import ( + "fmt" // AX-6-exception: bootstrap math diagnostics need formatted type and value output until Poindexter binding split. + stdmath "math" + "sort" + "strings" // AX-6-exception: bootstrap math sorting/keyword diagnostics need Compare, Join, and ToLower. + + "dappco.re/go/py/runtime" +) + +// Register exposes math helpers backed by pure Go algorithms. +// +// mathbinding.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + for _, module := range []runtime.Module{ + { + Name: "core.math", + Documentation: "Statistics, sorting, and scaling helpers for CorePy", + Functions: map[string]runtime.Function{ + "mean": mean, + "median": median, + "variance": variance, + "stdev": stdev, + "sort": sortValues, + "binary_search": binarySearch, + "epsilon_equal": epsilonEqual, + "normalize": normalize, + "rescale": rescale, + "moving_average": movingAverage, + "difference": difference, + }, + }, + { + Name: "core.math.kdtree", + Documentation: "KDTree-style nearest-neighbour helpers for CorePy", + Functions: map[string]runtime.Function{ + "build": buildKDTree, + "nearest": nearestKDTree, + }, + }, + { + Name: "core.math.knn", + Documentation: "KNN helpers for CorePy", + Functions: map[string]runtime.Function{ + "search": searchKNN, + }, + }, + { + Name: "core.math.signal", + Documentation: "Signal-processing helpers for CorePy", + Functions: map[string]runtime.Function{ + "moving_average": movingAverage, + "difference": difference, + }, + }, + } { + if err := interpreter.RegisterModule(module); err != nil { + return err + } + } + return nil +} + +type kdTreeHandle struct { + points [][]float64 + metric string +} + +// ResolveAttribute exposes the RFC KDTree object surface inside the bootstrap runtime. +// +// method, ok := tree.ResolveAttribute("nearest") +func (tree *kdTreeHandle) ResolveAttribute(name string) (any, bool) { + switch name { + case "nearest": + return runtime.BoundMethod{ + ModuleName: "core.math.kdtree", + FunctionName: "nearest", + Arguments: []any{tree}, + }, true + case "metric": + return tree.metric, true + case "points": + points := make([][]float64, 0, len(tree.points)) + for _, point := range tree.points { + points = append(points, append([]float64(nil), point...)) + } + return points, true + default: + return nil, false + } +} + +type neighbor struct { + Index int + Distance float64 + Point []float64 +} + +func mean(arguments ...any) (any, error) { + values, err := expectNumericSlice(arguments, 0, "core.math.mean") + if err != nil { + return nil, err + } + return average(values), nil +} + +func median(arguments ...any) (any, error) { + values, err := expectNumericSlice(arguments, 0, "core.math.median") + if err != nil { + return nil, err + } + return medianValue(values), nil +} + +func variance(arguments ...any) (any, error) { + values, err := expectNumericSlice(arguments, 0, "core.math.variance") + if err != nil { + return nil, err + } + return varianceValue(values), nil +} + +func stdev(arguments ...any) (any, error) { + values, err := expectNumericSlice(arguments, 0, "core.math.stdev") + if err != nil { + return nil, err + } + return stdmath.Sqrt(varianceValue(values)), nil +} + +func sortValues(arguments ...any) (any, error) { + values, err := expectSortableSlice(arguments, 0, "core.math.sort") + if err != nil { + return nil, err + } + + sorted := append([]any(nil), values...) + sort.SliceStable(sorted, func(i, j int) bool { + return compareSortable(sorted[i], sorted[j]) < 0 + }) + return sorted, nil +} + +func binarySearch(arguments ...any) (any, error) { + values, err := expectSortableSlice(arguments, 0, "core.math.binary_search") + if err != nil { + return nil, err + } + if len(arguments) < 2 { + return nil, fmt.Errorf("core.math.binary_search expected argument 1") + } + target := arguments[1] + + low := 0 + high := len(values) - 1 + for low <= high { + mid := (low + high) / 2 + comparison := compareSortable(values[mid], target) + switch { + case comparison == 0: + return mid, nil + case comparison < 0: + low = mid + 1 + default: + high = mid - 1 + } + } + return -1, nil +} + +func epsilonEqual(arguments ...any) (any, error) { + left, err := expectFloat(arguments, 0, "core.math.epsilon_equal") + if err != nil { + return nil, err + } + right, err := expectFloat(arguments, 1, "core.math.epsilon_equal") + if err != nil { + return nil, err + } + + epsilon := 1e-9 + if len(arguments) > 2 { + epsilon, err = expectFloat(arguments, 2, "core.math.epsilon_equal") + if err != nil { + return nil, err + } + } + return stdmath.Abs(left-right) <= epsilon, nil +} + +func normalize(arguments ...any) (any, error) { + if len(arguments) == 0 { + return nil, fmt.Errorf("core.math.normalize expected argument 0") + } + values, err := numericSliceFromValue(arguments[0], "core.math.normalize") + if err != nil { + return nil, err + } + if len(values) == 0 { + return []float64{}, nil + } + + minimum, maximum := minMax(values) + if minimum == maximum { + return make([]float64, len(values)), nil + } + + result := make([]float64, 0, len(values)) + scale := maximum - minimum + for _, value := range values { + result = append(result, (value-minimum)/scale) + } + return result, nil +} + +func rescale(arguments ...any) (any, error) { + if len(arguments) == 0 { + return nil, fmt.Errorf("core.math.rescale expected argument 0") + } + values, err := numericSliceFromValue(arguments[0], "core.math.rescale") + if err != nil { + return nil, err + } + newMinimum, err := expectFloat(arguments, 1, "core.math.rescale") + if err != nil { + return nil, err + } + newMaximum, err := expectFloat(arguments, 2, "core.math.rescale") + if err != nil { + return nil, err + } + if len(values) == 0 { + return []float64{}, nil + } + + minimum, maximum := minMax(values) + if minimum == maximum { + result := make([]float64, len(values)) + for index := range result { + result[index] = newMinimum + } + return result, nil + } + + result := make([]float64, 0, len(values)) + inputScale := maximum - minimum + outputScale := newMaximum - newMinimum + for _, value := range values { + normalized := (value - minimum) / inputScale + result = append(result, newMinimum+(normalized*outputScale)) + } + return result, nil +} + +func movingAverage(arguments ...any) (any, error) { + positional, keywordArguments := runtime.SplitKeywordArguments(arguments) + if len(positional) == 0 { + return nil, fmt.Errorf("core.math.moving_average expected argument 0") + } + if err := validateKeywordArguments("core.math.moving_average", keywordArguments, "window"); err != nil { + return nil, err + } + + values, err := numericSliceFromValue(positional[0], "core.math.moving_average") + if err != nil { + return nil, err + } + if len(values) == 0 { + return []float64{}, nil + } + + window := 1 + if len(positional) > 1 { + window, err = expectPositiveInt(positional, 1, "core.math.moving_average") + if err != nil { + return nil, err + } + } + window, err = keywordPositiveInt("core.math.moving_average", "window", window, keywordArguments, len(positional) > 1) + if err != nil { + return nil, err + } + + result := make([]float64, 0, len(values)) + var total float64 + for index, value := range values { + total += value + if index >= window { + total -= values[index-window] + } + + sampleCount := index + 1 + if sampleCount > window { + sampleCount = window + } + result = append(result, total/float64(sampleCount)) + } + return result, nil +} + +func difference(arguments ...any) (any, error) { + positional, keywordArguments := runtime.SplitKeywordArguments(arguments) + if len(positional) == 0 { + return nil, fmt.Errorf("core.math.difference expected argument 0") + } + if err := validateKeywordArguments("core.math.difference", keywordArguments, "lag"); err != nil { + return nil, err + } + + values, err := numericSliceFromValue(positional[0], "core.math.difference") + if err != nil { + return nil, err + } + if len(values) == 0 { + return []float64{}, nil + } + + lag := 1 + if len(positional) > 1 { + lag, err = expectPositiveInt(positional, 1, "core.math.difference") + if err != nil { + return nil, err + } + } + lag, err = keywordPositiveInt("core.math.difference", "lag", lag, keywordArguments, len(positional) > 1) + if err != nil { + return nil, err + } + if lag >= len(values) { + return []float64{}, nil + } + + result := make([]float64, 0, len(values)-lag) + for index := lag; index < len(values); index++ { + result = append(result, values[index]-values[index-lag]) + } + return result, nil +} + +func buildKDTree(arguments ...any) (any, error) { + positional, keywordArguments := runtime.SplitKeywordArguments(arguments) + points, err := expectPointSet(positional, 0, "core.math.kdtree.build") + if err != nil { + return nil, err + } + if err := validateKeywordArguments("core.math.kdtree.build", keywordArguments, "metric"); err != nil { + return nil, err + } + metric := "euclidean" + if len(positional) > 1 { + metric, err = expectMetric(positional[1], "core.math.kdtree.build") + if err != nil { + return nil, err + } + } + metric, err = keywordMetric("core.math.kdtree.build", metric, keywordArguments, len(positional) > 1) + if err != nil { + return nil, err + } + return &kdTreeHandle{ + points: points, + metric: metric, + }, nil +} + +func nearestKDTree(arguments ...any) (any, error) { + positional, keywordArguments := runtime.SplitKeywordArguments(arguments) + if len(positional) == 0 { + return nil, fmt.Errorf("core.math.kdtree.nearest expected argument 0") + } + if err := validateKeywordArguments("core.math.kdtree.nearest", keywordArguments, "k"); err != nil { + return nil, err + } + + tree, ok := positional[0].(*kdTreeHandle) + if !ok { + return nil, fmt.Errorf("core.math.kdtree.nearest expected KDTree handle, got %T", positional[0]) + } + query, err := expectPoint(positional, 1, "core.math.kdtree.nearest") + if err != nil { + return nil, err + } + k := 1 + if len(positional) > 2 { + k, err = expectPositiveInt(positional, 2, "core.math.kdtree.nearest") + if err != nil { + return nil, err + } + } + k, err = keywordPositiveInt("core.math.kdtree.nearest", "k", k, keywordArguments, len(positional) > 2) + if err != nil { + return nil, err + } + + return searchPoints(tree.points, query, k, tree.metric) +} + +func searchKNN(arguments ...any) (any, error) { + positional, keywordArguments := runtime.SplitKeywordArguments(arguments) + points, err := expectPointSet(positional, 0, "core.math.knn.search") + if err != nil { + return nil, err + } + if err := validateKeywordArguments("core.math.knn.search", keywordArguments, "k", "metric"); err != nil { + return nil, err + } + query, err := expectPoint(positional, 1, "core.math.knn.search") + if err != nil { + return nil, err + } + k := 1 + if len(positional) > 2 { + k, err = expectPositiveInt(positional, 2, "core.math.knn.search") + if err != nil { + return nil, err + } + } + k, err = keywordPositiveInt("core.math.knn.search", "k", k, keywordArguments, len(positional) > 2) + if err != nil { + return nil, err + } + + metric := "euclidean" + if len(positional) > 3 { + metric, err = expectMetric(positional[3], "core.math.knn.search") + if err != nil { + return nil, err + } + } + metric, err = keywordMetric("core.math.knn.search", metric, keywordArguments, len(positional) > 3) + if err != nil { + return nil, err + } + return searchPoints(points, query, k, metric) +} + +func searchPoints(points [][]float64, query []float64, k int, metric string) ([]map[string]any, error) { + if k <= 0 { + return nil, fmt.Errorf("k must be positive") + } + + neighbors := make([]neighbor, 0, len(points)) + for index, point := range points { + distance, err := pointDistance(metric, point, query) + if err != nil { + return nil, err + } + neighbors = append(neighbors, neighbor{ + Index: index, + Distance: distance, + Point: append([]float64(nil), point...), + }) + } + + sort.SliceStable(neighbors, func(i, j int) bool { + if neighbors[i].Distance == neighbors[j].Distance { + return neighbors[i].Index < neighbors[j].Index + } + return neighbors[i].Distance < neighbors[j].Distance + }) + if k > len(neighbors) { + k = len(neighbors) + } + + results := make([]map[string]any, 0, k) + for _, item := range neighbors[:k] { + results = append(results, map[string]any{ + "index": item.Index, + "distance": item.Distance, + "point": append([]float64(nil), item.Point...), + }) + } + return results, nil +} + +func pointDistance(metric string, left, right []float64) (float64, error) { + if len(left) != len(right) { + return 0, fmt.Errorf("point dimension mismatch: %d != %d", len(left), len(right)) + } + + switch metric { + case "euclidean": + var total float64 + for index := range left { + delta := left[index] - right[index] + total += delta * delta + } + return stdmath.Sqrt(total), nil + case "manhattan": + var total float64 + for index := range left { + total += stdmath.Abs(left[index] - right[index]) + } + return total, nil + case "chebyshev": + var maximum float64 + for index := range left { + delta := stdmath.Abs(left[index] - right[index]) + if delta > maximum { + maximum = delta + } + } + return maximum, nil + case "cosine": + var dotProduct float64 + var leftNorm float64 + var rightNorm float64 + for index := range left { + dotProduct += left[index] * right[index] + leftNorm += left[index] * left[index] + rightNorm += right[index] * right[index] + } + if leftNorm == 0 && rightNorm == 0 { + return 0, nil + } + if leftNorm == 0 || rightNorm == 0 { + return 1, nil + } + return 1 - (dotProduct / (stdmath.Sqrt(leftNorm) * stdmath.Sqrt(rightNorm))), nil + default: + return 0, fmt.Errorf("unknown metric %q", metric) + } +} + +func average(values []float64) float64 { + var total float64 + for _, value := range values { + total += value + } + return total / float64(len(values)) +} + +func medianValue(values []float64) float64 { + sorted := append([]float64(nil), values...) + sort.Float64s(sorted) + middle := len(sorted) / 2 + if len(sorted)%2 == 1 { + return sorted[middle] + } + return (sorted[middle-1] + sorted[middle]) / 2 +} + +func varianceValue(values []float64) float64 { + if len(values) == 0 { + return 0 + } + meanValue := average(values) + var total float64 + for _, value := range values { + delta := value - meanValue + total += delta * delta + } + return total / float64(len(values)) +} + +func minMax(values []float64) (float64, float64) { + minimum := values[0] + maximum := values[0] + for _, value := range values[1:] { + if value < minimum { + minimum = value + } + if value > maximum { + maximum = value + } + } + return minimum, maximum +} + +func expectNumericSlice(arguments []any, index int, functionName string) ([]float64, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + values, err := numericSliceFromValue(arguments[index], functionName) + if err != nil { + return nil, err + } + if len(values) == 0 { + return nil, fmt.Errorf("%s expected at least one numeric value", functionName) + } + return values, nil +} + +func expectPoint(arguments []any, index int, functionName string) ([]float64, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + values, err := numericSliceFromValue(arguments[index], functionName) + if err != nil { + return nil, err + } + if len(values) == 0 { + return nil, fmt.Errorf("%s expected point with at least one dimension", functionName) + } + return values, nil +} + +func expectPointSet(arguments []any, index int, functionName string) ([][]float64, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + points, err := pointSetFromValue(arguments[index], functionName) + if err != nil { + return nil, err + } + if len(points) == 0 { + return nil, fmt.Errorf("%s expected at least one point", functionName) + } + return points, nil +} + +func numericSliceFromValue(value any, functionName string) ([]float64, error) { + switch typed := value.(type) { + case []float64: + return append([]float64(nil), typed...), nil + case []int: + result := make([]float64, 0, len(typed)) + for _, item := range typed { + result = append(result, float64(item)) + } + return result, nil + case []any: + result := make([]float64, 0, len(typed)) + for _, item := range typed { + number, err := floatFromValue(item, functionName) + if err != nil { + return nil, err + } + result = append(result, number) + } + return result, nil + default: + return nil, fmt.Errorf("%s expected numeric slice, got %T", functionName, value) + } +} + +func pointSetFromValue(value any, functionName string) ([][]float64, error) { + switch typed := value.(type) { + case [][]float64: + result := make([][]float64, 0, len(typed)) + for _, point := range typed { + result = append(result, append([]float64(nil), point...)) + } + return result, nil + case []any: + result := make([][]float64, 0, len(typed)) + for _, item := range typed { + point, err := numericSliceFromValue(item, functionName) + if err != nil { + return nil, err + } + result = append(result, point) + } + return result, nil + default: + return nil, fmt.Errorf("%s expected point list, got %T", functionName, value) + } +} + +func expectSortableSlice(arguments []any, index int, functionName string) ([]any, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + + switch typed := arguments[index].(type) { + case []string: + result := make([]any, 0, len(typed)) + for _, item := range typed { + result = append(result, item) + } + return result, nil + case []int: + result := make([]any, 0, len(typed)) + for _, item := range typed { + result = append(result, item) + } + return result, nil + case []float64: + result := make([]any, 0, len(typed)) + for _, item := range typed { + result = append(result, item) + } + return result, nil + case []any: + if len(typed) == 0 { + return []any{}, nil + } + firstKind := sortableKind(typed[0]) + if firstKind == "" { + return nil, fmt.Errorf("%s expected sortable values, got %T", functionName, typed[0]) + } + result := make([]any, 0, len(typed)) + for _, item := range typed { + if sortableKind(item) != firstKind { + return nil, fmt.Errorf("%s expected homogenous sortable values, got %T", functionName, item) + } + result = append(result, item) + } + return result, nil + default: + return nil, fmt.Errorf("%s expected sortable slice, got %T", functionName, arguments[index]) + } +} + +func compareSortable(left, right any) int { + if leftText, ok := left.(string); ok { + rightText, ok := right.(string) + if !ok { + return strings.Compare(fmt.Sprintf("%T", left), fmt.Sprintf("%T", right)) + } + return strings.Compare(leftText, rightText) + } + + leftNumber, leftOK := maybeFloat(left) + rightNumber, rightOK := maybeFloat(right) + if leftOK && rightOK { + switch { + case leftNumber < rightNumber: + return -1 + case leftNumber > rightNumber: + return 1 + default: + return 0 + } + } + + return strings.Compare(fmt.Sprintf("%v", left), fmt.Sprintf("%v", right)) +} + +func sortableKind(value any) string { + if _, ok := value.(string); ok { + return "string" + } + if _, ok := maybeFloat(value); ok { + return "number" + } + return "" +} + +func expectFloat(arguments []any, index int, functionName string) (float64, error) { + if index >= len(arguments) { + return 0, fmt.Errorf("%s expected argument %d", functionName, index) + } + return floatFromValue(arguments[index], functionName) +} + +func expectPositiveInt(arguments []any, index int, functionName string) (int, error) { + if index >= len(arguments) { + return 0, fmt.Errorf("%s expected argument %d", functionName, index) + } + switch typed := arguments[index].(type) { + case int: + if typed <= 0 { + return 0, fmt.Errorf("%s expected positive integer, got %d", functionName, typed) + } + return typed, nil + default: + return 0, fmt.Errorf("%s expected positive integer, got %T", functionName, arguments[index]) + } +} + +func expectMetric(value any, functionName string) (string, error) { + text, ok := value.(string) + if !ok { + return "", fmt.Errorf("%s expected metric string, got %T", functionName, value) + } + text = strings.ToLower(text) + switch text { + case "euclidean", "manhattan", "chebyshev", "cosine": + return text, nil + default: + return "", fmt.Errorf("%s unknown metric %q", functionName, text) + } +} + +func floatFromValue(value any, functionName string) (float64, error) { + if number, ok := maybeFloat(value); ok { + return number, nil + } + return 0, fmt.Errorf("%s expected number, got %T", functionName, value) +} + +func maybeFloat(value any) (float64, bool) { + switch typed := value.(type) { + case int: + return float64(typed), true + case float64: + return typed, true + default: + return 0, false + } +} + +func keywordMetric(functionName string, current string, keywordArguments runtime.KeywordArguments, alreadySet bool) (string, error) { + if len(keywordArguments) == 0 { + return current, nil + } + + metricValue, ok := keywordArguments["metric"] + if !ok { + return current, nil + } + if alreadySet { + return "", fmt.Errorf("%s received multiple values for metric", functionName) + } + return expectMetric(metricValue, functionName) +} + +func keywordPositiveInt(functionName, name string, current int, keywordArguments runtime.KeywordArguments, alreadySet bool) (int, error) { + if len(keywordArguments) == 0 { + return current, nil + } + + value, ok := keywordArguments[name] + if !ok { + return current, nil + } + if alreadySet { + return 0, fmt.Errorf("%s received multiple values for %s", functionName, name) + } + switch typed := value.(type) { + case int: + if typed <= 0 { + return 0, fmt.Errorf("%s expected positive integer, got %d", functionName, typed) + } + return typed, nil + default: + return 0, fmt.Errorf("%s expected positive integer, got %T", functionName, value) + } +} + +func validateKeywordArguments(functionName string, keywordArguments runtime.KeywordArguments, allowed ...string) error { + if len(keywordArguments) == 0 { + return nil + } + + allowedSet := make(map[string]struct{}, len(allowed)) + for _, name := range allowed { + allowedSet[name] = struct{}{} + } + + var unexpected []string + for name := range keywordArguments { + if _, ok := allowedSet[name]; ok { + continue + } + unexpected = append(unexpected, name) + } + if len(unexpected) == 0 { + return nil + } + + sort.Strings(unexpected) + if len(unexpected) == 1 { + return fmt.Errorf("%s got unexpected keyword argument %q", functionName, unexpected[0]) + } + return fmt.Errorf("%s got unexpected keyword arguments %s", functionName, strings.Join(unexpected, ", ")) +} diff --git a/bindings/mcp/mcp.go b/bindings/mcp/mcp.go new file mode 100644 index 0000000..5b43fcf --- /dev/null +++ b/bindings/mcp/mcp.go @@ -0,0 +1,20 @@ +package mcp + +import "dappco.re/go/py/runtime" + +// Register exposes the planned MCP module surface. +// +// mcp.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.mcp", + Documentation: "MCP tool protocol helpers for CorePy; native binding pending", + Functions: map[string]runtime.Function{ + "available": available, + }, + }) +} + +func available(arguments ...any) (any, error) { + return false, nil +} diff --git a/bindings/medium/medium.go b/bindings/medium/medium.go new file mode 100644 index 0000000..366517b --- /dev/null +++ b/bindings/medium/medium.go @@ -0,0 +1,169 @@ +package medium + +import ( + "os" + "path/filepath" // AX-6-exception: file-backed Medium writes need parent directory resolution. + "unicode/utf8" + + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes Medium bindings for memory and filesystem-backed content. +// +// medium.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.medium", + Documentation: "Medium-backed content helpers for memory and filesystem transports", + Functions: map[string]runtime.Function{ + "memory": memory, + "from_path": fromPath, + "read_text": readText, + "write_text": writeText, + "read_bytes": readBytes, + "write_bytes": writeBytes, + }, + }) +} + +type handle struct { + location string + text string + data []byte +} + +func memory(arguments ...any) (any, error) { + initialText := "" + if len(arguments) > 0 { + var err error + initialText, err = typemap.ExpectString(arguments, 0, "core.medium.memory") + if err != nil { + return nil, err + } + } + + return &handle{ + text: initialText, + data: []byte(initialText), + }, nil +} + +func fromPath(arguments ...any) (any, error) { + path, err := typemap.ExpectString(arguments, 0, "core.medium.from_path") + if err != nil { + return nil, err + } + + return &handle{location: path}, nil +} + +func readText(arguments ...any) (any, error) { + mediumHandle, err := expectHandle(arguments, 0, "core.medium.read_text") + if err != nil { + return nil, err + } + if mediumHandle.location == "" { + return mediumHandle.text, nil + } + + return typemap.ResultValue(filesystem().Read(mediumHandle.location), "core.medium.read_text") +} + +func writeText(arguments ...any) (any, error) { + mediumHandle, err := expectHandle(arguments, 0, "core.medium.write_text") + if err != nil { + return nil, err + } + value, err := typemap.ExpectString(arguments, 1, "core.medium.write_text") + if err != nil { + return nil, err + } + + if mediumHandle.location == "" { + mediumHandle.text = value + mediumHandle.data = []byte(value) + return value, nil + } + + if err := ensureParentDir(mediumHandle.location, "core.medium.write_text"); err != nil { + return nil, err + } + if _, err := typemap.ResultValue(filesystem().Write(mediumHandle.location, value), "core.medium.write_text"); err != nil { + return nil, err + } + return value, nil +} + +func readBytes(arguments ...any) (any, error) { + mediumHandle, err := expectHandle(arguments, 0, "core.medium.read_bytes") + if err != nil { + return nil, err + } + if mediumHandle.location == "" { + if mediumHandle.data != nil { + return append([]byte(nil), mediumHandle.data...), nil + } + return []byte(mediumHandle.text), nil + } + + data, err := os.ReadFile(mediumHandle.location) + if err != nil { + return nil, core.Wrap(err, "core.medium.read_bytes", "read failed") + } + return data, nil +} + +func writeBytes(arguments ...any) (any, error) { + mediumHandle, err := expectHandle(arguments, 0, "core.medium.write_bytes") + if err != nil { + return nil, err + } + value, err := typemap.ExpectBytes(arguments, 1, "core.medium.write_bytes") + if err != nil { + return nil, err + } + + if mediumHandle.location == "" { + mediumHandle.data = append([]byte(nil), value...) + if utf8.Valid(value) { + mediumHandle.text = string(value) + } else { + mediumHandle.text = "" + } + return append([]byte(nil), value...), nil + } + + if err := ensureParentDir(mediumHandle.location, "core.medium.write_bytes"); err != nil { + return nil, err + } + if err := os.WriteFile(mediumHandle.location, value, 0644); err != nil { + return nil, core.Wrap(err, "core.medium.write_bytes", "write failed") + } + return append([]byte(nil), value...), nil +} + +func expectHandle(arguments []any, index int, functionName string) (*handle, error) { + if index >= len(arguments) { + return nil, core.E(functionName, "expected medium handle", nil) + } + mediumHandle, ok := arguments[index].(*handle) + if !ok { + return nil, core.E(functionName, "expected medium handle", nil) + } + return mediumHandle, nil +} + +func ensureParentDir(path, functionName string) error { + parentDirectory := filepath.Dir(path) + if parentDirectory == "." || parentDirectory == "" { + return nil + } + _, err := typemap.ResultValue(filesystem().EnsureDir(parentDirectory), functionName) + return err +} + +func filesystem() *core.Fs { + return (&core.Fs{}).NewUnrestricted() +} diff --git a/bindings/options/options.go b/bindings/options/options.go new file mode 100644 index 0000000..cf48d7e --- /dev/null +++ b/bindings/options/options.go @@ -0,0 +1,127 @@ +package options + +import ( + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes Core Options bindings. +// +// options.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.options", + Documentation: "Typed option primitives backed by dappco.re/go/core", + Functions: map[string]runtime.Function{ + "new": newOptions, + "set": setValue, + "get": getValue, + "has": hasKey, + "string": stringValue, + "int": intValue, + "bool": boolValue, + "items": items, + }, + }) +} + +func newOptions(arguments ...any) (any, error) { + if len(arguments) == 0 { + options := core.NewOptions() + return &options, nil + } + values, err := typemap.ExpectMap(arguments, 0, "core.options.new") + if err != nil { + return nil, err + } + return typemap.MapToOptions(values), nil +} + +func setValue(arguments ...any) (any, error) { + options, err := typemap.ExpectOptions(arguments, 0, "core.options.set") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.options.set") + if err != nil { + return nil, err + } + if len(arguments) < 3 { + return nil, core.E("core.options.set", "expected value argument", nil) + } + options.Set(key, arguments[2]) + return options, nil +} + +func getValue(arguments ...any) (any, error) { + options, err := typemap.ExpectOptions(arguments, 0, "core.options.get") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.options.get") + if err != nil { + return nil, err + } + result := options.Get(key) + if !result.OK { + return nil, nil + } + return result.Value, nil +} + +func hasKey(arguments ...any) (any, error) { + options, err := typemap.ExpectOptions(arguments, 0, "core.options.has") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.options.has") + if err != nil { + return nil, err + } + return options.Has(key), nil +} + +func stringValue(arguments ...any) (any, error) { + options, err := typemap.ExpectOptions(arguments, 0, "core.options.string") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.options.string") + if err != nil { + return nil, err + } + return options.String(key), nil +} + +func intValue(arguments ...any) (any, error) { + options, err := typemap.ExpectOptions(arguments, 0, "core.options.int") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.options.int") + if err != nil { + return nil, err + } + return options.Int(key), nil +} + +func boolValue(arguments ...any) (any, error) { + options, err := typemap.ExpectOptions(arguments, 0, "core.options.bool") + if err != nil { + return nil, err + } + key, err := typemap.ExpectString(arguments, 1, "core.options.bool") + if err != nil { + return nil, err + } + return options.Bool(key), nil +} + +func items(arguments ...any) (any, error) { + options, err := typemap.ExpectOptions(arguments, 0, "core.options.items") + if err != nil { + return nil, err + } + return typemap.OptionsToMap(options), nil +} diff --git a/bindings/path/path.go b/bindings/path/path.go new file mode 100644 index 0000000..638a32b --- /dev/null +++ b/bindings/path/path.go @@ -0,0 +1,98 @@ +package pathbinding + +import ( + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes path helpers backed by dappco.re/go/core. +// +// pathbinding.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.path", + Documentation: "Path helpers backed by dappco.re/go/core", + Functions: map[string]runtime.Function{ + "join": join, + "base": base, + "dir": dir, + "ext": ext, + "is_abs": isAbs, + "clean": clean, + "glob": glob, + }, + }) +} + +func join(arguments ...any) (any, error) { + segments, err := stringArguments(arguments, 0, "core.path.join") + if err != nil { + return nil, err + } + return core.JoinPath(segments...), nil +} + +func base(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.path.base") + if err != nil { + return nil, err + } + return core.PathBase(value), nil +} + +func dir(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.path.dir") + if err != nil { + return nil, err + } + return core.PathDir(value), nil +} + +func ext(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.path.ext") + if err != nil { + return nil, err + } + return core.PathExt(value), nil +} + +func isAbs(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.path.is_abs") + if err != nil { + return nil, err + } + return core.PathIsAbs(value), nil +} + +func clean(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.path.clean") + if err != nil { + return nil, err + } + separator := core.Env("DS") + if separator == "" { + separator = "/" + } + return core.CleanPath(value, separator), nil +} + +func glob(arguments ...any) (any, error) { + pattern, err := typemap.ExpectString(arguments, 0, "core.path.glob") + if err != nil { + return nil, err + } + return core.PathGlob(pattern), nil +} + +func stringArguments(arguments []any, startIndex int, functionName string) ([]string, error) { + values := make([]string, 0, len(arguments)-startIndex) + for index := startIndex; index < len(arguments); index++ { + value, err := typemap.ExpectString(arguments, index, functionName) + if err != nil { + return nil, err + } + values = append(values, value) + } + return values, nil +} diff --git a/bindings/process/process.go b/bindings/process/process.go new file mode 100644 index 0000000..ebfe546 --- /dev/null +++ b/bindings/process/process.go @@ -0,0 +1,433 @@ +package process + +import ( + "bytes" + "context" + "fmt" // AX-6-exception: process bootstrap preserves wrapped stderr context from exec failures. + "os" + "os/exec" // AX-6-exception: this binding provides the process primitive before go-process is registered. + "sort" + "strings" // AX-6-exception: process bootstrap trims stderr captured from os/exec. + "sync" + "time" + + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +var ( + defaultCoreOnce sync.Once + defaultCore *core.Core +) + +type executionOptions struct { + Directory string + Env []string + Timeout time.Duration + Check bool +} + +type executionResult struct { + Command []string + Stdout string + Stderr string + ExitCode int + TimedOut bool +} + +// Register exposes Process bindings backed by core.Process. +// +// process.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.process", + Documentation: "Process helpers backed by dappco.re/go/core", + Functions: map[string]runtime.Function{ + "run": run, + "run_in": runIn, + "run_with_env": runWithEnv, + "run_result": runResult, + "exists": exists, + }, + }) +} + +func run(arguments ...any) (any, error) { + positional, keywordArguments := runtime.SplitKeywordArguments(arguments) + options := executionOptions{Check: true} + if err := applyKeywordArguments(&options, "core.process.run", keywordArguments, "directory", "env", "timeout", "check"); err != nil { + return nil, err + } + + command, processArguments, err := commandArgs(positional, 0, "core.process.run") + if err != nil { + return nil, err + } + + result, err := executeProcess(context.Background(), command, processArguments, options) + if err != nil { + return nil, err + } + return result.Stdout, nil +} + +func runIn(arguments ...any) (any, error) { + positional, keywordArguments := runtime.SplitKeywordArguments(arguments) + options := executionOptions{Check: true} + if err := applyKeywordArguments(&options, "core.process.run_in", keywordArguments, "env", "timeout", "check"); err != nil { + return nil, err + } + + directory, err := typemap.ExpectString(positional, 0, "core.process.run_in") + if err != nil { + return nil, err + } + options.Directory = directory + command, processArguments, err := commandArgs(positional, 1, "core.process.run_in") + if err != nil { + return nil, err + } + + result, err := executeProcess(context.Background(), command, processArguments, options) + if err != nil { + return nil, err + } + return result.Stdout, nil +} + +func runWithEnv(arguments ...any) (any, error) { + positional, keywordArguments := runtime.SplitKeywordArguments(arguments) + options := executionOptions{Check: true} + if err := applyKeywordArguments(&options, "core.process.run_with_env", keywordArguments, "timeout", "check"); err != nil { + return nil, err + } + + directory, err := typemap.ExpectString(positional, 0, "core.process.run_with_env") + if err != nil { + return nil, err + } + env, err := envList(positional, 1, "core.process.run_with_env") + if err != nil { + return nil, err + } + options.Directory = directory + options.Env = env + command, processArguments, err := commandArgs(positional, 2, "core.process.run_with_env") + if err != nil { + return nil, err + } + + result, err := executeProcess(context.Background(), command, processArguments, options) + if err != nil { + return nil, err + } + return result.Stdout, nil +} + +func runResult(arguments ...any) (any, error) { + positional, keywordArguments := runtime.SplitKeywordArguments(arguments) + options := executionOptions{} + if err := applyKeywordArguments(&options, "core.process.run_result", keywordArguments, "directory", "env", "timeout", "check"); err != nil { + return nil, err + } + + command, processArguments, err := commandArgs(positional, 0, "core.process.run_result") + if err != nil { + return nil, err + } + + result, err := executeProcess(context.Background(), command, processArguments, options) + if err != nil { + return nil, err + } + return result.Map(), nil +} + +func exists(arguments ...any) (any, error) { + return processCore().Process().Exists(), nil +} + +func processCore() *core.Core { + defaultCoreOnce.Do(func() { + defaultCore = core.New() + defaultCore.Action("process.run", handleRun) + }) + return defaultCore +} + +func handleRun(ctx context.Context, options core.Options) core.Result { + command := options.String("command") + if command == "" { + return core.Result{Value: core.E("core.process.run", "command is required", nil), OK: false} + } + + result, err := executeProcess(ctx, command, optionStrings(options.Get("args")), executionOptions{ + Directory: options.String("dir"), + Env: optionStrings(options.Get("env")), + Check: true, + }) + if err != nil { + return core.Result{ + Value: core.E("core.process.run", core.Concat("command failed: ", command), err), + OK: false, + } + } + + return core.Result{Value: result.Stdout, OK: true} +} + +func executeProcess(ctx context.Context, command string, arguments []string, options executionOptions) (executionResult, error) { + if command == "" { + return executionResult{}, fmt.Errorf("core.process: command is required") + } + if ctx == nil { + ctx = context.Background() + } + if options.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, options.Timeout) + defer cancel() + } + + cmd := exec.CommandContext(ctx, command, arguments...) + if options.Directory != "" { + cmd.Dir = options.Directory + } + if len(options.Env) > 0 { + cmd.Env = append(os.Environ(), options.Env...) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + runErr := cmd.Run() + timedOut := ctx.Err() == context.DeadlineExceeded + exitCode := 0 + if runErr != nil { + exitCode = -1 + if exitErr, ok := runErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + } + + result := executionResult{ + Command: append([]string{command}, arguments...), + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + TimedOut: timedOut, + } + if (runErr != nil || timedOut) && options.Check { + return result, processError(result, options.Timeout, runErr) + } + return result, nil +} + +func (result executionResult) Map() map[string]any { + return map[string]any{ + "command": append([]string(nil), result.Command...), + "stdout": result.Stdout, + "stderr": result.Stderr, + "exit_code": result.ExitCode, + "timed_out": result.TimedOut, + "ok": result.ExitCode == 0 && !result.TimedOut, + } +} + +func processError(result executionResult, timeout time.Duration, cause error) error { + if result.TimedOut { + if timeout > 0 { + return fmt.Errorf("core.process: command timed out after %s: %s", timeout, strings.Join(result.Command, " ")) + } + return fmt.Errorf("core.process: command timed out: %s", strings.Join(result.Command, " ")) + } + if stderr := strings.TrimSpace(result.Stderr); stderr != "" { + first := strings.SplitN(stderr, "\n", 2)[0] + return fmt.Errorf("core.process: command exited with status %d: %s", result.ExitCode, first) + } + if cause != nil { + return fmt.Errorf("core.process: command exited with status %d: %w", result.ExitCode, cause) + } + return fmt.Errorf("core.process: command exited with status %d", result.ExitCode) +} + +func applyKeywordArguments(options *executionOptions, functionName string, keywordArguments runtime.KeywordArguments, allowed ...string) error { + if len(keywordArguments) == 0 { + return nil + } + + allowedSet := map[string]struct{}{} + for _, name := range allowed { + allowedSet[name] = struct{}{} + } + for name := range keywordArguments { + if _, ok := allowedSet[name]; !ok { + return fmt.Errorf("%s got unexpected keyword argument %q", functionName, name) + } + } + + if value, ok := keywordArguments["directory"]; ok { + directory, valid := value.(string) + if !valid { + return fmt.Errorf("%s expected directory to be string, got %T", functionName, value) + } + options.Directory = directory + } + if value, ok := keywordArguments["env"]; ok { + env, err := envFromValue(value, functionName) + if err != nil { + return err + } + options.Env = env + } + if value, ok := keywordArguments["timeout"]; ok { + timeout, err := timeoutFromValue(value, functionName) + if err != nil { + return err + } + options.Timeout = timeout + } + if value, ok := keywordArguments["check"]; ok { + check, valid := value.(bool) + if !valid { + return fmt.Errorf("%s expected check to be bool, got %T", functionName, value) + } + options.Check = check + } + return nil +} + +func commandArgs(arguments []any, commandIndex int, functionName string) (string, []string, error) { + command, err := typemap.ExpectString(arguments, commandIndex, functionName) + if err != nil { + return "", nil, err + } + + processArguments := make([]string, 0, len(arguments)-commandIndex-1) + for index := commandIndex + 1; index < len(arguments); index++ { + argument, err := typemap.ExpectString(arguments, index, functionName) + if err != nil { + return "", nil, err + } + processArguments = append(processArguments, argument) + } + + return command, processArguments, nil +} + +func timeoutFromValue(value any, functionName string) (time.Duration, error) { + switch typed := value.(type) { + case nil: + return 0, nil + case time.Duration: + if typed < 0 { + return 0, fmt.Errorf("%s expected non-negative timeout, got %s", functionName, typed) + } + return typed, nil + case int: + if typed < 0 { + return 0, fmt.Errorf("%s expected non-negative timeout, got %d", functionName, typed) + } + return time.Duration(typed) * time.Second, nil + case float64: + if typed < 0 { + return 0, fmt.Errorf("%s expected non-negative timeout, got %v", functionName, typed) + } + return time.Duration(typed * float64(time.Second)), nil + case string: + timeout, err := time.ParseDuration(typed) + if err != nil { + return 0, fmt.Errorf("%s expected timeout duration string: %w", functionName, err) + } + if timeout < 0 { + return 0, fmt.Errorf("%s expected non-negative timeout, got %s", functionName, timeout) + } + return timeout, nil + default: + return 0, fmt.Errorf("%s expected timeout to be seconds or duration string, got %T", functionName, value) + } +} + +func envList(arguments []any, index int, functionName string) ([]string, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + return envFromValue(arguments[index], functionName) +} + +func envFromValue(value any, functionName string) ([]string, error) { + switch typed := value.(type) { + case nil: + return nil, nil + case []string: + return append([]string(nil), typed...), nil + case []any: + result := make([]string, 0, len(typed)) + for _, item := range typed { + text, ok := item.(string) + if !ok { + return nil, fmt.Errorf("%s expected environment entries to be strings, got %T", functionName, item) + } + result = append(result, text) + } + return result, nil + case map[string]string: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + + result := make([]string, 0, len(keys)) + for _, key := range keys { + result = append(result, key+"="+typed[key]) + } + return result, nil + case map[string]any: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + + result := make([]string, 0, len(keys)) + for _, key := range keys { + text, ok := typed[key].(string) + if !ok { + return nil, fmt.Errorf("%s expected environment value for %q to be string, got %T", functionName, key, typed[key]) + } + result = append(result, key+"="+text) + } + return result, nil + default: + return nil, fmt.Errorf("%s expected environment mapping or []string, got %T", functionName, value) + } +} + +func optionStrings(result core.Result) []string { + if !result.OK { + return nil + } + + switch typed := result.Value.(type) { + case nil: + return nil + case []string: + return append([]string(nil), typed...) + case []any: + resultStrings := make([]string, 0, len(typed)) + for _, item := range typed { + text, ok := item.(string) + if !ok { + return nil + } + resultStrings = append(resultStrings, text) + } + return resultStrings + default: + return nil + } +} diff --git a/bindings/register/register.go b/bindings/register/register.go new file mode 100644 index 0000000..f1d3784 --- /dev/null +++ b/bindings/register/register.go @@ -0,0 +1,128 @@ +package register + +import ( + actionbinding "dappco.re/go/py/bindings/action" + "dappco.re/go/py/bindings/agent" + "dappco.re/go/py/bindings/api" + "dappco.re/go/py/bindings/array" + "dappco.re/go/py/bindings/cache" + "dappco.re/go/py/bindings/config" + "dappco.re/go/py/bindings/container" + cryptobinding "dappco.re/go/py/bindings/crypto" + "dappco.re/go/py/bindings/data" + dnsbinding "dappco.re/go/py/bindings/dns" + "dappco.re/go/py/bindings/echo" + entitlementbinding "dappco.re/go/py/bindings/entitlement" + "dappco.re/go/py/bindings/err" + "dappco.re/go/py/bindings/fs" + i18nbinding "dappco.re/go/py/bindings/i18n" + infobinding "dappco.re/go/py/bindings/info" + "dappco.re/go/py/bindings/json" + "dappco.re/go/py/bindings/log" + mathbinding "dappco.re/go/py/bindings/math" + "dappco.re/go/py/bindings/mcp" + "dappco.re/go/py/bindings/medium" + "dappco.re/go/py/bindings/options" + pathbinding "dappco.re/go/py/bindings/path" + "dappco.re/go/py/bindings/process" + registrybinding "dappco.re/go/py/bindings/registry" + scmbinding "dappco.re/go/py/bindings/scm" + "dappco.re/go/py/bindings/service" + stdlibbinding "dappco.re/go/py/bindings/stdlib" + "dappco.re/go/py/bindings/store" + stringsbinding "dappco.re/go/py/bindings/strings" + taskbinding "dappco.re/go/py/bindings/task" + "dappco.re/go/py/bindings/ws" + "dappco.re/go/py/runtime" +) + +// ModuleSpec describes one CorePy binding module and its registration hook. +type ModuleSpec struct { + Name string + Register func(runtime.Interpreter) error +} + +// DefaultModuleSpecs returns the binding registry used by Tier 1 backends. +func DefaultModuleSpecs() []ModuleSpec { + return []ModuleSpec{ + {Name: "core.action", Register: actionbinding.Register}, + {Name: "core.agent", Register: agent.Register}, + {Name: "core.api", Register: api.Register}, + {Name: "core.array", Register: array.Register}, + {Name: "core.cache", Register: cache.Register}, + {Name: "core.container", Register: container.Register}, + {Name: "core.entitlement", Register: entitlementbinding.Register}, + {Name: "core.echo", Register: echo.Register}, + {Name: "core.fs", Register: fs.Register}, + {Name: "core.json", Register: json.Register}, + {Name: "core.medium", Register: medium.Register}, + {Name: "core.options", Register: options.Register}, + {Name: "core.path", Register: pathbinding.Register}, + {Name: "core.process", Register: process.Register}, + {Name: "core.config", Register: config.Register}, + {Name: "core.data", Register: data.Register}, + {Name: "core.i18n", Register: i18nbinding.Register}, + {Name: "core.info", Register: infobinding.Register}, + {Name: "core.service", Register: service.Register}, + {Name: "core.log", Register: log.Register}, + {Name: "core.err", Register: err.Register}, + {Name: "core.mcp", Register: mcp.Register}, + {Name: "core.crypto", Register: cryptobinding.Register}, + {Name: "core.dns", Register: dnsbinding.Register}, + {Name: "core.math", Register: mathbinding.Register}, + {Name: "core.registry", Register: registrybinding.Register}, + {Name: "core.scm", Register: scmbinding.Register}, + {Name: "core.store", Register: store.Register}, + {Name: "core.strings", Register: stringsbinding.Register}, + {Name: "core.task", Register: taskbinding.Register}, + {Name: "core.ws", Register: ws.Register}, + } +} + +// DefaultModuleNames returns the canonical default binding names. +func DefaultModuleNames() []string { + specs := DefaultModuleSpecs() + names := make([]string, 0, len(specs)) + for _, spec := range specs { + names = append(names, spec.Name) + } + return names +} + +// DefaultShadowModuleSpecs returns Python stdlib-shaped aliases that Tier 1 +// resolves to Core-backed primitives. +func DefaultShadowModuleSpecs() []ModuleSpec { + stdlibSpecs := stdlibbinding.Specs() + specs := make([]ModuleSpec, 0, len(stdlibSpecs)) + for _, spec := range stdlibSpecs { + specs = append(specs, ModuleSpec{Name: spec.Name, Register: spec.Register}) + } + return specs +} + +// DefaultShadowModuleNames returns the canonical stdlib shadow names. +func DefaultShadowModuleNames() []string { + specs := DefaultShadowModuleSpecs() + names := make([]string, 0, len(specs)) + for _, spec := range specs { + names = append(names, spec.Name) + } + return names +} + +// DefaultModules registers the bootstrap CorePy module set. +// +// register.DefaultModules(interpreter) +func DefaultModules(interpreter runtime.Interpreter) error { + for _, spec := range DefaultModuleSpecs() { + if err := spec.Register(interpreter); err != nil { + return err + } + } + for _, spec := range DefaultShadowModuleSpecs() { + if err := spec.Register(interpreter); err != nil { + return err + } + } + return nil +} diff --git a/bindings/registry/registry.go b/bindings/registry/registry.go new file mode 100644 index 0000000..ff752a0 --- /dev/null +++ b/bindings/registry/registry.go @@ -0,0 +1,229 @@ +package registry + +import ( + "fmt" // AX-6-exception: registry bootstrap validation reports dynamic Go types. + + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes Core registry helpers. +// +// registry.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.registry", + Documentation: "Named collection helpers for CorePy", + Functions: map[string]runtime.Function{ + "new": newRegistry, + "set": set, + "get": get, + "has": has, + "names": names, + "list": list, + "len": length, + "delete": deleteValue, + "disable": disable, + "enable": enable, + "disabled": disabled, + "lock": lock, + "locked": locked, + "seal": seal, + "sealed": sealed, + "open": open, + }, + }) +} + +func newRegistry(arguments ...any) (any, error) { + return core.NewRegistry[any](), nil +} + +func set(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.registry.set") + if err != nil { + return nil, err + } + name, err := typemap.ExpectString(arguments, 1, "core.registry.set") + if err != nil { + return nil, err + } + if len(arguments) < 3 { + return nil, fmt.Errorf("core.registry.set expected argument 2") + } + if _, err := typemap.ResultValue(registryValue.Set(name, arguments[2]), "core.registry.set"); err != nil { + return nil, err + } + return registryValue, nil +} + +func get(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.registry.get") + if err != nil { + return nil, err + } + name, err := typemap.ExpectString(arguments, 1, "core.registry.get") + if err != nil { + return nil, err + } + result := registryValue.Get(name) + if result.OK { + return result.Value, nil + } + if len(arguments) > 2 { + return arguments[2], nil + } + return nil, nil +} + +func has(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.registry.has") + if err != nil { + return nil, err + } + name, err := typemap.ExpectString(arguments, 1, "core.registry.has") + if err != nil { + return nil, err + } + return registryValue.Has(name), nil +} + +func names(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.registry.names") + if err != nil { + return nil, err + } + return registryValue.Names(), nil +} + +func list(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.registry.list") + if err != nil { + return nil, err + } + pattern, err := typemap.ExpectString(arguments, 1, "core.registry.list") + if err != nil { + return nil, err + } + return registryValue.List(pattern), nil +} + +func length(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.registry.len") + if err != nil { + return nil, err + } + return registryValue.Len(), nil +} + +func deleteValue(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.registry.delete") + if err != nil { + return nil, err + } + name, err := typemap.ExpectString(arguments, 1, "core.registry.delete") + if err != nil { + return nil, err + } + if _, err := typemap.ResultValue(registryValue.Delete(name), "core.registry.delete"); err != nil { + return nil, err + } + return true, nil +} + +func disable(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.registry.disable") + if err != nil { + return nil, err + } + name, err := typemap.ExpectString(arguments, 1, "core.registry.disable") + if err != nil { + return nil, err + } + if _, err := typemap.ResultValue(registryValue.Disable(name), "core.registry.disable"); err != nil { + return nil, err + } + return registryValue, nil +} + +func enable(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.registry.enable") + if err != nil { + return nil, err + } + name, err := typemap.ExpectString(arguments, 1, "core.registry.enable") + if err != nil { + return nil, err + } + if _, err := typemap.ResultValue(registryValue.Enable(name), "core.registry.enable"); err != nil { + return nil, err + } + return registryValue, nil +} + +func disabled(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.registry.disabled") + if err != nil { + return nil, err + } + name, err := typemap.ExpectString(arguments, 1, "core.registry.disabled") + if err != nil { + return nil, err + } + return registryValue.Disabled(name), nil +} + +func lock(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.registry.lock") + if err != nil { + return nil, err + } + registryValue.Lock() + return registryValue, nil +} + +func locked(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.registry.locked") + if err != nil { + return nil, err + } + return registryValue.Locked(), nil +} + +func seal(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.registry.seal") + if err != nil { + return nil, err + } + registryValue.Seal() + return registryValue, nil +} + +func sealed(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.registry.sealed") + if err != nil { + return nil, err + } + return registryValue.Sealed(), nil +} + +func open(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.registry.open") + if err != nil { + return nil, err + } + registryValue.Open() + return registryValue, nil +} + +func expectRegistry(arguments []any, index int, functionName string) (*core.Registry[any], error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(*core.Registry[any]) + if !ok { + return nil, fmt.Errorf("%s expected registry handle, got %T", functionName, arguments[index]) + } + return value, nil +} diff --git a/bindings/scm/scm.go b/bindings/scm/scm.go new file mode 100644 index 0000000..cf36a5b --- /dev/null +++ b/bindings/scm/scm.go @@ -0,0 +1,152 @@ +package scm + +import ( + "fmt" // AX-6-exception: SCM bootstrap preserves wrapped git command errors. + "os/exec" // AX-6-exception: SCM binding shells to git until go-scm is wired as the backing primitive. + "strings" // AX-6-exception: SCM parses git porcelain output with stdlib line helpers. + + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes Git-backed source-control helpers. +// +// scm.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.scm", + Documentation: "Git helpers for CorePy", + Functions: map[string]runtime.Function{ + "exists": exists, + "root": root, + "branch": branch, + "head": head, + "status": status, + "tracked_files": trackedFiles, + }, + }) +} + +func exists(arguments ...any) (any, error) { + directory, err := directoryArgument(arguments, "core.scm.exists") + if err != nil { + return nil, err + } + if _, err := exec.LookPath("git"); err != nil { + return false, nil + } + output, err := git(directory, false, "rev-parse", "--is-inside-work-tree") + if err != nil { + return false, nil + } + return strings.TrimSpace(output) == "true", nil +} + +func root(arguments ...any) (any, error) { + directory, err := directoryArgument(arguments, "core.scm.root") + if err != nil { + return nil, err + } + output, err := git(directory, true, "rev-parse", "--show-toplevel") + if err != nil { + return nil, err + } + return strings.TrimSpace(output), nil +} + +func branch(arguments ...any) (any, error) { + directory, err := directoryArgument(arguments, "core.scm.branch") + if err != nil { + return nil, err + } + output, err := git(directory, true, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return nil, err + } + return strings.TrimSpace(output), nil +} + +func head(arguments ...any) (any, error) { + directory, err := directoryArgument(arguments, "core.scm.head") + if err != nil { + return nil, err + } + output, err := git(directory, true, "rev-parse", "HEAD") + if err != nil { + return nil, err + } + return strings.TrimSpace(output), nil +} + +func status(arguments ...any) (any, error) { + directory, err := directoryArgument(arguments, "core.scm.status") + if err != nil { + return nil, err + } + output, err := git(directory, true, "status", "--short", "--branch") + if err != nil { + return nil, err + } + + lines := []string{} + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimRight(line, "\r") + if strings.TrimSpace(trimmed) == "" { + continue + } + lines = append(lines, trimmed) + } + + branchName := "" + if len(lines) > 0 && strings.HasPrefix(lines[0], "## ") { + branchName = strings.TrimSpace(strings.TrimPrefix(lines[0], "## ")) + lines = lines[1:] + } + + return map[string]any{ + "branch": branchName, + "clean": len(lines) == 0, + "changes": lines, + }, nil +} + +func trackedFiles(arguments ...any) (any, error) { + directory, err := directoryArgument(arguments, "core.scm.tracked_files") + if err != nil { + return nil, err + } + output, err := git(directory, true, "ls-files") + if err != nil { + return nil, err + } + files := []string{} + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + files = append(files, trimmed) + } + return files, nil +} + +func directoryArgument(arguments []any, functionName string) (string, error) { + if len(arguments) == 0 { + return ".", nil + } + return typemap.ExpectString(arguments, 0, functionName) +} + +func git(directory string, check bool, arguments ...string) (string, error) { + gitBinary, err := exec.LookPath("git") + if err != nil { + return "", fmt.Errorf("git is not available") + } + + command := exec.Command(gitBinary, append([]string{"-C", directory}, arguments...)...) + output, err := command.CombinedOutput() + if err != nil && check { + return "", fmt.Errorf("git %s failed: %w: %s", strings.Join(arguments, " "), err, strings.TrimSpace(string(output))) + } + return string(output), nil +} diff --git a/bindings/service/service.go b/bindings/service/service.go new file mode 100644 index 0000000..29905aa --- /dev/null +++ b/bindings/service/service.go @@ -0,0 +1,101 @@ +package service + +import ( + "context" + + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes Service bindings backed by dappco.re/go/core. +// +// service.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.service", + Documentation: "Service registry backed by dappco.re/go/core", + Functions: map[string]runtime.Function{ + "new": newCore, + "register": registerService, + "get": getService, + "names": names, + "start_all": startAll, + "stop_all": stopAll, + }, + }) +} + +func newCore(arguments ...any) (any, error) { + if len(arguments) == 0 { + return core.New(), nil + } + name, err := typemap.ExpectString(arguments, 0, "core.service.new") + if err != nil { + return nil, err + } + return core.New(core.WithOption("name", name)), nil +} + +func registerService(arguments ...any) (any, error) { + instance, err := typemap.ExpectCore(arguments, 0, "core.service.register") + if err != nil { + return nil, err + } + name, err := typemap.ExpectString(arguments, 1, "core.service.register") + if err != nil { + return nil, err + } + if len(arguments) > 2 { + if _, err := typemap.ResultValue(instance.RegisterService(name, arguments[2]), "core.service.register"); err != nil { + return nil, err + } + return instance, nil + } + if _, err := typemap.ResultValue(instance.Service(name, core.Service{}), "core.service.register"); err != nil { + return nil, err + } + return instance, nil +} + +func getService(arguments ...any) (any, error) { + instance, err := typemap.ExpectCore(arguments, 0, "core.service.get") + if err != nil { + return nil, err + } + name, err := typemap.ExpectString(arguments, 1, "core.service.get") + if err != nil { + return nil, err + } + return typemap.ResultValue(instance.Service(name), "core.service.get") +} + +func names(arguments ...any) (any, error) { + instance, err := typemap.ExpectCore(arguments, 0, "core.service.names") + if err != nil { + return nil, err + } + return instance.Services(), nil +} + +func startAll(arguments ...any) (any, error) { + instance, err := typemap.ExpectCore(arguments, 0, "core.service.start_all") + if err != nil { + return nil, err + } + if _, err := typemap.ResultValue(instance.ServiceStartup(context.Background(), nil), "core.service.start_all"); err != nil { + return nil, err + } + return true, nil +} + +func stopAll(arguments ...any) (any, error) { + instance, err := typemap.ExpectCore(arguments, 0, "core.service.stop_all") + if err != nil { + return nil, err + } + if _, err := typemap.ResultValue(instance.ServiceShutdown(context.Background()), "core.service.stop_all"); err != nil { + return nil, err + } + return true, nil +} diff --git a/bindings/stdlib/stdlib.go b/bindings/stdlib/stdlib.go new file mode 100644 index 0000000..5bdf634 --- /dev/null +++ b/bindings/stdlib/stdlib.go @@ -0,0 +1,552 @@ +// Package stdlib exposes small Python-standard-library-shaped modules backed +// by CorePy primitives. +// +// Tier 1 gpython does not carry CPython's C-backed stdlib modules. These +// shadows make common imports such as `import os`, `import json`, and +// `import subprocess` resolve to Core-backed helpers while keeping the broader +// module contract explicit. +package stdlib + +import ( + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" // AX-6-exception: stdlib shadow helpers report Python-shaped argument errors. + "net" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Spec describes one compatibility module. +type Spec struct { + Name string + Register func(runtime.Interpreter) error +} + +// Specs returns the Tier 1 stdlib shadow modules. +func Specs() []Spec { + return []Spec{ + {Name: "base64", Register: registerBase64}, + {Name: "hashlib", Register: registerHashlib}, + {Name: "json", Register: registerJSON}, + {Name: "logging", Register: registerLogging}, + {Name: "os", Register: registerOS}, + {Name: "os.path", Register: registerOSPath}, + {Name: "socket", Register: registerSocket}, + {Name: "subprocess", Register: registerSubprocess}, + } +} + +// Register registers all stdlib shadow modules. +func Register(interpreter runtime.Interpreter) error { + for _, spec := range Specs() { + if err := spec.Register(interpreter); err != nil { + return err + } + } + return nil +} + +func registerJSON(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "json", + Documentation: "Tier 1 JSON stdlib shadow backed by core JSON helpers", + Functions: map[string]runtime.Function{ + "dumps": jsonDumps, + "loads": jsonLoads, + }, + }) +} + +func jsonDumps(arguments ...any) (any, error) { + if len(arguments) != 1 { + return nil, core.E("json.dumps", "expected exactly one argument", nil) + } + return core.JSONMarshalString(arguments[0]), nil +} + +func jsonLoads(arguments ...any) (any, error) { + text, err := typemap.ExpectString(arguments, 0, "json.loads") + if err != nil { + return nil, err + } + var value any + if _, err := typemap.ResultValue(core.JSONUnmarshalString(text, &value), "json.loads"); err != nil { + return nil, err + } + return value, nil +} + +func registerOS(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "os", + Documentation: "Tier 1 os stdlib shadow backed by core fs/process/path helpers", + Functions: map[string]runtime.Function{ + "getcwd": osGetcwd, + "getenv": osGetenv, + "listdir": osListdir, + "makedirs": osMakedirs, + "remove": osRemove, + "system": osSystem, + }, + }) +} + +func osGetcwd(arguments ...any) (any, error) { + return os.Getwd() +} + +func osGetenv(arguments ...any) (any, error) { + key, err := typemap.ExpectString(arguments, 0, "os.getenv") + if err != nil { + return nil, err + } + if value, ok := os.LookupEnv(key); ok { + return value, nil + } + if len(arguments) > 1 { + return arguments[1], nil + } + return "", nil +} + +func osListdir(arguments ...any) (any, error) { + path, err := typemap.ExpectString(arguments, 0, "os.listdir") + if err != nil { + return nil, err + } + entries, err := os.ReadDir(path) + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + slices.Sort(names) + return names, nil +} + +func osMakedirs(arguments ...any) (any, error) { + path, err := typemap.ExpectString(arguments, 0, "os.makedirs") + if err != nil { + return nil, err + } + mode := 0755 + if len(arguments) > 1 { + mode, err = typemap.ExpectInt(arguments, 1, "os.makedirs") + if err != nil { + return nil, err + } + } + return nil, os.MkdirAll(path, os.FileMode(mode)) +} + +func osRemove(arguments ...any) (any, error) { + path, err := typemap.ExpectString(arguments, 0, "os.remove") + if err != nil { + return nil, err + } + return nil, os.Remove(path) +} + +func osSystem(arguments ...any) (any, error) { + command, err := typemap.ExpectString(arguments, 0, "os.system") + if err != nil { + return nil, err + } + cmd := exec.Command("sh", "-c", command) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode(), nil + } + return 1, err + } + return 0, nil +} + +func registerOSPath(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "os.path", + Documentation: "Tier 1 os.path stdlib shadow backed by core path helpers", + Functions: map[string]runtime.Function{ + "abspath": osPathAbsPath, + "basename": osPathBasename, + "dirname": osPathDirname, + "exists": osPathExists, + "isabs": osPathIsAbs, + "join": osPathJoin, + }, + }) +} + +func osPathAbsPath(arguments ...any) (any, error) { + path, err := typemap.ExpectString(arguments, 0, "os.path.abspath") + if err != nil { + return nil, err + } + return filepath.Abs(path) +} + +func osPathBasename(arguments ...any) (any, error) { + path, err := typemap.ExpectString(arguments, 0, "os.path.basename") + if err != nil { + return nil, err + } + return filepath.Base(path), nil +} + +func osPathDirname(arguments ...any) (any, error) { + path, err := typemap.ExpectString(arguments, 0, "os.path.dirname") + if err != nil { + return nil, err + } + return filepath.Dir(path), nil +} + +func osPathExists(arguments ...any) (any, error) { + path, err := typemap.ExpectString(arguments, 0, "os.path.exists") + if err != nil { + return nil, err + } + _, statErr := os.Stat(path) + if statErr == nil { + return true, nil + } + if os.IsNotExist(statErr) { + return false, nil + } + return nil, statErr +} + +func osPathIsAbs(arguments ...any) (any, error) { + path, err := typemap.ExpectString(arguments, 0, "os.path.isabs") + if err != nil { + return nil, err + } + return filepath.IsAbs(path), nil +} + +func osPathJoin(arguments ...any) (any, error) { + segments := make([]string, 0, len(arguments)) + for index := range arguments { + segment, err := typemap.ExpectString(arguments, index, "os.path.join") + if err != nil { + return nil, err + } + segments = append(segments, segment) + } + return filepath.Join(segments...), nil +} + +func registerSubprocess(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "subprocess", + Documentation: "Tier 1 subprocess stdlib shadow backed by core process helpers", + Functions: map[string]runtime.Function{ + "check_output": subprocessCheckOutput, + "getoutput": subprocessGetOutput, + "run": subprocessRun, + }, + }) +} + +func subprocessCheckOutput(arguments ...any) (any, error) { + result, err := runCommand("subprocess.check_output", arguments, true) + if err != nil { + return nil, err + } + return result["stdout"], nil +} + +func subprocessGetOutput(arguments ...any) (any, error) { + command, err := typemap.ExpectString(arguments, 0, "subprocess.getoutput") + if err != nil { + return nil, err + } + completed := exec.Command("sh", "-c", command) + output, err := completed.CombinedOutput() + if err != nil { + return string(output), nil + } + return string(output), nil +} + +func subprocessRun(arguments ...any) (any, error) { + return runCommand("subprocess.run", arguments, false) +} + +func runCommand(functionName string, arguments []any, check bool) (map[string]any, error) { + commandLine, err := commandLineArguments(arguments, functionName) + if err != nil { + return nil, err + } + cmd := exec.Command(commandLine[0], commandLine[1:]...) + output, err := cmd.Output() + stderr := "" + exitCode := 0 + if err != nil { + exitCode = -1 + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + stderr = string(exitErr.Stderr) + } + if check { + if stderr != "" { + return nil, fmt.Errorf("%s exited with status %d: %s", functionName, exitCode, firstLine(stderr)) + } + return nil, fmt.Errorf("%s exited with status %d: %w", functionName, exitCode, err) + } + } + return map[string]any{ + "args": commandLine, + "stdout": string(output), + "stderr": stderr, + "returncode": exitCode, + "timed_out": false, + "ok": exitCode == 0, + "core_shadow": true, + }, nil +} + +func commandLineArguments(arguments []any, functionName string) ([]string, error) { + if len(arguments) == 0 { + return nil, fmt.Errorf("%s expected command arguments", functionName) + } + switch typed := arguments[0].(type) { + case []any: + values := make([]string, 0, len(typed)) + for index, value := range typed { + text, ok := value.(string) + if !ok { + return nil, fmt.Errorf("%s expected command list item %d to be string, got %T", functionName, index, value) + } + values = append(values, text) + } + if len(values) == 0 { + return nil, fmt.Errorf("%s expected non-empty command list", functionName) + } + return values, nil + case []string: + if len(typed) == 0 { + return nil, fmt.Errorf("%s expected non-empty command list", functionName) + } + return append([]string(nil), typed...), nil + case string: + return append([]string{typed}, stringArguments(arguments[1:])...), nil + default: + return nil, fmt.Errorf("%s expected command string or list, got %T", functionName, arguments[0]) + } +} + +func stringArguments(arguments []any) []string { + values := make([]string, 0, len(arguments)) + for _, argument := range arguments { + values = append(values, fmt.Sprint(argument)) + } + return values +} + +func registerLogging(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "logging", + Documentation: "Tier 1 logging stdlib shadow backed by core log helpers", + Functions: map[string]runtime.Function{ + "basicConfig": loggingBasicConfig, + "debug": loggingDebug, + "info": loggingInfo, + "warning": loggingWarning, + "error": loggingError, + }, + }) +} + +func loggingBasicConfig(arguments ...any) (any, error) { + return nil, nil +} + +func loggingDebug(arguments ...any) (any, error) { + return logWith(core.Debug, "logging.debug", arguments...) +} + +func loggingInfo(arguments ...any) (any, error) { + return logWith(core.Info, "logging.info", arguments...) +} + +func loggingWarning(arguments ...any) (any, error) { + return logWith(core.Warn, "logging.warning", arguments...) +} + +func loggingError(arguments ...any) (any, error) { + return logWith(core.Error, "logging.error", arguments...) +} + +func logWith(fn func(string, ...any), functionName string, arguments ...any) (any, error) { + message, err := typemap.ExpectString(arguments, 0, functionName) + if err != nil { + return nil, err + } + fn(message, arguments[1:]...) + return nil, nil +} + +func registerHashlib(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "hashlib", + Documentation: "Tier 1 hashlib stdlib shadow backed by core crypto helpers", + Functions: map[string]runtime.Function{ + "_hexdigest": hashlibHexDigest, + "sha1": hashlibSHA1, + "sha256": hashlibSHA256, + }, + }) +} + +func hashlibSHA1(arguments ...any) (any, error) { + value, err := typemap.ExpectBytes(arguments, 0, "hashlib.sha1") + if err != nil { + return nil, err + } + sum := sha1.Sum(value) + return hexDigest{value: hex.EncodeToString(sum[:])}, nil +} + +func hashlibSHA256(arguments ...any) (any, error) { + value, err := typemap.ExpectBytes(arguments, 0, "hashlib.sha256") + if err != nil { + return nil, err + } + sum := sha256.Sum256(value) + return hexDigest{value: hex.EncodeToString(sum[:])}, nil +} + +type hexDigest struct { + value string +} + +func (digest hexDigest) ResolveAttribute(name string) (any, bool) { + switch name { + case "hexdigest": + return runtime.BoundMethod{ModuleName: "hashlib", FunctionName: "_hexdigest", Arguments: []any{digest}}, true + default: + return nil, false + } +} + +func hashlibHexDigest(arguments ...any) (any, error) { + if len(arguments) == 0 { + return nil, fmt.Errorf("hashlib._hexdigest expected digest handle") + } + digest, ok := arguments[0].(hexDigest) + if !ok { + return nil, fmt.Errorf("hashlib._hexdigest expected digest handle, got %T", arguments[0]) + } + return digest.value, nil +} + +func registerBase64(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "base64", + Documentation: "Tier 1 base64 stdlib shadow backed by core crypto helpers", + Functions: map[string]runtime.Function{ + "b64decode": base64Decode, + "b64encode": base64Encode, + }, + }) +} + +func base64Encode(arguments ...any) (any, error) { + value, err := typemap.ExpectBytes(arguments, 0, "base64.b64encode") + if err != nil { + return nil, err + } + return base64.StdEncoding.EncodeToString(value), nil +} + +func base64Decode(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "base64.b64decode") + if err != nil { + return nil, err + } + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return nil, err + } + return decoded, nil +} + +func registerSocket(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "socket", + Documentation: "Tier 1 socket DNS stdlib shadow backed by core DNS helpers", + Functions: map[string]runtime.Function{ + "gethostbyname": socketGetHostByName, + "gethostbyname_ex": socketGetHostByNameEx, + "getservbyname": socketGetServByName, + }, + }) +} + +func socketGetHostByName(arguments ...any) (any, error) { + host, err := typemap.ExpectString(arguments, 0, "socket.gethostbyname") + if err != nil { + return nil, err + } + addresses, err := net.LookupHost(host) + if err != nil { + return nil, err + } + if len(addresses) == 0 { + return nil, fmt.Errorf("socket.gethostbyname: no addresses for %s", host) + } + return addresses[0], nil +} + +func socketGetHostByNameEx(arguments ...any) (any, error) { + host, err := typemap.ExpectString(arguments, 0, "socket.gethostbyname_ex") + if err != nil { + return nil, err + } + addresses, err := net.LookupHost(host) + if err != nil { + return nil, err + } + slices.Sort(addresses) + return []any{host, []string{}, addresses}, nil +} + +func socketGetServByName(arguments ...any) (any, error) { + service, err := typemap.ExpectString(arguments, 0, "socket.getservbyname") + if err != nil { + return nil, err + } + network := "tcp" + if len(arguments) > 1 { + network, err = typemap.ExpectString(arguments, 1, "socket.getservbyname") + if err != nil { + return nil, err + } + } + return net.LookupPort(strings.ToLower(network), service) +} + +func firstLine(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if index := strings.IndexByte(value, '\n'); index != -1 { + return strings.TrimSpace(value[:index]) + } + return value +} diff --git a/bindings/store/store.go b/bindings/store/store.go new file mode 100644 index 0000000..367ec2a --- /dev/null +++ b/bindings/store/store.go @@ -0,0 +1,20 @@ +package store + +import "dappco.re/go/py/runtime" + +// Register exposes the planned Store module surface. +// +// store.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.store", + Documentation: "SQLite KV and workspace helpers for CorePy; native binding pending", + Functions: map[string]runtime.Function{ + "available": available, + }, + }) +} + +func available(arguments ...any) (any, error) { + return false, nil +} diff --git a/bindings/strings/strings.go b/bindings/strings/strings.go new file mode 100644 index 0000000..12bb13d --- /dev/null +++ b/bindings/strings/strings.go @@ -0,0 +1,201 @@ +package stringsbinding + +import ( + core "dappco.re/go/core" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +// Register exposes string helpers backed by dappco.re/go/core. +// +// stringsbinding.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.strings", + Documentation: "String helpers backed by dappco.re/go/core", + Functions: map[string]runtime.Function{ + "contains": contains, + "trim": trim, + "trim_prefix": trimPrefix, + "trim_suffix": trimSuffix, + "has_prefix": hasPrefix, + "has_suffix": hasSuffix, + "split": split, + "split_n": splitN, + "join": join, + "replace": replace, + "lower": lower, + "upper": upper, + "rune_count": runeCount, + "concat": concat, + }, + }) +} + +func contains(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.strings.contains") + if err != nil { + return nil, err + } + substring, err := typemap.ExpectString(arguments, 1, "core.strings.contains") + if err != nil { + return nil, err + } + return core.Contains(value, substring), nil +} + +func trim(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.strings.trim") + if err != nil { + return nil, err + } + return core.Trim(value), nil +} + +func trimPrefix(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.strings.trim_prefix") + if err != nil { + return nil, err + } + prefix, err := typemap.ExpectString(arguments, 1, "core.strings.trim_prefix") + if err != nil { + return nil, err + } + return core.TrimPrefix(value, prefix), nil +} + +func trimSuffix(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.strings.trim_suffix") + if err != nil { + return nil, err + } + suffix, err := typemap.ExpectString(arguments, 1, "core.strings.trim_suffix") + if err != nil { + return nil, err + } + return core.TrimSuffix(value, suffix), nil +} + +func hasPrefix(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.strings.has_prefix") + if err != nil { + return nil, err + } + prefix, err := typemap.ExpectString(arguments, 1, "core.strings.has_prefix") + if err != nil { + return nil, err + } + return core.HasPrefix(value, prefix), nil +} + +func hasSuffix(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.strings.has_suffix") + if err != nil { + return nil, err + } + suffix, err := typemap.ExpectString(arguments, 1, "core.strings.has_suffix") + if err != nil { + return nil, err + } + return core.HasSuffix(value, suffix), nil +} + +func split(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.strings.split") + if err != nil { + return nil, err + } + separator, err := typemap.ExpectString(arguments, 1, "core.strings.split") + if err != nil { + return nil, err + } + return core.Split(value, separator), nil +} + +func splitN(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.strings.split_n") + if err != nil { + return nil, err + } + separator, err := typemap.ExpectString(arguments, 1, "core.strings.split_n") + if err != nil { + return nil, err + } + limit, err := typemap.ExpectInt(arguments, 2, "core.strings.split_n") + if err != nil { + return nil, err + } + return core.SplitN(value, separator, limit), nil +} + +func join(arguments ...any) (any, error) { + separator, err := typemap.ExpectString(arguments, 0, "core.strings.join") + if err != nil { + return nil, err + } + parts, err := stringArguments(arguments, 1, "core.strings.join") + if err != nil { + return nil, err + } + return core.Join(separator, parts...), nil +} + +func replace(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.strings.replace") + if err != nil { + return nil, err + } + oldValue, err := typemap.ExpectString(arguments, 1, "core.strings.replace") + if err != nil { + return nil, err + } + newValue, err := typemap.ExpectString(arguments, 2, "core.strings.replace") + if err != nil { + return nil, err + } + return core.Replace(value, oldValue, newValue), nil +} + +func lower(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.strings.lower") + if err != nil { + return nil, err + } + return core.Lower(value), nil +} + +func upper(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.strings.upper") + if err != nil { + return nil, err + } + return core.Upper(value), nil +} + +func runeCount(arguments ...any) (any, error) { + value, err := typemap.ExpectString(arguments, 0, "core.strings.rune_count") + if err != nil { + return nil, err + } + return core.RuneCount(value), nil +} + +func concat(arguments ...any) (any, error) { + parts, err := stringArguments(arguments, 0, "core.strings.concat") + if err != nil { + return nil, err + } + return core.Concat(parts...), nil +} + +func stringArguments(arguments []any, startIndex int, functionName string) ([]string, error) { + values := make([]string, 0, len(arguments)-startIndex) + for index := startIndex; index < len(arguments); index++ { + value, err := typemap.ExpectString(arguments, index, functionName) + if err != nil { + return nil, err + } + values = append(values, value) + } + return values, nil +} diff --git a/bindings/task/task.go b/bindings/task/task.go new file mode 100644 index 0000000..396b268 --- /dev/null +++ b/bindings/task/task.go @@ -0,0 +1,377 @@ +package task + +import ( + "fmt" // AX-6-exception: task bootstrap validation reports dynamic Go types and action names. + + core "dappco.re/go/core" + actionbinding "dappco.re/go/py/bindings/action" + "dappco.re/go/py/bindings/typemap" + "dappco.re/go/py/runtime" +) + +type Step struct { + Action string + With map[string]any + Async bool + Input string +} + +type Handle struct { + Name string + Description string + Steps []Step +} + +type Registry = core.Registry[*Handle] + +// Register exposes Core task helpers. +// +// task.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.task", + Documentation: "Task composition helpers for CorePy", + Functions: map[string]runtime.Function{ + "new": newTask, + "new_registry": newRegistry, + "new_step": newStep, + "register": registerTask, + "get": getTask, + "names": names, + "run": run, + "exists": exists, + }, + }) +} + +func newTask(arguments ...any) (any, error) { + item := &Handle{} + if len(arguments) > 0 { + name, err := typemap.ExpectString(arguments, 0, "core.task.new") + if err != nil { + return nil, err + } + item.Name = name + } + if len(arguments) > 1 { + steps, err := parseSteps(arguments[1], "core.task.new") + if err != nil { + return nil, err + } + item.Steps = steps + } + if len(arguments) > 2 { + description, err := typemap.ExpectString(arguments, 2, "core.task.new") + if err != nil { + return nil, err + } + item.Description = description + } + return item, nil +} + +func newRegistry(arguments ...any) (any, error) { + return core.NewRegistry[*Handle](), nil +} + +func newStep(arguments ...any) (any, error) { + positional, keywordArguments := runtime.SplitKeywordArguments(arguments) + + actionName, err := typemap.ExpectString(positional, 0, "core.task.new_step") + if err != nil { + return nil, err + } + step := Step{Action: actionName, With: map[string]any{}} + if len(positional) > 1 { + withValues, err := typemap.ExpectMap(positional, 1, "core.task.new_step") + if err != nil { + return nil, err + } + step.With = withValues + } + if len(positional) > 2 { + async, ok := positional[2].(bool) + if !ok { + return nil, fmt.Errorf("core.task.new_step expected argument 2 to be bool, got %T", positional[2]) + } + step.Async = async + } + if len(positional) > 3 { + input, err := typemap.ExpectString(positional, 3, "core.task.new_step") + if err != nil { + return nil, err + } + step.Input = input + } + if len(keywordArguments) > 0 { + if withValues, ok := keywordArguments["with"].(map[string]any); ok { + step.With = cloneMap(withValues) + } + if withValues, ok := keywordArguments["with_values"].(map[string]any); ok { + step.With = cloneMap(withValues) + } + if asyncValue, exists := keywordArguments["async"]; exists { + asyncBool, ok := asyncValue.(bool) + if !ok { + return nil, fmt.Errorf("core.task.new_step expected keyword async to be bool, got %T", asyncValue) + } + step.Async = asyncBool + } + if asyncValue, exists := keywordArguments["async_step"]; exists { + asyncBool, ok := asyncValue.(bool) + if !ok { + return nil, fmt.Errorf("core.task.new_step expected keyword async_step to be bool, got %T", asyncValue) + } + step.Async = asyncBool + } + if inputValue, exists := keywordArguments["input"]; exists { + inputString, ok := inputValue.(string) + if !ok { + return nil, fmt.Errorf("core.task.new_step expected keyword input to be string, got %T", inputValue) + } + step.Input = inputString + } + } + return stepToMap(step), nil +} + +func registerTask(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.task.register") + if err != nil { + return nil, err + } + name, err := typemap.ExpectString(arguments, 1, "core.task.register") + if err != nil { + return nil, err + } + steps, err := parseSteps(arguments[2], "core.task.register") + if err != nil { + return nil, err + } + item := &Handle{Name: name, Steps: steps} + if len(arguments) > 3 { + description, err := typemap.ExpectString(arguments, 3, "core.task.register") + if err != nil { + return nil, err + } + item.Description = description + } + if _, err := typemap.ResultValue(registryValue.Set(name, item), "core.task.register"); err != nil { + return nil, err + } + return item, nil +} + +func getTask(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.task.get") + if err != nil { + return nil, err + } + name, err := typemap.ExpectString(arguments, 1, "core.task.get") + if err != nil { + return nil, err + } + result := registryValue.Get(name) + if !result.OK { + return &Handle{Name: name}, nil + } + return result.Value, nil +} + +func names(arguments ...any) (any, error) { + registryValue, err := expectRegistry(arguments, 0, "core.task.names") + if err != nil { + return nil, err + } + return registryValue.Names(), nil +} + +func run(arguments ...any) (any, error) { + item, err := expectHandle(arguments, 0, "core.task.run") + if err != nil { + return nil, err + } + actionRegistry, err := expectActionRegistry(arguments, 1, "core.task.run") + if err != nil { + return nil, err + } + options := map[string]any{} + if len(arguments) > 2 { + options, err = typemap.ExpectMap(arguments, 2, "core.task.run") + if err != nil { + return nil, err + } + } + return RunHandle(item, actionRegistry, options) +} + +func exists(arguments ...any) (any, error) { + item, err := expectHandle(arguments, 0, "core.task.exists") + if err != nil { + return nil, err + } + return len(item.Steps) > 0, nil +} + +// RunHandle executes a task with an action registry and runtime values. +func RunHandle(item *Handle, actions *actionbinding.Registry, options map[string]any) (any, error) { + if item == nil || len(item.Steps) == 0 { + name := "" + if item != nil && item.Name != "" { + name = item.Name + } + return nil, fmt.Errorf("task has no steps: %s", name) + } + + var ( + lastValue any + lastOK bool + ) + + for _, step := range item.Steps { + stepOptions := cloneMap(step.With) + if len(stepOptions) == 0 { + stepOptions = cloneMap(options) + } + if step.Input == "previous" && lastOK { + stepOptions["_input"] = lastValue + } + + actionResult := actions.Get(step.Action) + if !actionResult.OK { + return nil, fmt.Errorf("action not found: %s", step.Action) + } + actionValue := actionResult.Value.(*actionbinding.Handle) + + if step.Async { + go func(currentAction *actionbinding.Handle, currentOptions map[string]any) { + _, _ = actionbinding.RunHandle(currentAction, currentOptions) + }(actionValue, cloneMap(stepOptions)) + continue + } + + lastResult, err := actionbinding.RunHandle(actionValue, stepOptions) + if err != nil { + return nil, err + } + lastValue = lastResult + lastOK = true + } + return lastValue, nil +} + +func parseSteps(value any, functionName string) ([]Step, error) { + items, ok := value.([]any) + if !ok { + return nil, fmt.Errorf("%s expected steps to be []any, got %T", functionName, value) + } + + steps := make([]Step, 0, len(items)) + for _, item := range items { + switch typed := item.(type) { + case map[string]any: + step, err := mapToStep(typed, functionName) + if err != nil { + return nil, err + } + steps = append(steps, step) + default: + return nil, fmt.Errorf("%s expected step definitions to be map[string]any, got %T", functionName, item) + } + } + return steps, nil +} + +func mapToStep(values map[string]any, functionName string) (Step, error) { + actionValue, ok := values["action"].(string) + if !ok || actionValue == "" { + return Step{}, fmt.Errorf("%s expected step action to be a non-empty string", functionName) + } + + step := Step{Action: actionValue, With: map[string]any{}} + if withValues, ok := values["with"].(map[string]any); ok { + step.With = cloneMap(withValues) + } + if withValues, ok := values["with_values"].(map[string]any); ok { + step.With = cloneMap(withValues) + } + if asyncValue, exists := values["async"]; exists { + asyncBool, ok := asyncValue.(bool) + if !ok { + return Step{}, fmt.Errorf("%s expected step async to be bool, got %T", functionName, asyncValue) + } + step.Async = asyncBool + } + if asyncValue, exists := values["async_step"]; exists { + asyncBool, ok := asyncValue.(bool) + if !ok { + return Step{}, fmt.Errorf("%s expected step async_step to be bool, got %T", functionName, asyncValue) + } + step.Async = asyncBool + } + if inputValue, exists := values["input"]; exists { + inputString, ok := inputValue.(string) + if !ok { + return Step{}, fmt.Errorf("%s expected step input to be string, got %T", functionName, inputValue) + } + step.Input = inputString + } + return step, nil +} + +func stepToMap(step Step) map[string]any { + return map[string]any{ + "action": actionValue(step), + "with": cloneMap(step.With), + "async": step.Async, + "input": step.Input, + } +} + +func actionValue(step Step) string { + return step.Action +} + +func cloneMap(values map[string]any) map[string]any { + if len(values) == 0 { + return map[string]any{} + } + cloned := make(map[string]any, len(values)) + for key, value := range values { + cloned[key] = value + } + return cloned +} + +func expectRegistry(arguments []any, index int, functionName string) (*Registry, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(*Registry) + if !ok { + return nil, fmt.Errorf("%s expected task registry, got %T", functionName, arguments[index]) + } + return value, nil +} + +func expectHandle(arguments []any, index int, functionName string) (*Handle, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(*Handle) + if !ok { + return nil, fmt.Errorf("%s expected task handle, got %T", functionName, arguments[index]) + } + return value, nil +} + +func expectActionRegistry(arguments []any, index int, functionName string) (*actionbinding.Registry, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(*actionbinding.Registry) + if !ok { + return nil, fmt.Errorf("%s expected action registry, got %T", functionName, arguments[index]) + } + return value, nil +} diff --git a/bindings/typemap/typemap.go b/bindings/typemap/typemap.go new file mode 100644 index 0000000..332994f --- /dev/null +++ b/bindings/typemap/typemap.go @@ -0,0 +1,206 @@ +package typemap + +import ( + "fmt" // AX-6-exception: bootstrap type mapper reports dynamic Go types before gpython exception mapping lands. + "sort" + + core "dappco.re/go/core" +) + +// ResultValue unwraps a Core Result into a plain Go value. +// +// value, err := typemap.ResultValue(result, "core.fs.read_file") +func ResultValue(result core.Result, functionName string) (any, error) { + if result.OK { + return result.Value, nil + } + if result.Value == nil { + return nil, fmt.Errorf("%s failed", functionName) + } + if err, ok := result.Value.(error); ok { + return nil, err + } + return nil, fmt.Errorf("%s failed: %v", functionName, result.Value) +} + +// ExpectString returns the string argument at the given index. +// +// path, err := typemap.ExpectString(arguments, 0, "core.fs.read_file") +func ExpectString(arguments []any, index int, functionName string) (string, error) { + if index >= len(arguments) { + return "", fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(string) + if !ok { + return "", fmt.Errorf("%s expected argument %d to be string, got %T", functionName, index, arguments[index]) + } + return value, nil +} + +// ExpectInt returns the int argument at the given index. +// +// limit, err := typemap.ExpectInt(arguments, 2, "core.strings.split_n") +func ExpectInt(arguments []any, index int, functionName string) (int, error) { + if index >= len(arguments) { + return 0, fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(int) + if !ok { + return 0, fmt.Errorf("%s expected argument %d to be int, got %T", functionName, index, arguments[index]) + } + return value, nil +} + +// ExpectBytes returns a byte slice argument at the given index. +// +// content, err := typemap.ExpectBytes(arguments, 1, "core.fs.write_bytes") +func ExpectBytes(arguments []any, index int, functionName string) ([]byte, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + + switch typed := arguments[index].(type) { + case []byte: + return append([]byte(nil), typed...), nil + case string: + return []byte(typed), nil + default: + return nil, fmt.Errorf("%s expected argument %d to be []byte, got %T", functionName, index, arguments[index]) + } +} + +// ExpectMap returns the map argument at the given index. +// +// values, err := typemap.ExpectMap(arguments, 0, "core.options.new") +func ExpectMap(arguments []any, index int, functionName string) (map[string]any, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(map[string]any) + if !ok { + return nil, fmt.Errorf("%s expected argument %d to be map[string]any, got %T", functionName, index, arguments[index]) + } + return value, nil +} + +// ExpectOptions returns an Options pointer from either a pointer, value, or map. +// +// options, err := typemap.ExpectOptions(arguments, 0, "core.options.set") +func ExpectOptions(arguments []any, index int, functionName string) (*core.Options, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + + switch typed := arguments[index].(type) { + case *core.Options: + return typed, nil + case core.Options: + options := typed + return &options, nil + case map[string]any: + return MapToOptions(typed), nil + default: + return nil, fmt.Errorf("%s expected Options-compatible value, got %T", functionName, arguments[index]) + } +} + +// OptionsToMap returns a map copy of the option items. +// +// values := typemap.OptionsToMap(options) +func OptionsToMap(options *core.Options) map[string]any { + values := map[string]any{} + if options == nil { + return values + } + for _, item := range options.Items() { + values[item.Key] = item.Value + } + return values +} + +// MapToOptions converts a Python-style dict into Core Options. +// +// options := typemap.MapToOptions(map[string]any{"name": "corepy"}) +func MapToOptions(values map[string]any) *core.Options { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + + items := make([]core.Option, 0, len(keys)) + for _, key := range keys { + items = append(items, core.Option{Key: key, Value: values[key]}) + } + options := core.NewOptions(items...) + return &options +} + +// ExpectConfig returns a Config pointer. +// +// config, err := typemap.ExpectConfig(arguments, 0, "core.config.set") +func ExpectConfig(arguments []any, index int, functionName string) (*core.Config, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(*core.Config) + if !ok { + return nil, fmt.Errorf("%s expected *core.Config, got %T", functionName, arguments[index]) + } + return value, nil +} + +// ExpectData returns a Data pointer. +// +// data, err := typemap.ExpectData(arguments, 0, "core.data.mount_path") +func ExpectData(arguments []any, index int, functionName string) (*core.Data, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(*core.Data) + if !ok { + return nil, fmt.Errorf("%s expected *core.Data, got %T", functionName, arguments[index]) + } + return value, nil +} + +// ExpectCore returns a Core pointer. +// +// instance, err := typemap.ExpectCore(arguments, 0, "core.service.register") +func ExpectCore(arguments []any, index int, functionName string) (*core.Core, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(*core.Core) + if !ok { + return nil, fmt.Errorf("%s expected *core.Core, got %T", functionName, arguments[index]) + } + return value, nil +} + +// ExpectError returns an error argument. +// +// err, convErr := typemap.ExpectError(arguments, 0, "core.err.wrap") +func ExpectError(arguments []any, index int, functionName string) (error, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + value, ok := arguments[index].(error) + if !ok { + return nil, fmt.Errorf("%s expected error argument, got %T", functionName, arguments[index]) + } + return value, nil +} + +// OptionalError returns an error argument or nil when the value is None/nil. +// +// err, convErr := typemap.OptionalError(arguments, 0, "core.err.wrap") +func OptionalError(arguments []any, index int, functionName string) (error, error) { + if index >= len(arguments) { + return nil, fmt.Errorf("%s expected argument %d", functionName, index) + } + if arguments[index] == nil { + return nil, nil + } + return ExpectError(arguments, index, functionName) +} diff --git a/bindings/ws/ws.go b/bindings/ws/ws.go new file mode 100644 index 0000000..b0464b5 --- /dev/null +++ b/bindings/ws/ws.go @@ -0,0 +1,20 @@ +package ws + +import "dappco.re/go/py/runtime" + +// Register exposes the planned WebSocket module surface. +// +// ws.Register(interpreter) +func Register(interpreter runtime.Interpreter) error { + return interpreter.RegisterModule(runtime.Module{ + Name: "core.ws", + Documentation: "WebSocket helpers for CorePy; native binding pending", + Functions: map[string]runtime.Function{ + "available": available, + }, + }) +} + +func available(arguments ...any) (any, error) { + return false, nil +} diff --git a/cmd/corepy/main.go b/cmd/corepy/main.go new file mode 100644 index 0000000..66d0efa --- /dev/null +++ b/cmd/corepy/main.go @@ -0,0 +1,299 @@ +package main + +import ( + "bufio" + "context" + "flag" + "fmt" + "io" + "os" + "strings" + "time" + + "dappco.re/go/py/bindings/register" + corepyruntime "dappco.re/go/py/runtime" + "dappco.re/go/py/runtime/tier2" +) + +const version = "0.5.0" + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(arguments []string) error { + return runWithIO(arguments, os.Stdout, os.Stderr) +} + +func runWithIO(arguments []string, stdout io.Writer, stderr io.Writer) error { + return runWithStreams(arguments, os.Stdin, stdout, stderr) +} + +func runWithStreams(arguments []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { + if len(arguments) == 0 { + return usageError() + } + + switch arguments[0] { + case "run": + return runScript(arguments[1:], stdout, stderr) + case "repl": + return runREPL(arguments[1:], stdin, stdout, stderr) + case "modules": + return listModules(arguments[1:], stdout, stderr) + case "tier2": + return runTier2(arguments[1:], stdout, stderr) + case "version": + return printVersion(arguments[1:], stdout) + default: + return usageError() + } +} + +func runScript(arguments []string, stdout io.Writer, stderr io.Writer) error { + flags := flag.NewFlagSet("corepy run", flag.ContinueOnError) + flags.SetOutput(stderr) + backend := flags.String("backend", corepyruntime.BackendBootstrap, "runtime backend: bootstrap or gpython") + expression := flags.String("e", "", "execute source string") + tier2Fallback := flags.Bool("tier2-fallback", false, "retry unsupported Tier 1 imports with Tier 2 CPython") + tier2Python := flags.String("python", "", "host Python executable for -tier2-fallback") + tier2Timeout := flags.Duration("timeout", 0, "Tier 2 fallback timeout, for example 10s or 250ms") + if err := flags.Parse(arguments); err != nil { + return err + } + + var ( + source string + filename string + ) + switch { + case strings.TrimSpace(*expression) != "": + source = *expression + case flags.NArg() == 1: + filename = flags.Arg(0) + content, err := os.ReadFile(flags.Arg(0)) + if err != nil { + return fmt.Errorf("corepy run: read %s: %w", flags.Arg(0), err) + } + source = string(content) + default: + return fmt.Errorf("usage: corepy run [-backend bootstrap|gpython] [-tier2-fallback] [-python python3] [-timeout 10s] [-e source] [file.py]") + } + + interpreter, err := newInterpreter(*backend) + if err != nil { + return err + } + defer interpreter.Close() + + output, err := interpreter.Run(source) + if err != nil { + if !*tier2Fallback || !corepyruntime.IsTier2FallbackCandidate(err) { + return err + } + return runTier2Fallback(filename, source, *tier2Python, *tier2Timeout, stdout, stderr) + } + _, err = io.WriteString(stdout, output) + return err +} + +func runTier2Fallback(filename string, source string, python string, timeout time.Duration, stdout io.Writer, stderr io.Writer) error { + runner := tier2.NewRunner(tier2.Options{ + Python: python, + PythonPath: localPythonPath(), + Timeout: timeout, + Stdout: stdout, + Stderr: stderr, + }) + if filename != "" { + _, err := runner.RunFile(context.Background(), filename) + return err + } + _, err := runner.RunSource(context.Background(), source) + return err +} + +func runREPL(arguments []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { + flags := flag.NewFlagSet("corepy repl", flag.ContinueOnError) + flags.SetOutput(stderr) + backend := flags.String("backend", corepyruntime.BackendBootstrap, "runtime backend: bootstrap or gpython") + if err := flags.Parse(arguments); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("usage: corepy repl [-backend bootstrap|gpython]") + } + + interpreter, err := newInterpreter(*backend) + if err != nil { + return err + } + defer interpreter.Close() + + sessionCreator, ok := interpreter.(corepyruntime.SessionCreator) + if !ok { + return fmt.Errorf("corepy repl: backend %q does not support stateful sessions", *backend) + } + session := sessionCreator.NewSession() + + scanner := bufio.NewScanner(stdin) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if line == ":quit" || line == "quit()" || line == "exit()" { + break + } + + output, err := session.Run(line) + if err != nil { + fmt.Fprintln(stderr, err) + continue + } + if _, err := io.WriteString(stdout, output); err != nil { + return err + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("corepy repl: read input: %w", err) + } + return nil +} + +func listModules(arguments []string, stdout io.Writer, stderr io.Writer) error { + flags := flag.NewFlagSet("corepy modules", flag.ContinueOnError) + flags.SetOutput(stderr) + backend := flags.String("backend", corepyruntime.BackendBootstrap, "runtime backend: bootstrap or gpython") + if err := flags.Parse(arguments); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("usage: corepy modules [-backend bootstrap|gpython]") + } + + interpreter, err := newInterpreter(*backend) + if err != nil { + return err + } + defer interpreter.Close() + + lister, ok := interpreter.(corepyruntime.ModuleLister) + if !ok { + return fmt.Errorf("corepy modules: backend does not expose module listing") + } + for _, name := range lister.Modules() { + fmt.Fprintln(stdout, name) + } + return nil +} + +func runTier2(arguments []string, stdout io.Writer, stderr io.Writer) error { + if len(arguments) == 0 { + return tier2UsageError() + } + + switch arguments[0] { + case "run": + return runTier2Script(arguments[1:], stdout, stderr) + case "which": + return printTier2Python(arguments[1:], stdout, stderr) + default: + return tier2UsageError() + } +} + +func runTier2Script(arguments []string, stdout io.Writer, stderr io.Writer) error { + flags := flag.NewFlagSet("corepy tier2 run", flag.ContinueOnError) + flags.SetOutput(stderr) + python := flags.String("python", "", "host Python executable") + timeout := flags.Duration("timeout", 0, "timeout, for example 10s or 250ms") + expression := flags.String("e", "", "execute source string") + if err := flags.Parse(arguments); err != nil { + return err + } + + var ( + source string + filename string + ) + switch { + case strings.TrimSpace(*expression) != "": + source = *expression + case flags.NArg() == 1: + filename = flags.Arg(0) + default: + return fmt.Errorf("usage: corepy tier2 run [-python python3] [-timeout 10s] [-e source] [file.py]") + } + + runner := tier2.NewRunner(tier2.Options{ + Python: *python, + PythonPath: localPythonPath(), + Timeout: *timeout, + Stdout: stdout, + Stderr: stderr, + }) + if source != "" { + _, err := runner.RunSource(context.Background(), source) + return err + } + _, err := runner.RunFile(context.Background(), filename) + return err +} + +func printTier2Python(arguments []string, stdout io.Writer, stderr io.Writer) error { + flags := flag.NewFlagSet("corepy tier2 which", flag.ContinueOnError) + flags.SetOutput(stderr) + python := flags.String("python", "", "host Python executable") + if err := flags.Parse(arguments); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("usage: corepy tier2 which [-python python3]") + } + + path, err := tier2.ResolvePython(*python) + if err != nil { + return err + } + _, err = fmt.Fprintln(stdout, path) + return err +} + +func printVersion(arguments []string, stdout io.Writer) error { + if len(arguments) != 0 { + return fmt.Errorf("usage: corepy version") + } + _, err := fmt.Fprintf(stdout, "corepy %s backend=%s tier2=cpython-subprocess\n", version, corepyruntime.BackendBootstrap) + return err +} + +func newInterpreter(backend string) (corepyruntime.Interpreter, error) { + interpreter, err := corepyruntime.New(corepyruntime.Options{Backend: backend}) + if err != nil { + return nil, err + } + if err := register.DefaultModules(interpreter); err != nil { + _ = interpreter.Close() + return nil, err + } + return interpreter, nil +} + +func localPythonPath() []string { + if path, ok := tier2.LocalPythonPath(""); ok { + return []string{path} + } + return nil +} + +func usageError() error { + return fmt.Errorf("usage: corepy run [-backend bootstrap|gpython] [-tier2-fallback] [-python python3] [-timeout 10s] [-e source] [file.py] | corepy repl [-backend bootstrap|gpython] | corepy modules [-backend bootstrap|gpython] | corepy tier2 run|which | corepy version") +} + +func tier2UsageError() error { + return fmt.Errorf("usage: corepy tier2 run [-python python3] [-timeout %s] [-e source] [file.py] | corepy tier2 which [-python python3]", (10 * time.Second).String()) +} diff --git a/cmd/corepy/main_test.go b/cmd/corepy/main_test.go new file mode 100644 index 0000000..f7efdf5 --- /dev/null +++ b/cmd/corepy/main_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "bytes" + "strings" + "testing" + + "dappco.re/go/py/runtime/tier2" +) + +func TestRun_RunExpression_Good(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := runWithIO([]string{"run", "-e", `from core import echo; print(echo("cli"))`}, &stdout, &stderr) + if err != nil { + t.Fatalf("run expression: %v stderr=%q", err, stderr.String()) + } + if strings.TrimSpace(stdout.String()) != "cli" { + t.Fatalf("unexpected stdout %q", stdout.String()) + } +} + +func TestRun_RunExpressionTier2Fallback_Good(t *testing.T) { + requireTier2Python(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := runWithIO([]string{"run", "-tier2-fallback", "-e", `import sys; from core import echo; print(echo("tier2"), sys.version_info[0])`}, &stdout, &stderr) + if err != nil { + t.Fatalf("run expression with tier2 fallback: %v stderr=%q", err, stderr.String()) + } + if strings.TrimSpace(stdout.String()) != "tier2 3" { + t.Fatalf("unexpected stdout %q", stdout.String()) + } +} + +func TestRun_RunExpressionNoFallback_Bad(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := runWithIO([]string{"run", "-e", `import sys; print("tier2")`}, &stdout, &stderr) + if err == nil { + t.Fatal("expected unsupported import without fallback") + } + if !strings.Contains(err.Error(), "unsupported import sys") { + t.Fatalf("unexpected error %v", err) + } + if stdout.String() != "" { + t.Fatalf("expected no stdout, got %q", stdout.String()) + } +} + +func TestRun_ReplPreservesNamespace_Good(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + stdin := strings.NewReader("from core import echo\nmessage = echo(\"repl\")\nprint(message)\n:quit\n") + + err := runWithStreams([]string{"repl"}, stdin, &stdout, &stderr) + if err != nil { + t.Fatalf("run repl: %v stderr=%q", err, stderr.String()) + } + if strings.TrimSpace(stdout.String()) != "repl" { + t.Fatalf("unexpected stdout %q", stdout.String()) + } + if stderr.String() != "" { + t.Fatalf("unexpected stderr %q", stderr.String()) + } +} + +func TestRun_Modules_Good(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := runWithIO([]string{"modules"}, &stdout, &stderr) + if err != nil { + t.Fatalf("list modules: %v stderr=%q", err, stderr.String()) + } + output := stdout.String() + for _, expected := range []string{"core", "core.fs", "core.process", "core.math.kdtree"} { + if !strings.Contains(output, expected+"\n") { + t.Fatalf("expected %s in module list, got %q", expected, output) + } + } +} + +func TestRun_Tier2Which_Good(t *testing.T) { + requireTier2Python(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := runWithIO([]string{"tier2", "which"}, &stdout, &stderr) + if err != nil { + t.Fatalf("tier2 which: %v stderr=%q", err, stderr.String()) + } + if strings.TrimSpace(stdout.String()) == "" { + t.Fatal("expected tier2 python path") + } +} + +func TestRun_Tier2RunExpression_Good(t *testing.T) { + requireTier2Python(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := runWithIO([]string{"tier2", "run", "-e", `from core import echo; print(echo("tier2-cli"))`}, &stdout, &stderr) + if err != nil { + t.Fatalf("tier2 run: %v stderr=%q", err, stderr.String()) + } + if strings.TrimSpace(stdout.String()) != "tier2-cli" { + t.Fatalf("unexpected stdout %q", stdout.String()) + } +} + +func TestRun_Tier2RunFailure_Bad(t *testing.T) { + requireTier2Python(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + err := runWithIO([]string{"tier2", "run", "-e", `import sys; print("nope", file=sys.stderr); sys.exit(4)`}, &stdout, &stderr) + if err == nil { + t.Fatal("expected tier2 failure") + } + if !strings.Contains(err.Error(), "nope") { + t.Fatalf("expected stderr in error, got %v", err) + } + if strings.TrimSpace(stderr.String()) != "nope" { + t.Fatalf("expected streamed stderr, got %q", stderr.String()) + } +} + +func requireTier2Python(t *testing.T) { + t.Helper() + + if _, err := tier2.ResolvePython(""); err != nil { + t.Skipf("Tier 2 CPython is not available in this environment: %v", err) + } +} diff --git a/docs/gpython-readiness.md b/docs/gpython-readiness.md new file mode 100644 index 0000000..6b5f217 --- /dev/null +++ b/docs/gpython-readiness.md @@ -0,0 +1,134 @@ +# gpython Readiness Audit + +Pass 4 audit for swapping the bootstrap interpreter to the planned +`LetheanNetwork/gpython` backend without rewriting primitive bindings. + +## Pass 4 Boundary + +The public runtime selector now routes `Options{Backend: "gpython"}` through a +build-tagged factory: + +- default builds still return `BackendNotBuiltError` +- `-tags gpython` builds instantiate `runtime/gpython` +- `runtime/gpython` currently delegates to the bootstrap interpreter as a smoke + shell +- TODO: replace that package-local delegate with the real + `LetheanNetwork/gpython` fork, `py.RegisterModule`, `py.METH_VARARGS`, and + `py.METH_KEYWORDS` wiring + +This keeps the gpython integration boundary testable without making the default +build depend on a fork that is not vendored here yet. + +Pass 4 adds two pieces that should survive the real gpython swap: + +- stdlib-shaped shadow modules for `json`, `os`, `os.path`, `subprocess`, + `logging`, `hashlib`, `base64`, and `socket`; these are registered beside + `core.*` modules and route common Python imports to Core-backed primitives +- a typed unsupported-import error that the CLI can use for + `corepy run -tier2-fallback`, retrying the script in Tier 2 CPython only when + Tier 1 cannot satisfy the import table + +The real gpython backend should preserve that import policy: first try native +Tier 1 Core/shadow modules, then surface unsupported imports as fallback +eligible instead of mixing implicit CPython execution into the interpreter. + +## Audit Basis + +Upstream gpython exposes modules through `py.RegisterModule` / `py.ModuleImpl` +and method functions shaped as `py.PyCFunction`, `py.PyCFunctionNoArgs`, and +`py.PyCFunctionWithKeywords`, where arguments arrive as `py.Tuple` plus optional +`py.StringDict` keyword arguments. `py.ParseTuple`, `py.ParseTupleAndKeywords`, +and `py.UnpackTuple` are the native parse surface. + +References: +- https://pkg.go.dev/github.com/go-python/gpython/py +- https://github.com/go-python/gpython + +The existing CorePy bindings all use `runtime.Function func(...any) (any, +error)`. That is acceptable if the gpython backend provides one shared adapter: + +1. Convert `py.Tuple` / `py.StringDict` to `[]any` plus trailing + `runtime.KeywordArguments`. +2. Invoke the existing `runtime.Function`. +3. Convert the returned Go value or error back to `py.Object` / Python + exception. + +Bindings marked READY need only that shared adapter. Bindings marked +NEEDS-ADAPTATION need module-specific Python-visible handle, class, callable, or +protocol wrappers in addition to the shared adapter. No current binding is +blocked by an absent gpython feature. + +## Summary + +| Status | Count | Percent | +|---|---:|---:| +| READY | 17 | 54.8% | +| NEEDS-ADAPTATION | 14 | 45.2% | +| BLOCKED | 0 | 0.0% | + +## Per-Binding Audit + +| Binding | Status | Notes | +|---|---|---| +| `action` | NEEDS-ADAPTATION | Stores and invokes callables. gpython needs a callable proxy that can hold a `py.Object`, call it with converted context/options, and preserve the existing Go `runtime.Function` path. | +| `agent` | READY | Stub exposes `available() -> bool`; no handle or keyword behavior. | +| `api` | READY | Stub exposes `available() -> bool`; no handle or keyword behavior. | +| `array` | NEEDS-ADAPTATION | Returns an internal Go handle. Needs a Python class/capsule wrapper so subsequent calls can safely recover the Go array handle. | +| `cache` | NEEDS-ADAPTATION | Returns a cache handle and persists JSON-like maps. Needs handle wrapping plus map/list conversion in typemap. | +| `config` | NEEDS-ADAPTATION | Returns `*core.Config`. Needs a Python-visible config handle and method/module-level parity wrappers. | +| `container` | READY | Stub exposes `available() -> bool`; no handle or keyword behavior. | +| `crypto` | READY | Primitive string/bytes/int inputs and string/bytes/bool outputs; suitable for shared tuple/typemap adapter. | +| `data` | NEEDS-ADAPTATION | Returns `*core.Data` and mounts host paths. Needs handle wrapping and careful path/bytes/list conversion. | +| `dns` | READY | String inputs with string/list/int outputs; no persistent handles. | +| `echo` | READY | Single object round-trip; useful as the first gpython adapter proof. | +| `entitlement` | NEEDS-ADAPTATION | Returns a Core entitlement value with Python method parity in shims. Needs struct/class wrapping or stable value conversion. | +| `err` | NEEDS-ADAPTATION | Returns and accepts Go `error` values. Needs scoped Python exception objects that preserve operation, code, root, and wrapping semantics. | +| `fs` | READY | Path strings and bytes/text payloads only; maps cleanly through shared typemap. | +| `i18n` | NEEDS-ADAPTATION | Accepts translator interfaces. Needs Python protocol adapter for `translate`, `set_language`, `language`, and `available_languages`. | +| `info` | READY | No-arg/string inputs and map/list/string outputs; no persistent handles. | +| `json` | READY | JSON text and JSON-like primitives/lists/maps; depends only on shared map/list conversion. | +| `log` | READY | Level/message string calls and variadic key/value payloads; can ride shared tuple conversion. | +| `math` | NEEDS-ADAPTATION | Scalar/list helpers are READY, but `kdtree.build` returns a handle with methods and keyword arguments. Needs class wrapper for KDTree and keyword-aware adapters. | +| `mcp` | READY | Stub exposes `available() -> bool`; no handle or keyword behavior. | +| `medium` | NEEDS-ADAPTATION | Returns file/memory medium handles and reads/writes bytes. Needs handle wrapping and lifetime rules for file-backed state. | +| `options` | NEEDS-ADAPTATION | Returns `*core.Options`. Needs a Python-visible options handle and module-level helper parity. | +| `path` | READY | String/list helpers over path primitives; no persistent handles. | +| `process` | READY | Go-backed process execution lets Tier 1 avoid Python `subprocess`; inputs/outputs are strings, lists, and env maps. | +| `registry` | NEEDS-ADAPTATION | Returns a generic registry handle. Needs Python-visible handle, mutability guard errors, and safe `any` item conversion. | +| `scm` | READY | String path inputs and string/list/map outputs; no persistent handles. | +| `service` | NEEDS-ADAPTATION | Stores services and may call lifecycle interfaces. Needs Python protocol wrappers for startup/shutdown-capable objects. | +| `store` | READY | Stub exposes `available() -> bool`; no handle or keyword behavior. | +| `strings` | READY | String/list/int helpers; no persistent handles. | +| `task` | NEEDS-ADAPTATION | Uses task handles and action registry integration. Needs handle wrappers and callable bridge parity with `action`. | +| `ws` | READY | Stub exposes `available() -> bool`; no handle or keyword behavior. | + +## Stdlib Shadow Audit + +| Import | Tier 1 backing | Coverage | +|---|---|---| +| `json` | `core.JSONMarshalString` / `core.JSONUnmarshalString` | `dumps`, `loads` | +| `os` | Go `os` plus Core path/process conventions | `getcwd`, `getenv`, `listdir`, `makedirs`, `remove`, `system` | +| `os.path` | Go filepath/Core path semantics | `abspath`, `basename`, `dirname`, `exists`, `isabs`, `join` | +| `subprocess` | Go `os/exec`, matching `core.process` result shape | `check_output`, `getoutput`, `run` | +| `logging` | Core log functions | `basicConfig`, `debug`, `info`, `warning`, `error` | +| `hashlib` | Go crypto hashes | `sha1`, `sha256`, `hexdigest` handles | +| `base64` | Go base64 codec | `b64encode`, `b64decode` | +| `socket` | Go `net` DNS/service lookup | `gethostbyname`, `gethostbyname_ex`, `getservbyname` | + +## Estimated Lift + +Minimum swap once `LetheanNetwork/gpython` is available: + +- Shared gpython backend shell, stdout capture, module registration selector: + 1-2 days. +- Shared `py.Tuple` / `py.StringDict` / `py.Object` typemap adapter for + primitives, bytes, lists, maps, and errors: 2-4 days. +- Python-visible handle/class wrappers for stateful modules (`array`, `cache`, + `config`, `data`, `math.kdtree`, `medium`, `options`, `registry`): 3-5 days. +- Callable/protocol adapters for `action`, `task`, `i18n`, and `service`: + 3-5 days. +- Parity pass over examples, CLI, and bootstrap/gpython backend tests: 2-3 days. + +Pragmatic estimate: roughly 2 engineering weeks for a reliable gpy-0.2 swap if +the fork already builds, supports the required syntax subset, and does not +require gpython runtime patches beyond module registration and stdout capture. diff --git a/examples/echo.py b/examples/echo.py new file mode 100644 index 0000000..8170812 --- /dev/null +++ b/examples/echo.py @@ -0,0 +1,4 @@ +from core import echo + + +print(echo("hello")) diff --git a/examples/filesystem.py b/examples/filesystem.py new file mode 100644 index 0000000..7ee4ac9 --- /dev/null +++ b/examples/filesystem.py @@ -0,0 +1,5 @@ +from core import fs, json + + +target = fs.write_file("/tmp/corepy-example.json", json.dumps({"name": "corepy"})) +print(fs.read_file(target)) diff --git a/examples/math.py b/examples/math.py new file mode 100644 index 0000000..9fa5430 --- /dev/null +++ b/examples/math.py @@ -0,0 +1,8 @@ +from core import math + + +scores = [0.2, 0.4, 0.9] +print(math.mean(scores)) + +tree = math.kdtree.build([[0.0, 0.0], [1.0, 1.0], [3.0, 3.0]], metric="euclidean") +print(tree.nearest([0.8, 0.8], k=2)) diff --git a/examples/primitive_pipeline.py b/examples/primitive_pipeline.py new file mode 100644 index 0000000..93049a8 --- /dev/null +++ b/examples/primitive_pipeline.py @@ -0,0 +1,12 @@ +from core import cache, crypto, fs, json, path + + +workspace = fs.temp_dir("corepy-pass3-") +payload = json.dumps({"name": "corepy", "pass": 3}) +target = path.join(workspace, "payload.json") +fs.write_file(target, payload) +print(crypto.sha256(fs.read_file(target))) + +store = cache.new(path.join(workspace, "cache"), 60) +cache.set(store, "payload", {"path": target}) +print(cache.has(store, "payload")) diff --git a/examples/signal.py b/examples/signal.py new file mode 100644 index 0000000..2a98f92 --- /dev/null +++ b/examples/signal.py @@ -0,0 +1,6 @@ +from core import math + + +values = [1, 3, 6, 10] +print(math.moving_average(values, window=2)) +print(math.signal.difference(values)) diff --git a/examples/stdlib_shadow.py b/examples/stdlib_shadow.py new file mode 100644 index 0000000..2da9b63 --- /dev/null +++ b/examples/stdlib_shadow.py @@ -0,0 +1,16 @@ +import base64 +import hashlib +import json +import os + +from core import fs, path + + +workspace = fs.temp_dir("corepy-stdlib-") +target = os.path.join(workspace, "payload.json") +fs.write_file(target, json.dumps({"name": "corepy", "tier": 1})) + +digest = hashlib.sha256(fs.read_file(target)) +print(os.path.basename(target)) +print(base64.b64encode(path.base(target))) +print(digest.hexdigest()) diff --git a/go.mod b/go.mod index 6c06a10..a285dc6 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module dappco.re/go/py go 1.26.0 + +require dappco.re/go/core v0.8.0-alpha.1 + +replace dappco.re/go/core => ../go diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5d19fde --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/py/build/README.md b/py/build/README.md new file mode 100644 index 0000000..689ad53 --- /dev/null +++ b/py/build/README.md @@ -0,0 +1,47 @@ +# CorePy Tier 2 Build Stub + +This directory is the scaffold for the Tier 2 CorePy distribution described in +RFC §8.3: CPython imports `core`, but the module is backed by a native extension +generated with `gopy` from the same Go primitives used by Tier 1. + +Tier 2 is for Python programs that need CPython-only libraries such as numpy, +torch, mlx, pandas, or scipy alongside `core`. Tier 1 remains the embedded +gpython path for pure-Core workloads. + +## Current State + +Pass 2 intentionally does not build the native extension. The Codex sandbox does +not provide `gopy` or the target CPython toolchain, so this package installs as a +documentation stub. Importing `core` raises `NotImplementedError` and points +operators back to this build flow. + +Pass 3 adds a host-CPython subprocess runner in `runtime/tier2/` and exposes it +through: + +```bash +corepy tier2 run -e 'from core import echo; print(echo("hello"))' +corepy tier2 run -timeout 30s script.py +corepy tier2 which +``` + +That runner is not the final gopy extension. It is the managed process boundary +for Tier 2 work while the native package remains a build scaffold. + +## Build Flow + +Install prerequisites in the operator build environment: + +```bash +go install github.com/go-python/gopy@latest +python3.13 --version +``` + +Then run: + +```bash +./build.sh +``` + +When `gopy` is present, the wrapper invokes it against the Go binding packages +under `bindings/` and writes generated artifacts under `py/build/dist/gopy/`. +Until the real Tier 2 package lands, generated output is not checked in. diff --git a/py/build/_build_stub.py b/py/build/_build_stub.py new file mode 100644 index 0000000..3da75bf --- /dev/null +++ b/py/build/_build_stub.py @@ -0,0 +1,12 @@ +"""Documentation stub for the future gopy-built CorePy extension.""" + + +class Tier2BuildUnavailableError(NotImplementedError): + """Raised when the native Tier 2 CorePy extension has not been built.""" + + +def unavailable() -> None: + raise Tier2BuildUnavailableError( + "Tier 2 CorePy is not built. Run py/build/build.sh in an environment " + "with gopy and CPython 3.13+; see py/build/README.md." + ) diff --git a/py/build/build.sh b/py/build/build.sh new file mode 100755 index 0000000..69321f5 --- /dev/null +++ b/py/build/build.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../.." && pwd)" +output_dir="${script_dir}/dist/gopy" + +if ! command -v gopy >/dev/null 2>&1; then + echo "Tier 2 build requires gopy + CPython 3.13+ -- see README.md" + exit 0 +fi + +python_bin="${PYTHON:-python3.13}" +if ! command -v "${python_bin}" >/dev/null 2>&1; then + echo "Tier 2 build requires gopy + CPython 3.13+ -- see README.md" + exit 0 +fi + +mkdir -p "${output_dir}" +cd "${repo_root}" + +gopy build \ + -output="${output_dir}" \ + -name=core \ + -vm="${python_bin}" \ + ./bindings/... diff --git a/py/build/core/__init__.py b/py/build/core/__init__.py new file mode 100644 index 0000000..d275893 --- /dev/null +++ b/py/build/core/__init__.py @@ -0,0 +1,7 @@ +"""Stub package for the future gopy-built CorePy Tier 2 extension.""" + +from _build_stub import Tier2BuildUnavailableError, unavailable + +unavailable() + +__all__ = ["Tier2BuildUnavailableError"] diff --git a/py/build/pyproject.toml b/py/build/pyproject.toml new file mode 100644 index 0000000..52e62e1 --- /dev/null +++ b/py/build/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "core-tier2-stub" +version = "0.2.0" +description = "CorePy Tier 2 gopy build scaffold" +license = { text = "EUPL-1.2" } +requires-python = ">=3.13" +authors = [ + { name = "Lethean CIC" } +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["core"] +include = ["_build_stub.py"] diff --git a/py/build/tests/test_tier2_stub.py b/py/build/tests/test_tier2_stub.py new file mode 100644 index 0000000..dbbc2c9 --- /dev/null +++ b/py/build/tests/test_tier2_stub.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +import unittest + + +class Tier2StubTests(unittest.TestCase): + def test_import_core_raises_typed_error_good(self) -> None: + build_root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(build_root)) + try: + from _build_stub import Tier2BuildUnavailableError + + with self.assertRaisesRegex(Tier2BuildUnavailableError, "gopy and CPython 3.13"): + importlib.import_module("core") + finally: + sys.path.remove(str(build_root)) + sys.modules.pop("core", None) + sys.modules.pop("_build_stub", None) + + +if __name__ == "__main__": + unittest.main() diff --git a/py/core/__init__.py b/py/core/__init__.py index 85d1210..da67f01 100644 --- a/py/core/__init__.py +++ b/py/core/__init__.py @@ -1,8 +1,56 @@ """core — Python binding for Core primitives. -Same primitives as Go's `core/go`, same shape, different syntax surface. +Use the same import paths across Tier 1 and Tier 2: -See plans/code/core/py/RFC.md for the full contract. + from core import action, agent, api, array, cache, container, crypto, dns, echo, entitlement, fs, i18n, info, json, math, mcp, options, path, registry, scm, store, strings, task, ws + print(echo("hello")) + fs.write_file("/tmp/corepy.json", json.dumps({"name": "corepy"})) """ -__version__ = "0.1.0" +from . import action, agent, api, array, cache, config, container, crypto, data, dns, entitlement, err, fs, i18n, info, json, log, math, mcp, medium, options, path, process, registry, scm, service, store, strings, task, ws + +__version__ = "0.2.0" + + +def echo(value: str) -> str: + """Return the value unchanged. + + echo("hello") + """ + + return value + + +__all__ = [ + "array", + "action", + "agent", + "api", + "cache", + "config", + "container", + "crypto", + "data", + "dns", + "echo", + "entitlement", + "err", + "fs", + "i18n", + "info", + "json", + "log", + "math", + "mcp", + "medium", + "options", + "path", + "process", + "registry", + "scm", + "service", + "store", + "strings", + "task", + "ws", +] diff --git a/py/core/action.py b/py/core/action.py new file mode 100644 index 0000000..05de015 --- /dev/null +++ b/py/core/action.py @@ -0,0 +1,278 @@ +"""Named action helpers mirroring Core's capability map. + +from core import action + +actions = action.new_registry() +action.register(actions, "echo", lambda ctx, values: values["text"]) +""" + +from __future__ import annotations + +import builtins +import inspect +from typing import Any, Callable + + +ActionHandler = Callable[..., Any] + + +class Action: + """Named callable with enable/disable and existence checks. + + item = action.Action("echo", handler) + """ + + def __init__( + self, + name: str = "", + handler: ActionHandler | None = None, + description: str = "", + schema: dict[str, Any] | None = None, + ) -> None: + self.name = name + self.handler = handler + self.description = description + self.schema = {} if schema is None else dict(schema) + self.enabled = True + + def run(self, values: dict[str, Any] | None = None, context: Any = None) -> Any: + """Run the action with Core-shaped options. + + item.run({"text": "hello"}) + """ + + if not self.exists(): + raise RuntimeError(f"action not registered: {self.name or ''}") + if not self.enabled: + raise RuntimeError(f"action disabled: {self.name}") + return _invoke_handler(self.handler, context, _values(values)) + + def exists(self) -> bool: + """Return True when a handler is present. + + item.exists() + """ + + return self.handler is not None + + +class ActionRegistry: + """Named registry of actions in insertion order. + + actions = action.ActionRegistry() + """ + + def __init__(self) -> None: + self._actions: dict[str, Action] = {} + self._order: list[str] = [] + + def register( + self, + name: str, + handler: ActionHandler | None, + description: str = "", + schema: dict[str, Any] | None = None, + ) -> Action: + """Register or replace a named action. + + actions.register("echo", handler) + """ + + if name not in self._actions: + self._order.append(name) + item = Action(name, handler, description, schema) + self._actions[name] = item + return item + + def get(self, name: str) -> Action: + """Return a registered action or a placeholder. + + actions.get("echo") + """ + + return self._actions.get(name, Action(name)) + + def names(self) -> list[str]: + """Return registered action names in insertion order. + + actions.names() + """ + + return builtins.list(self._order) + + def run(self, name: str, values: dict[str, Any] | None = None, context: Any = None) -> Any: + """Run a named action. + + actions.run("echo", {"text": "hello"}) + """ + + return self.get(name).run(values, context) + + def disable(self, name: str) -> Action: + """Disable a named action. + + actions.disable("echo") + """ + + item = self.get(name) + if not item.exists(): + raise KeyError(name) + item.enabled = False + return item + + def enable(self, name: str) -> Action: + """Enable a named action. + + actions.enable("echo") + """ + + item = self.get(name) + if not item.exists(): + raise KeyError(name) + item.enabled = True + return item + + +def new( + name: str = "", + handler: ActionHandler | None = None, + description: str = "", + schema: dict[str, Any] | None = None, +) -> Action: + """Create an Action handle. + + action.new("echo", handler) + """ + + return Action(name, handler, description, schema) + + +def new_registry() -> ActionRegistry: + """Create an ActionRegistry handle. + + action.new_registry() + """ + + return ActionRegistry() + + +def register( + registry_value: ActionRegistry, + name: str, + handler: ActionHandler | None, + description: str = "", + schema: dict[str, Any] | None = None, +) -> Action: + """Register or replace a named action. + + action.register(actions, "echo", handler) + """ + + return registry_value.register(name, handler, description, schema) + + +def get(registry_value: ActionRegistry, name: str) -> Action: + """Return a named action or a placeholder. + + action.get(actions, "echo") + """ + + return registry_value.get(name) + + +def names(registry_value: ActionRegistry) -> list[str]: + """Return registered action names. + + action.names(actions) + """ + + return registry_value.names() + + +def run(action_value: Action | ActionRegistry, *arguments: Any, **kwargs: Any) -> Any: + """Run an action handle or a named action on a registry. + + action.run(item, {"text": "hello"}) + action.run(actions, "echo", {"text": "hello"}) + """ + + context = kwargs.get("context") + if isinstance(action_value, ActionRegistry): + if not arguments: + raise TypeError("action.run expected an action name") + name = str(arguments[0]) + values = arguments[1] if len(arguments) > 1 else None + return action_value.run(name, values, context) + values = arguments[0] if arguments else None + return action_value.run(values, context) + + +def exists(action_value: Action) -> bool: + """Return True when an action has a handler. + + action.exists(item) + """ + + return action_value.exists() + + +def disable(registry_value: ActionRegistry, name: str) -> Action: + """Disable a named action. + + action.disable(actions, "echo") + """ + + return registry_value.disable(name) + + +def enable(registry_value: ActionRegistry, name: str) -> Action: + """Enable a named action. + + action.enable(actions, "echo") + """ + + return registry_value.enable(name) + + +def _values(values: dict[str, Any] | None) -> dict[str, Any]: + if values is None: + return {} + return dict(values) + + +def _invoke_handler(handler: ActionHandler | None, context: Any, values: dict[str, Any]) -> Any: + if handler is None: + raise RuntimeError("action handler is not set") + + try: + signature = inspect.signature(handler) + except (TypeError, ValueError): + return handler(context, values) + + parameters = [ + parameter + for parameter in signature.parameters.values() + if parameter.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + if any(parameter.kind == inspect.Parameter.VAR_POSITIONAL for parameter in signature.parameters.values()): + return handler(context, values) + if builtins.len(parameters) == 0: + return handler() + if builtins.len(parameters) == 1: + return handler(values) + return handler(context, values) + + +__all__ = [ + "Action", + "ActionRegistry", + "disable", + "enable", + "exists", + "get", + "names", + "new", + "new_registry", + "register", + "run", +] diff --git a/py/core/agent.py b/py/core/agent.py new file mode 100644 index 0000000..0e2683f --- /dev/null +++ b/py/core/agent.py @@ -0,0 +1,16 @@ +"""core.agent — agent dispatch and fleet primitives. + + available() +""" + + +def available() -> bool: + """Return whether the native Agent binding is active. + + available() + """ + + return False + + +__all__ = ["available"] diff --git a/py/core/api.py b/py/core/api.py new file mode 100644 index 0000000..77ed18d --- /dev/null +++ b/py/core/api.py @@ -0,0 +1,16 @@ +"""core.api — REST server and client primitives. + + available() +""" + + +def available() -> bool: + """Return whether the native API binding is active. + + available() + """ + + return False + + +__all__ = ["available"] diff --git a/py/core/array.py b/py/core/array.py new file mode 100644 index 0000000..06ff477 --- /dev/null +++ b/py/core/array.py @@ -0,0 +1,196 @@ +"""Array helpers mirroring Core's typed slice primitive. + +from core import array + +values = array.new("a", "b") +array.add(values, "c") +""" + +from __future__ import annotations + +import builtins +from typing import Any + + +class Array: + """Mutable ordered collection with Core-shaped helpers. + + values = array.Array("a", "b") + """ + + def __init__(self, *items: Any) -> None: + self._items = list(items) + + def add(self, *values: Any) -> None: + """Append values in order. + + values.add("c", "d") + """ + + self._items.extend(values) + + def add_unique(self, *values: Any) -> None: + """Append only values that are not already present. + + values.add_unique("c", "d") + """ + + for value in values: + if not self.contains(value): + self._items.append(value) + + def contains(self, value: Any) -> bool: + """Return True when the value is present. + + values.contains("c") + """ + + return any(item == value for item in self._items) + + def remove(self, value: Any) -> None: + """Remove the first matching value when present. + + values.remove("b") + """ + + for index, item in enumerate(self._items): + if item == value: + del self._items[index] + return + + def deduplicate(self) -> None: + """Remove duplicate values while preserving order. + + values.deduplicate() + """ + + unique_items: list[Any] = [] + for item in self._items: + if any(existing == item for existing in unique_items): + continue + unique_items.append(item) + self._items = unique_items + + def len(self) -> int: + """Return the number of stored values. + + values.len() + """ + + return builtins.len(self._items) + + def clear(self) -> None: + """Remove all values. + + values.clear() + """ + + self._items.clear() + + def as_list(self) -> list[Any]: + """Return a shallow list copy. + + values.as_list() + """ + + return list(self._items) + + +def new(*items: Any) -> Array: + """Create a new Array handle. + + array.new("a", "b") + """ + + return Array(*items) + + +def add(array_value: Array, *values: Any) -> Array: + """Append values and return the handle. + + array.add(values, "c") + """ + + array_value.add(*values) + return array_value + + +def add_unique(array_value: Array, *values: Any) -> Array: + """Append only missing values and return the handle. + + array.add_unique(values, "c") + """ + + array_value.add_unique(*values) + return array_value + + +def contains(array_value: Array, value: Any) -> bool: + """Return True when the value exists in the handle. + + array.contains(values, "c") + """ + + return array_value.contains(value) + + +def remove(array_value: Array, value: Any) -> Array: + """Remove the first matching value and return the handle. + + array.remove(values, "b") + """ + + array_value.remove(value) + return array_value + + +def deduplicate(array_value: Array) -> Array: + """Remove duplicates and return the handle. + + array.deduplicate(values) + """ + + array_value.deduplicate() + return array_value + + +def len(array_value: Array) -> int: + """Return the number of stored values. + + array.len(values) + """ + + return array_value.len() + + +def clear(array_value: Array) -> Array: + """Clear the handle and return it. + + array.clear(values) + """ + + array_value.clear() + return array_value + + +def as_list(array_value: Array) -> list[Any]: + """Return a shallow list copy. + + array.as_list(values) + """ + + return array_value.as_list() + + +__all__ = [ + "Array", + "add", + "add_unique", + "as_list", + "clear", + "contains", + "deduplicate", + "len", + "new", + "remove", +] diff --git a/py/core/cache.py b/py/core/cache.py new file mode 100644 index 0000000..a49942c --- /dev/null +++ b/py/core/cache.py @@ -0,0 +1,288 @@ +"""JSON cache helpers with path-shaped keys. + +from core import cache + +store = cache.new("/tmp/corepy-cache", 300) +cache.set(store, "dns/localhost", {"host": "127.0.0.1"}) +""" + +from __future__ import annotations + +import json +from pathlib import Path, PurePosixPath +import time +from typing import Any + + +_DEFAULT_TTL_SECONDS = 3600 + + +class Cache: + """File-backed JSON cache with TTL expiry. + + store = cache.Cache("/tmp/corepy-cache", 300) + """ + + def __init__(self, base_dir: str | Path | None = None, ttl_seconds: int = _DEFAULT_TTL_SECONDS) -> None: + self._base_dir = Path.cwd() / ".core" / "cache" if base_dir in (None, "") else Path(base_dir) + self._ttl_seconds = _ttl_value(ttl_seconds) + self._base_dir.mkdir(parents=True, exist_ok=True) + + @property + def base_dir(self) -> Path: + """Return the cache root directory. + + store.base_dir + """ + + return self._base_dir + + def path(self, key: str) -> str: + """Return the storage path for a cache key. + + store.path("dns/localhost") + """ + + return str(self._path_for_key(key)) + + def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> str: + """Store a JSON-serialisable value under a key. + + store.set("dns/localhost", {"host": "127.0.0.1"}) + """ + + ttl_value = self._ttl_seconds if ttl_seconds is None else _ttl_value(ttl_seconds) + target = self._path_for_key(key) + target.parent.mkdir(parents=True, exist_ok=True) + now = time.time() + target.write_text( + json.dumps( + { + "data": value, + "cached_at": now, + "expires_at": now + ttl_value, + }, + indent=2, + sort_keys=True, + ), + encoding="utf-8", + ) + return str(target) + + def get(self, key: str, default: Any = None) -> Any: + """Return the cached value or the provided default. + + store.get("dns/localhost", {}) + """ + + entry = self._load_entry(key) + if entry is None: + return default + return entry["data"] + + def has(self, key: str) -> bool: + """Return True when an unexpired cache entry exists. + + store.has("dns/localhost") + """ + + return self._load_entry(key) is not None + + def delete(self, key: str) -> bool: + """Delete a cached value when present. + + store.delete("dns/localhost") + """ + + target = self._path_for_key(key) + if not target.exists(): + return False + target.unlink() + return True + + def clear(self, prefix: str = "") -> int: + """Delete all keys that match a prefix. + + store.clear("dns") + """ + + removed = 0 + for key in self.keys(prefix): + if self.delete(key): + removed += 1 + return removed + + def keys(self, prefix: str = "") -> list[str]: + """List stored keys, optionally under a prefix. + + store.keys("dns") + """ + + normalized_prefix = _prefix(prefix) + if not self._base_dir.exists(): + return [] + + keys: list[str] = [] + for target in sorted(self._base_dir.rglob("*.json")): + if not target.is_file(): + continue + key = target.relative_to(self._base_dir).as_posix()[:-5] + if normalized_prefix and key != normalized_prefix and not key.startswith(f"{normalized_prefix}/"): + continue + keys.append(key) + return keys + + def _load_entry(self, key: str) -> dict[str, Any] | None: + target = self._path_for_key(key) + if not target.exists(): + return None + try: + entry = json.loads(target.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(entry, dict): + return None + expires_at = entry.get("expires_at") + if not isinstance(expires_at, (int, float)) or time.time() > float(expires_at): + target.unlink(missing_ok=True) + return None + return entry + + def _path_for_key(self, key: str) -> Path: + return self._base_dir.joinpath(*_key_parts(key)).with_suffix(".json") + + +def new(base_dir: str | Path | None = None, ttl_seconds: int = _DEFAULT_TTL_SECONDS) -> Cache: + """Create a cache handle. + + cache.new("/tmp/corepy-cache", 300) + """ + + return Cache(base_dir, ttl_seconds) + + +def path(cache_value: Cache, key: str) -> str: + """Return the cache storage path for a key. + + cache.path(store, "dns/localhost") + """ + + return cache_value.path(key) + + +def set(cache_value: Cache, key: str, value: Any) -> str: + """Store a cache entry with the default TTL. + + cache.set(store, "dns/localhost", {"host": "127.0.0.1"}) + """ + + return cache_value.set(key, value) + + +def set_with_ttl(cache_value: Cache, key: str, value: Any, ttl_seconds: int) -> str: + """Store a cache entry with an explicit TTL. + + cache.set_with_ttl(store, "dns/localhost", {"host": "127.0.0.1"}, 60) + """ + + return cache_value.set(key, value, ttl_seconds) + + +def get(cache_value: Cache, key: str, default: Any = None) -> Any: + """Return a cache entry or the default value. + + cache.get(store, "dns/localhost", {}) + """ + + return cache_value.get(key, default) + + +def has(cache_value: Cache, key: str) -> bool: + """Return True when a key exists and has not expired. + + cache.has(store, "dns/localhost") + """ + + return cache_value.has(key) + + +def delete(cache_value: Cache, key: str) -> bool: + """Delete a cache key when present. + + cache.delete(store, "dns/localhost") + """ + + return cache_value.delete(key) + + +def clear(cache_value: Cache, prefix: str = "") -> int: + """Delete cache keys by prefix. + + cache.clear(store, "dns") + """ + + return cache_value.clear(prefix) + + +def keys(cache_value: Cache, prefix: str = "") -> list[str]: + """List cache keys, optionally filtered by prefix. + + cache.keys(store, "dns") + """ + + return cache_value.keys(prefix) + + +def _key_parts(key: str) -> list[str]: + parts = _parts(key, allow_empty=False, field_name="cache key") + if not parts: + raise ValueError("cache key must not be empty") + return parts + + +def _prefix(prefix: str) -> str: + return "/".join(_parts(prefix, allow_empty=True, field_name="cache prefix")) + + +def _parts(value: str, *, allow_empty: bool, field_name: str) -> list[str]: + raw = str(value).strip().replace("\\", "/") + if raw == "": + if allow_empty: + return [] + raise ValueError(f"{field_name} must not be empty") + path_value = PurePosixPath(raw) + if path_value.is_absolute(): + raise ValueError(f"{field_name} must be relative") + parts: list[str] = [] + for part in path_value.parts: + if part in {"", "."}: + continue + if part == "..": + raise ValueError(f"{field_name} must not contain '..'") + parts.append(part) + if not parts and not allow_empty: + raise ValueError(f"{field_name} must not be empty") + return parts + + +def _ttl_value(ttl_seconds: int) -> int: + ttl_value = int(ttl_seconds) + if ttl_value < 0: + raise ValueError("ttl_seconds must be zero or positive") + if ttl_value == 0: + return _DEFAULT_TTL_SECONDS + return ttl_value + + +__all__ = [ + "Cache", + "clear", + "delete", + "get", + "has", + "keys", + "new", + "path", + "set", + "set_with_ttl", +] diff --git a/py/core/config.py b/py/core/config.py new file mode 100644 index 0000000..057722a --- /dev/null +++ b/py/core/config.py @@ -0,0 +1,231 @@ +"""Configuration and feature flags with concrete examples. + +from core import config + +cfg = config.Config() +cfg.set("database.host", "localhost") +cfg.enable("debug") +""" + +from __future__ import annotations + +import builtins +import os +from typing import Any + + +class Config: + """Runtime settings plus feature flags. + + cfg = config.Config() + """ + + def __init__(self) -> None: + self._settings: dict[str, Any] = {} + self._features: dict[str, bool] = {} + + def set(self, key: str, value: Any) -> None: + """Store a setting by key. + + cfg.set("database.host", "localhost") + """ + + self._settings[key] = value + + def get(self, key: str, default: Any = None) -> Any: + """Read a setting by key. + + cfg.get("database.host") + """ + + if key in self._settings: + return self._settings[key] + value = _env_value(key) + if value is not None: + return value + return default + + def string(self, key: str) -> str: + """Read a string setting or an empty string. + + cfg.string("database.host") + """ + + if key in self._settings: + value = self._settings[key] + else: + value = _env_value(key, "") + return value if isinstance(value, str) else "" + + def int(self, key: str) -> int: + """Read an integer setting or zero. + + cfg.int("port") + """ + + if key in self._settings: + value = self._settings[key] + return value if isinstance(value, builtins.int) and not isinstance(value, builtins.bool) else 0 + + value = _env_value(key) + if value is None: + return 0 + try: + return builtins.int(value) + except (TypeError, ValueError): + return 0 + + def bool(self, key: str) -> bool: + """Read a boolean setting or False. + + cfg.bool("debug") + """ + + if key in self._settings: + value = self._settings[key] + return value if isinstance(value, builtins.bool) else False + + value = _env_value(key) + if value is None: + return False + return value.strip().lower() in {"1", "true", "t", "yes", "y", "on"} + + def enable(self, feature: str) -> None: + """Enable a feature flag. + + cfg.enable("debug") + """ + + self._features[feature] = True + + def disable(self, feature: str) -> None: + """Disable a feature flag. + + cfg.disable("debug") + """ + + self._features[feature] = False + + def enabled(self, feature: str) -> bool: + """Return True when a feature flag is enabled. + + cfg.enabled("debug") + """ + + return self._features.get(feature, False) + + def enabled_features(self) -> list[str]: + """Return all enabled feature names. + + cfg.enabled_features() + """ + + return [feature for feature, enabled in self._features.items() if enabled] + + +def new() -> Config: + """Create a Config handle. + + config.new() + """ + + return Config() + + +def set(config_value: Config, key: str, value: Any) -> Config: + """Set a configuration value and return the handle. + + config.set(cfg, "debug", True) + """ + + config_value.set(key, value) + return config_value + + +def get(config_value: Config, key: str) -> Any: + """Read a configuration value from a handle. + + config.get(cfg, "database.host") + """ + + return config_value.get(key) + + +def string(config_value: Config, key: str) -> str: + """Read a string configuration value from a handle. + + config.string(cfg, "database.host") + """ + + return config_value.string(key) + + +def int(config_value: Config, key: str) -> builtins.int: + """Read an integer configuration value from a handle. + + config.int(cfg, "port") + """ + + return config_value.int(key) + + +def bool(config_value: Config, key: str) -> builtins.bool: + """Read a boolean configuration value from a handle. + + config.bool(cfg, "debug") + """ + + return config_value.bool(key) + + +def enable(config_value: Config, feature: str) -> Config: + """Enable a feature flag and return the handle. + + config.enable(cfg, "tier1") + """ + + config_value.enable(feature) + return config_value + + +def disable(config_value: Config, feature: str) -> Config: + """Disable a feature flag and return the handle. + + config.disable(cfg, "tier1") + """ + + config_value.disable(feature) + return config_value + + +def enabled(config_value: Config, feature: str) -> bool: + """Return True when a feature is enabled on a handle. + + config.enabled(cfg, "tier1") + """ + + return config_value.enabled(feature) + + +def enabled_features(config_value: Config) -> list[str]: + """Return all enabled features from a handle. + + config.enabled_features(cfg) + """ + + return config_value.enabled_features() + + +def _env_value(key: str, default: Any = None) -> Any: + return os.environ.get(_env_key(key), default) + + +def _env_key(key: str) -> str: + return ( + key.strip() + .replace(".", "_") + .replace("-", "_") + .replace("/", "_") + .replace(" ", "_") + .upper() + ) diff --git a/py/core/container.py b/py/core/container.py new file mode 100644 index 0000000..453342e --- /dev/null +++ b/py/core/container.py @@ -0,0 +1,16 @@ +"""core.container — container orchestration primitives. + + available() +""" + + +def available() -> bool: + """Return whether the native Container binding is active. + + available() + """ + + return False + + +__all__ = ["available"] diff --git a/py/core/crypto.py b/py/core/crypto.py new file mode 100644 index 0000000..2ec5d50 --- /dev/null +++ b/py/core/crypto.py @@ -0,0 +1,95 @@ +"""Cryptographic helpers for hashing, signing, and encoding. + +from core import crypto + +digest = crypto.sha256("hello") +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets + + +def sha1(value: bytes | str) -> str: + """Return the SHA-1 hex digest of a value. + + crypto.sha1("hello") + """ + + return hashlib.sha1(_bytes(value)).hexdigest() + + +def sha256(value: bytes | str) -> str: + """Return the SHA-256 hex digest of a value. + + crypto.sha256("hello") + """ + + return hashlib.sha256(_bytes(value)).hexdigest() + + +def hmac_sha256(key: bytes | str, value: bytes | str) -> str: + """Return the HMAC-SHA256 hex digest of a value. + + crypto.hmac_sha256("secret", "hello") + """ + + return hmac.new(_bytes(key), _bytes(value), hashlib.sha256).hexdigest() + + +def compare_digest(left: bytes | str, right: bytes | str) -> bool: + """Return True when two values match in constant time. + + crypto.compare_digest("a", "a") + """ + + return hmac.compare_digest(_bytes(left), _bytes(right)) + + +def base64_encode(value: bytes | str) -> str: + """Return a Base64-encoded ASCII string. + + crypto.base64_encode("hello") + """ + + return base64.b64encode(_bytes(value)).decode("ascii") + + +def base64_decode(value: str) -> bytes: + """Decode a Base64 string into bytes. + + crypto.base64_decode("aGVsbG8=") + """ + + return base64.b64decode(value.encode("ascii")) + + +def random_bytes(size: int) -> bytes: + """Return cryptographically random bytes. + + crypto.random_bytes(16) + """ + + if size < 0: + raise ValueError("size must be zero or positive") + return secrets.token_bytes(size) + + +def _bytes(value: bytes | str) -> bytes: + if isinstance(value, bytes): + return value + return value.encode("utf-8") + + +__all__ = [ + "base64_decode", + "base64_encode", + "compare_digest", + "hmac_sha256", + "random_bytes", + "sha1", + "sha256", +] diff --git a/py/core/data.py b/py/core/data.py new file mode 100644 index 0000000..3801d9c --- /dev/null +++ b/py/core/data.py @@ -0,0 +1,235 @@ +"""Mounted content helpers with path-first examples. + +from core import data + +assets = data.Data() +assets.mount("fixtures", "/tmp/corepy-fixtures") +text = assets.read_string("fixtures/example.txt") +""" + +from __future__ import annotations + +import builtins +from pathlib import Path +import re +import shutil +from typing import Any, Mapping + + +_GO_TEMPLATE_PATTERN = re.compile(r"\{\{\s*\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}") + + +class Data: + """Mounted content registry for local directories. + + assets = data.Data() + """ + + def __init__(self) -> None: + self._mounts: dict[str, Path] = {} + + def mount(self, name: str, source: str | Path, path: str = ".") -> str: + """Mount a local directory under a logical name. + + assets.mount("fixtures", "/tmp/corepy-fixtures") + """ + + root = Path(source).expanduser().resolve() + mounted_root = (root / path).resolve() + self._mounts[name] = mounted_root + return str(mounted_root) + + def read_file(self, path: str) -> bytes: + """Read mounted file bytes. + + assets.read_file("fixtures/example.txt") + """ + + return self._resolve(path).read_bytes() + + def read_string(self, path: str) -> str: + """Read mounted file text. + + assets.read_string("fixtures/example.txt") + """ + + return self._resolve(path).read_text(encoding="utf-8") + + def list(self, path: str) -> list[str]: + """List child names at a mounted path. + + assets.list("fixtures") + """ + + return sorted(child.name for child in self._resolve(path).iterdir()) + + def list_names(self, path: str) -> list[str]: + """List child names without file extensions. + + assets.list_names("fixtures") + """ + + names: list[str] = [] + for child_name in self.list(path): + names.append(Path(child_name).stem) + return names + + def extract(self, path: str, target_dir: str | Path, template_data: Mapping[str, Any] | None = None) -> str: + """Copy a mounted directory into a target directory. + + assets.extract("fixtures/templates", "/tmp/corepy-workspace", {"Name": "corepy"}) + """ + + source_directory = self._resolve(path) + target_directory = Path(target_dir).expanduser().resolve() + target_directory.mkdir(parents=True, exist_ok=True) + + for source_path in sorted(source_directory.rglob("*")): + relative_path = source_path.relative_to(source_directory) + rendered_relative = _render_go_template(relative_path.as_posix(), template_data) + destination_path = _safe_destination(target_directory, _strip_template_filter(source_path, rendered_relative)) + if source_path.is_dir(): + destination_path.mkdir(parents=True, exist_ok=True) + continue + + destination_path.parent.mkdir(parents=True, exist_ok=True) + if not _is_template_file(source_path): + shutil.copy2(source_path, destination_path) + continue + + try: + text = source_path.read_text(encoding="utf-8") + except UnicodeDecodeError: + shutil.copy2(source_path, destination_path) + continue + destination_path.write_text(_render_go_template(text, template_data), encoding="utf-8") + + return str(target_directory) + + def mounts(self) -> list[str]: + """Return mounted names in insertion order. + + assets.mounts() + """ + + return builtins.list(self._mounts.keys()) + + def _resolve(self, logical_path: str) -> Path: + mount_name, _, relative_path = logical_path.partition("/") + if mount_name not in self._mounts: + raise KeyError(f"mount not found: {mount_name}") + root = self._mounts[mount_name] + return root if relative_path == "" else root / relative_path + + +def new() -> Data: + """Create a Data handle. + + data.new() + """ + + return Data() + + +def mount(data_value: Data, name: str, source: str | Path, path: str = ".") -> Data: + """Mount a directory onto a Data handle and return it. + + data.mount(assets, "fixtures", "/tmp/corepy-fixtures") + """ + + data_value.mount(name, source, path) + return data_value + + +def mount_path(data_value: Data, name: str, source: str | Path, path: str = ".") -> Data: + """Mount a directory onto a Data handle using the Go binding name. + + data.mount_path(assets, "fixtures", "/tmp/corepy-fixtures") + """ + + return mount(data_value, name, source, path) + + +def read_file(data_value: Data, path: str) -> bytes: + """Read file bytes from a Data handle. + + data.read_file(assets, "fixtures/example.txt") + """ + + return data_value.read_file(path) + + +def read_string(data_value: Data, path: str) -> str: + """Read text from a Data handle. + + data.read_string(assets, "fixtures/example.txt") + """ + + return data_value.read_string(path) + + +def list(data_value: Data, path: str) -> list[str]: + """List child names from a Data handle. + + data.list(assets, "fixtures") + """ + + return data_value.list(path) + + +def list_names(data_value: Data, path: str) -> list[str]: + """List child stems from a Data handle. + + data.list_names(assets, "fixtures") + """ + + return data_value.list_names(path) + + +def extract(data_value: Data, path: str, target_dir: str | Path, template_data: Mapping[str, Any] | None = None) -> str: + """Extract mounted content from a Data handle. + + data.extract(assets, "fixtures/templates", "/tmp/corepy-workspace", {"Name": "corepy"}) + """ + + return data_value.extract(path, target_dir, template_data) + + +def mounts(data_value: Data) -> list[str]: + """Return mounted names from a Data handle. + + data.mounts(assets) + """ + + return data_value.mounts() + + +def _is_template_file(path: Path) -> bool: + return ".tmpl" in path.name + + +def _strip_template_filter(source_path: Path, rendered_relative: str) -> Path: + relative = Path(rendered_relative) + if not _is_template_file(source_path): + return relative + return relative.with_name(relative.name.replace(".tmpl", "")) + + +def _render_go_template(value: str, template_data: Mapping[str, Any] | None) -> str: + if template_data is None: + return value + + def replace(match: re.Match[str]) -> str: + key = match.group(1) + if key not in template_data: + return match.group(0) + return str(template_data[key]) + + return _GO_TEMPLATE_PATTERN.sub(replace, value) + + +def _safe_destination(target_root: Path, relative_path: Path) -> Path: + destination = (target_root / relative_path).resolve() + if destination != target_root and target_root not in destination.parents: + raise ValueError(f"extracted path escapes target directory: {relative_path}") + return destination diff --git a/py/core/dns.py b/py/core/dns.py new file mode 100644 index 0000000..725f7a5 --- /dev/null +++ b/py/core/dns.py @@ -0,0 +1,62 @@ +"""DNS helpers for host and service lookups. + +from core import dns + +addresses = dns.lookup_host("localhost") +""" + +from __future__ import annotations + +import socket + + +def lookup_host(name: str) -> list[str]: + """Return host addresses for a name. + + dns.lookup_host("localhost") + """ + + return _unique(item[4][0] for item in socket.getaddrinfo(name, None)) + + +def lookup_ip(name: str) -> list[str]: + """Return IP addresses for a host. + + dns.lookup_ip("localhost") + """ + + return lookup_host(name) + + +def reverse_lookup(address: str) -> list[str]: + """Return reverse-DNS names for an address. + + dns.reverse_lookup("127.0.0.1") + """ + + hostname, aliases, _ = socket.gethostbyaddr(address) + return _unique([hostname, *aliases]) + + +def lookup_port(network: str, service: str) -> int: + """Return the port number for a service name. + + dns.lookup_port("tcp", "http") + """ + + return socket.getservbyname(service, network.lower()) + + +def _unique(values: list[str] | tuple[str, ...] | object) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for value in values: + text = str(value) + if text in seen: + continue + seen.add(text) + result.append(text) + return result + + +__all__ = ["lookup_host", "lookup_ip", "lookup_port", "reverse_lookup"] diff --git a/py/core/entitlement.py b/py/core/entitlement.py new file mode 100644 index 0000000..4071b4d --- /dev/null +++ b/py/core/entitlement.py @@ -0,0 +1,90 @@ +"""Permission-result helpers mirroring Core's entitlement primitive. + +from core import entitlement + +grant = entitlement.new(True, False, 5, 4, 1, "") +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(slots=True) +class Entitlement: + """Permission decision with optional quota information. + + grant = entitlement.Entitlement(allowed=True, limit=5, used=4, remaining=1) + """ + + allowed: bool = False + unlimited: bool = False + limit: int = 0 + used: int = 0 + remaining: int = 0 + reason: str = "" + + def near_limit(self, threshold: float) -> bool: + """Return True when usage meets or exceeds the threshold. + + grant.near_limit(0.8) + """ + + if self.unlimited or self.limit == 0: + return False + return (self.used / self.limit) >= threshold + + def usage_percent(self) -> float: + """Return current usage as a percentage of the limit. + + grant.usage_percent() + """ + + if self.limit == 0: + return 0.0 + return (self.used / self.limit) * 100.0 + + +def new( + allowed: bool = False, + unlimited: bool = False, + limit: int = 0, + used: int = 0, + remaining: int | None = None, + reason: str = "", +) -> Entitlement: + """Create an Entitlement value. + + entitlement.new(True, False, 5, 4, 1, "") + """ + + remaining_value = limit - used if remaining is None else int(remaining) + return Entitlement( + allowed=bool(allowed), + unlimited=bool(unlimited), + limit=int(limit), + used=int(used), + remaining=remaining_value, + reason=str(reason), + ) + + +def near_limit(entitlement_value: Entitlement, threshold: float) -> bool: + """Return True when the entitlement is near its limit. + + entitlement.near_limit(grant, 0.8) + """ + + return entitlement_value.near_limit(threshold) + + +def usage_percent(entitlement_value: Entitlement) -> float: + """Return current usage percentage for an entitlement. + + entitlement.usage_percent(grant) + """ + + return entitlement_value.usage_percent() + + +__all__ = ["Entitlement", "near_limit", "new", "usage_percent"] diff --git a/py/core/err.py b/py/core/err.py new file mode 100644 index 0000000..1a2f8b8 --- /dev/null +++ b/py/core/err.py @@ -0,0 +1,95 @@ +"""Structured errors with operation-first context. + +from core import err + +issue = err.e("core.save", "write failed") +wrapped = err.wrap(issue, "core.deploy", "deploy failed") +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(slots=True) +class CoreError(Exception): + """Structured error with operation context. + + err.e("core.save", "write failed") + """ + + operation: str + message: str + cause: BaseException | None = None + code: str = "" + + def __str__(self) -> str: + prefix = f"{self.operation}: " if self.operation else "" + if self.cause is None and self.code == "": + return prefix + self.message + if self.cause is None: + return f"{prefix}{self.message} [{self.code}]" + if self.code == "": + return f"{prefix}{self.message}: {self.cause}" + return f"{prefix}{self.message} [{self.code}]: {self.cause}" + + +def e(operation: str, message: str, cause: BaseException | None = None, code: str = "") -> CoreError: + """Create a structured error. + + err.e("core.save", "write failed") + """ + + return CoreError(operation=operation, message=message, cause=cause, code=code) + + +def wrap(cause: BaseException | None, operation: str, message: str, code: str = "") -> CoreError | None: + """Wrap an existing error with operation context. + + err.wrap(issue, "core.deploy", "deploy failed") + """ + + if cause is None and code == "": + return None + return CoreError(operation=operation, message=message, cause=cause, code=code) + + +def operation(value: BaseException) -> str: + """Return the error operation when available. + + err.operation(issue) + """ + + return value.operation if isinstance(value, CoreError) else "" + + +def error_code(value: BaseException) -> str: + """Return the error code when available. + + err.error_code(issue) + """ + + return value.code if isinstance(value, CoreError) else "" + + +def message(value: BaseException) -> str: + """Return the structured message or plain string. + + err.message(issue) + """ + + return value.message if isinstance(value, CoreError) else str(value) + + +def root(value: BaseException | None) -> BaseException | None: + """Return the deepest wrapped error. + + err.root(issue) + """ + + current = value + while isinstance(current, CoreError) and current.cause is not None: + if not isinstance(current.cause, BaseException): + break + current = current.cause + return current diff --git a/py/core/fs.py b/py/core/fs.py new file mode 100644 index 0000000..071de19 --- /dev/null +++ b/py/core/fs.py @@ -0,0 +1,75 @@ +"""Filesystem primitives with path-shaped examples. + +from core import fs + +fs.ensure_dir("/tmp/corepy") +fs.write_file("/tmp/corepy/config.json", '{"name":"corepy"}') +text = fs.read_file("/tmp/corepy/config.json") +""" + +from __future__ import annotations + +from pathlib import Path +import tempfile + + +def read_file(path: str | Path) -> str: + """Read a UTF-8 file into a string. + + fs.read_file("/tmp/corepy/config.json") + """ + + return Path(path).read_text(encoding="utf-8") + + +def read_bytes(path: str | Path) -> bytes: + """Read a file into bytes. + + fs.read_bytes("/tmp/corepy/config.json") + """ + + return Path(path).read_bytes() + + +def write_file(path: str | Path, content: str) -> str: + """Write UTF-8 text to a file. + + fs.write_file("/tmp/corepy/config.json", '{"name":"corepy"}') + """ + + filename = Path(path) + ensure_dir(filename.parent) + filename.write_text(content, encoding="utf-8") + return str(filename) + + +def write_bytes(path: str | Path, content: bytes) -> str: + """Write bytes to a file. + + fs.write_bytes("/tmp/corepy/config.bin", b"corepy") + """ + + filename = Path(path) + ensure_dir(filename.parent) + filename.write_bytes(content) + return str(filename) + + +def ensure_dir(path: str | Path) -> str: + """Create a directory if it does not already exist. + + fs.ensure_dir("/tmp/corepy") + """ + + directory = Path(path) + directory.mkdir(parents=True, exist_ok=True) + return str(directory) + + +def temp_dir(prefix: str = "corepy-") -> str: + """Create a temporary directory and return its path. + + workdir = fs.temp_dir("corepy-") + """ + + return tempfile.mkdtemp(prefix=prefix) diff --git a/py/core/i18n.py b/py/core/i18n.py new file mode 100644 index 0000000..7b86da3 --- /dev/null +++ b/py/core/i18n.py @@ -0,0 +1,209 @@ +"""Locale and translation helpers. + +from core import i18n + +messages = i18n.new() +""" + +from __future__ import annotations + +import builtins +from typing import Any, Protocol + + +class Translator(Protocol): + def translate(self, message_id: str, *args: Any) -> Any: ... + def set_language(self, lang: str) -> None: ... + def language(self) -> str: ... + def available_languages(self) -> list[str]: ... + + +class I18n: + """Locale collection plus optional translator dispatch. + + messages = i18n.I18n() + """ + + def __init__(self) -> None: + self._locales: list[Any] = [] + self._locale = "" + self._translator: Translator | None = None + + def add_locales(self, *mounts: Any) -> None: + """Append locale mounts. + + messages.add_locales("locales") + """ + + self._locales.extend(mounts) + + def locales(self) -> list[Any]: + """Return collected locale mounts. + + messages.locales() + """ + + return builtins.list(self._locales) + + def set_translator(self, translator: Translator | None) -> None: + """Register a translator implementation. + + messages.set_translator(translator) + """ + + self._translator = translator + if translator is not None and self._locale: + translator.set_language(self._locale) + + def translator(self) -> Translator | None: + """Return the registered translator. + + messages.translator() + """ + + return self._translator + + def translate(self, message_id: str, *args: Any) -> Any: + """Translate a message or return the key as-is. + + messages.translate("hello") + """ + + if self._translator is None: + return message_id + return self._translator.translate(message_id, *args) + + def set_language(self, lang: str) -> None: + """Set the active language. + + messages.set_language("de") + """ + + if lang == "": + return + self._locale = lang + if self._translator is not None: + self._translator.set_language(lang) + + def language(self) -> str: + """Return the active language or `en`. + + messages.language() + """ + + if self._locale: + return self._locale + if self._translator is not None: + value = self._translator.language() + if value: + return value + return "en" + + def available_languages(self) -> list[str]: + """Return available language codes. + + messages.available_languages() + """ + + if self._translator is None: + return ["en"] + return builtins.list(self._translator.available_languages()) + + +def new() -> I18n: + """Create an I18n handle. + + i18n.new() + """ + + return I18n() + + +def add_locales(i18n_value: I18n, *mounts: Any) -> I18n: + """Append locale mounts and return the handle. + + i18n.add_locales(messages, "locales") + """ + + i18n_value.add_locales(*mounts) + return i18n_value + + +def locales(i18n_value: I18n) -> list[Any]: + """Return collected locale mounts. + + i18n.locales(messages) + """ + + return i18n_value.locales() + + +def set_translator(i18n_value: I18n, translator: Translator | None) -> I18n: + """Register a translator and return the handle. + + i18n.set_translator(messages, translator) + """ + + i18n_value.set_translator(translator) + return i18n_value + + +def translator(i18n_value: I18n) -> Translator | None: + """Return the registered translator. + + i18n.translator(messages) + """ + + return i18n_value.translator() + + +def translate(i18n_value: I18n, message_id: str, *args: Any) -> Any: + """Translate a message or return the key. + + i18n.translate(messages, "hello") + """ + + return i18n_value.translate(message_id, *args) + + +def set_language(i18n_value: I18n, lang: str) -> I18n: + """Set the active language and return the handle. + + i18n.set_language(messages, "de") + """ + + i18n_value.set_language(lang) + return i18n_value + + +def language(i18n_value: I18n) -> str: + """Return the active language. + + i18n.language(messages) + """ + + return i18n_value.language() + + +def available_languages(i18n_value: I18n) -> list[str]: + """Return available language codes. + + i18n.available_languages(messages) + """ + + return i18n_value.available_languages() + + +__all__ = [ + "I18n", + "Translator", + "add_locales", + "available_languages", + "language", + "locales", + "new", + "set_language", + "set_translator", + "translate", + "translator", +] diff --git a/py/core/info.py b/py/core/info.py new file mode 100644 index 0000000..58f8d56 --- /dev/null +++ b/py/core/info.py @@ -0,0 +1,128 @@ +"""Read-only system information helpers. + +from core import info + +print(info.env("OS")) +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import getpass +import os +from pathlib import Path +import platform +import shutil +import socket +import subprocess +import tempfile + + +def env(key: str) -> str: + """Return a system value by key, falling back to the process environment. + + info.env("OS") + """ + + return _SNAPSHOT.get(key, os.environ.get(key, "")) + + +def keys() -> list[str]: + """Return the available built-in keys. + + info.keys() + """ + + return sorted(_SNAPSHOT) + + +def snapshot() -> dict[str, str]: + """Return a copy of the built-in system snapshot. + + info.snapshot() + """ + + return dict(_SNAPSHOT) + + +def _build_snapshot() -> dict[str, str]: + home = Path(os.environ.get("CORE_HOME", Path.home())) + snapshot = { + "OS": _os_name(), + "ARCH": platform.machine().lower(), + "GO": _go_version(), + "DS": os.sep, + "PS": os.pathsep, + "PID": str(os.getpid()), + "NUM_CPU": str(os.cpu_count() or 0), + "USER": getpass.getuser(), + "HOSTNAME": socket.gethostname(), + "DIR_HOME": str(home), + "DIR_DOWNLOADS": str(home / "Downloads"), + "DIR_CODE": str(home / "Code"), + "DIR_TMP": tempfile.gettempdir(), + "DIR_CWD": str(Path.cwd()), + "CORE_START": _START, + } + snapshot["DIR_CONFIG"] = _config_dir(home) + snapshot["DIR_CACHE"] = _cache_dir(home) + snapshot["DIR_DATA"] = _data_dir(home) + return snapshot + + +def _os_name() -> str: + name = platform.system().lower() + if name == "darwin": + return "darwin" + if name.startswith("win"): + return "windows" + return name + + +def _go_version() -> str: + go_binary = shutil.which("go") + if go_binary is None: + return os.environ.get("GOVERSION", "") + completed = subprocess.run( + [go_binary, "env", "GOVERSION"], + capture_output=True, + check=False, + text=True, + ) + if completed.returncode == 0: + return completed.stdout.strip() + return os.environ.get("GOVERSION", "") + + +def _config_dir(home: Path) -> str: + os_name = _os_name() + if os_name == "darwin": + return str(home / "Library" / "Application Support") + if os_name == "windows": + return os.environ.get("APPDATA", str(home / "AppData" / "Roaming")) + return os.environ.get("XDG_CONFIG_HOME", str(home / ".config")) + + +def _cache_dir(home: Path) -> str: + os_name = _os_name() + if os_name == "darwin": + return str(home / "Library" / "Caches") + if os_name == "windows": + return os.environ.get("LOCALAPPDATA", str(home / "AppData" / "Local")) + return os.environ.get("XDG_CACHE_HOME", str(home / ".cache")) + + +def _data_dir(home: Path) -> str: + os_name = _os_name() + if os_name == "darwin": + return str(home / "Library") + if os_name == "windows": + return os.environ.get("LOCALAPPDATA", str(home / "AppData" / "Local")) + return os.environ.get("XDG_DATA_HOME", str(home / ".local" / "share")) + + +_START = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") +_SNAPSHOT = _build_snapshot() + + +__all__ = ["env", "keys", "snapshot"] diff --git a/py/core/json.py b/py/core/json.py new file mode 100644 index 0000000..a44f894 --- /dev/null +++ b/py/core/json.py @@ -0,0 +1,32 @@ +"""JSON helpers with Core-shaped naming. + +from core import json + +payload = json.dumps({"name": "corepy"}) +data = json.loads(payload) +""" + +from __future__ import annotations + +import json as jsonlib +from typing import Any + + +def dumps(value: Any, *, indent: int | None = None, sort_keys: bool = False) -> str: + """Serialise a value to JSON text. + + json.dumps({"name": "corepy"}) + """ + + return jsonlib.dumps(value, indent=indent, sort_keys=sort_keys) + + +def loads(value: str | bytes) -> Any: + """Deserialise JSON text or bytes. + + json.loads('{"name":"corepy"}') + """ + + if isinstance(value, bytes): + value = value.decode("utf-8") + return jsonlib.loads(value) diff --git a/py/core/log.py b/py/core/log.py new file mode 100644 index 0000000..8765459 --- /dev/null +++ b/py/core/log.py @@ -0,0 +1,91 @@ +"""Structured logging helpers with predictable names. + +from core import log + +log.set_level("info") +log.info("service started", "service", "corepy") +""" + +from __future__ import annotations + +import logging +from typing import Any + + +_LOGGER = logging.getLogger("core") +_QUIET_LEVEL = logging.CRITICAL + 10 +_LEVELS = { + "quiet": _QUIET_LEVEL, + "error": logging.ERROR, + "warn": logging.WARNING, + "info": logging.INFO, + "debug": logging.DEBUG, +} + +if not _LOGGER.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(levelname)s %(message)s")) + _LOGGER.addHandler(handler) +_LOGGER.propagate = False +_LOGGER.setLevel(logging.INFO) + + +def set_level(level: str | int) -> None: + """Set the logger level. + + log.set_level("debug") + """ + + if isinstance(level, str): + level_value = _LEVELS.get(level.lower()) + if level_value is None: + raise ValueError(f"unknown log level: {level}") + _LOGGER.setLevel(level_value) + return + _LOGGER.setLevel(level) + + +def debug(message: str, *keyvals: Any) -> None: + """Log a debug message with optional key-value pairs. + + log.debug("service started", "service", "corepy") + """ + + _emit(_LOGGER.debug, message, *keyvals) + + +def info(message: str, *keyvals: Any) -> None: + """Log an info message with optional key-value pairs. + + log.info("service started", "service", "corepy") + """ + + _emit(_LOGGER.info, message, *keyvals) + + +def warn(message: str, *keyvals: Any) -> None: + """Log a warning message with optional key-value pairs. + + log.warn("service slow", "service", "corepy") + """ + + _emit(_LOGGER.warning, message, *keyvals) + + +def error(message: str, *keyvals: Any) -> None: + """Log an error message with optional key-value pairs. + + log.error("service failed", "service", "corepy") + """ + + _emit(_LOGGER.error, message, *keyvals) + + +def _emit(writer: Any, message: str, *keyvals: Any) -> None: + if len(keyvals) % 2 != 0: + raise ValueError("keyvals must be key-value pairs") + if not keyvals: + writer(message) + return + pairs = [f"{key}={value}" for key, value in zip(keyvals[::2], keyvals[1::2])] + writer(f"{message} {' '.join(pairs)}") diff --git a/py/core/math/__init__.py b/py/core/math/__init__.py new file mode 100644 index 0000000..c2c598b --- /dev/null +++ b/py/core/math/__init__.py @@ -0,0 +1,144 @@ +"""Math helpers for Tier 1-friendly statistics and nearest-neighbour search. + +from core import math +from core.math import kdtree, knn, signal + +scores = [0.2, 0.4, 0.9] +average = math.mean(scores) +smoothed = math.moving_average([1, 3, 6, 10], window=2) +tree = kdtree.build([[0.0, 0.0], [1.0, 1.0]], metric="euclidean") +""" + +from __future__ import annotations + +import bisect +import math as mathlib +import statistics +from typing import Any, Iterable, Sequence + +from . import kdtree, knn, signal +from ._shared import Number, _float_values +from .kdtree import KDTree +from .signal import difference, moving_average + + +def mean(values: Iterable[Number]) -> float: + """Return the arithmetic mean of numeric values. + + math.mean([0.2, 0.4, 0.9]) + """ + + return statistics.fmean(_float_values(values)) + + +def median(values: Iterable[Number]) -> float: + """Return the median of numeric values. + + math.median([0.2, 0.4, 0.9]) + """ + + return float(statistics.median(_float_values(values))) + + +def variance(values: Iterable[Number]) -> float: + """Return the population variance of numeric values. + + math.variance([0.2, 0.4, 0.9]) + """ + + items = _float_values(values) + average = statistics.fmean(items) + return sum((value - average) ** 2 for value in items) / len(items) + + +def stdev(values: Iterable[Number]) -> float: + """Return the population standard deviation of numeric values. + + math.stdev([0.2, 0.4, 0.9]) + """ + + return mathlib.sqrt(variance(values)) + + +def sort(values: Sequence[Any]) -> list[Any]: + """Return a sorted copy of the values. + + math.sort([3, 1, 2]) + """ + + return sorted(values) + + +def binary_search(values: Sequence[Any], target: Any) -> int: + """Return the index of a sorted value or `-1`. + + math.binary_search([1, 2, 3], 2) + """ + + index = bisect.bisect_left(values, target) + if index >= len(values) or values[index] != target: + return -1 + return index + + +def epsilon_equal(left: Number, right: Number, epsilon: float = 1e-9) -> bool: + """Return True when two numbers are within epsilon. + + math.epsilon_equal(0.1 + 0.2, 0.3) + """ + + return abs(float(left) - float(right)) <= epsilon + + +def normalize(values: Iterable[Number]) -> list[float]: + """Scale values into the `[0, 1]` range. + + math.normalize([10, 20, 30]) + """ + + items = _float_values(values, allow_empty=True) + if not items: + return [] + minimum = min(items) + maximum = max(items) + if minimum == maximum: + return [0.0 for _ in items] + scale = maximum - minimum + return [(value - minimum) / scale for value in items] + + +def rescale(values: Iterable[Number], new_min: float, new_max: float) -> list[float]: + """Scale values into a target numeric range. + + math.rescale([10, 20, 30], -1.0, 1.0) + """ + + items = _float_values(values, allow_empty=True) + if not items: + return [] + minimum = min(items) + maximum = max(items) + if minimum == maximum: + return [float(new_min) for _ in items] + input_scale = maximum - minimum + output_scale = new_max - new_min + return [new_min + (((value - minimum) / input_scale) * output_scale) for value in items] + + +__all__ = [ + "KDTree", + "binary_search", + "difference", + "epsilon_equal", + "kdtree", + "knn", + "mean", + "median", + "moving_average", + "normalize", + "rescale", + "signal", + "sort", + "stdev", + "variance", +] diff --git a/py/core/math/_shared.py b/py/core/math/_shared.py new file mode 100644 index 0000000..2f0b959 --- /dev/null +++ b/py/core/math/_shared.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import math as mathlib +from typing import Any, Iterable, Sequence + + +Number = int | float + + +def _float_values(values: Iterable[Number], *, allow_empty: bool = False) -> list[float]: + items = [float(value) for value in values] + if not items and not allow_empty: + raise ValueError("values must not be empty") + return items + + +def _point(values: Sequence[Number]) -> tuple[float, ...]: + point = tuple(float(value) for value in values) + if not point: + raise ValueError("points must not be empty") + return point + + +def _search(points: Sequence[tuple[float, ...]], query: tuple[float, ...], k: int, metric: str) -> list[dict[str, Any]]: + if k <= 0: + raise ValueError("k must be positive") + metric_name = _metric(metric) + + neighbours = [ + { + "index": index, + "distance": _distance(metric_name, point, query), + "point": list(point), + } + for index, point in enumerate(points) + ] + neighbours.sort(key=lambda item: (item["distance"], item["index"])) + return neighbours[:k] + + +def _metric(metric: str) -> str: + metric_name = metric.lower() + if metric_name not in {"euclidean", "manhattan", "chebyshev", "cosine"}: + raise ValueError(f"unknown metric: {metric}") + return metric_name + + +def _distance(metric: str, left: Sequence[float], right: Sequence[float]) -> float: + if len(left) != len(right): + raise ValueError(f"point dimension mismatch: {len(left)} != {len(right)}") + + if metric == "euclidean": + return mathlib.sqrt(sum((a - b) ** 2 for a, b in zip(left, right))) + if metric == "manhattan": + return sum(abs(a - b) for a, b in zip(left, right)) + if metric == "chebyshev": + return max(abs(a - b) for a, b in zip(left, right)) + + dot_product = sum(a * b for a, b in zip(left, right)) + left_norm = mathlib.sqrt(sum(a * a for a in left)) + right_norm = mathlib.sqrt(sum(b * b for b in right)) + if left_norm == 0 and right_norm == 0: + return 0.0 + if left_norm == 0 or right_norm == 0: + return 1.0 + return 1.0 - (dot_product / (left_norm * right_norm)) diff --git a/py/core/math/kdtree.py b/py/core/math/kdtree.py new file mode 100644 index 0000000..a8ac6ef --- /dev/null +++ b/py/core/math/kdtree.py @@ -0,0 +1,44 @@ +"""KDTree-style nearest-neighbour helpers. + +from core.math.kdtree import build + +tree = build([[0.0, 0.0], [1.0, 1.0]], metric="euclidean") +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Sequence + +from ._shared import Number, _metric, _point, _search + + +@dataclass(slots=True) +class KDTree: + """In-memory nearest-neighbour index for vector points. + + tree = build([[0.0, 0.0], [1.0, 1.0]]) + """ + + points: list[tuple[float, ...]] + metric: str = "euclidean" + + def nearest(self, query: Sequence[Number], k: int = 1) -> list[dict[str, Any]]: + """Return the `k` nearest points to the query. + + tree.nearest([0.8, 0.8], k=2) + """ + + return _search(self.points, _point(query), k, self.metric) + + +def build(points: Sequence[Sequence[Number]], metric: str = "euclidean") -> KDTree: + """Build a KDTree-like handle. + + build([[0.0, 0.0], [1.0, 1.0]], metric="euclidean") + """ + + return KDTree(points=[_point(point) for point in points], metric=_metric(metric)) + + +__all__ = ["KDTree", "build"] diff --git a/py/core/math/knn.py b/py/core/math/knn.py new file mode 100644 index 0000000..5c267fc --- /dev/null +++ b/py/core/math/knn.py @@ -0,0 +1,29 @@ +"""KNN helpers exposed on the RFC import path. + +from core.math.knn import search + +neighbours = search([[0.0, 0.0], [1.0, 1.0]], [0.8, 0.8], k=1) +""" + +from __future__ import annotations + +from typing import Any, Sequence + +from ._shared import Number, _metric, _point, _search + + +def search( + points: Sequence[Sequence[Number]], + query: Sequence[Number], + k: int = 1, + metric: str = "euclidean", +) -> list[dict[str, Any]]: + """Return the `k` nearest points without building a tree handle. + + search([[0.0, 0.0], [1.0, 1.0]], [0.8, 0.8], k=1) + """ + + return _search([_point(point) for point in points], _point(query), k, _metric(metric)) + + +__all__ = ["search"] diff --git a/py/core/math/signal.py b/py/core/math/signal.py new file mode 100644 index 0000000..dc7161f --- /dev/null +++ b/py/core/math/signal.py @@ -0,0 +1,54 @@ +"""Signal-processing helpers for Tier 1-friendly filtering and transforms. + +from core.math import signal + +smoothed = signal.moving_average([1, 3, 6, 10], window=2) +delta = signal.difference([1, 3, 6, 10]) +""" + +from __future__ import annotations + +from ._shared import Number, _float_values + + +def moving_average(values: list[Number] | tuple[Number, ...], window: int = 1) -> list[float]: + """Return a trailing moving average for each sample. + + signal.moving_average([1, 3, 6, 10], window=2) + """ + + if window <= 0: + raise ValueError("window must be positive") + + items = _float_values(values, allow_empty=True) + if not items: + return [] + + result: list[float] = [] + total = 0.0 + for index, value in enumerate(items): + total += value + if index >= window: + total -= items[index - window] + + sample_count = min(index + 1, window) + result.append(total / sample_count) + return result + + +def difference(values: list[Number] | tuple[Number, ...], lag: int = 1) -> list[float]: + """Return the finite difference transform for a sequence. + + signal.difference([1, 3, 6, 10]) + """ + + if lag <= 0: + raise ValueError("lag must be positive") + + items = _float_values(values, allow_empty=True) + if lag >= len(items): + return [] + return [items[index] - items[index - lag] for index in range(lag, len(items))] + + +__all__ = ["difference", "moving_average"] diff --git a/py/core/mcp.py b/py/core/mcp.py new file mode 100644 index 0000000..b9f2216 --- /dev/null +++ b/py/core/mcp.py @@ -0,0 +1,16 @@ +"""core.mcp — MCP tool protocol primitives. + + available() +""" + + +def available() -> bool: + """Return whether the native MCP binding is active. + + available() + """ + + return False + + +__all__ = ["available"] diff --git a/py/core/medium.py b/py/core/medium.py new file mode 100644 index 0000000..ded5d65 --- /dev/null +++ b/py/core/medium.py @@ -0,0 +1,383 @@ +"""Simple transport wrapper for memory, filesystem, or Qdrant-backed content. + +from core import medium + +buffer = medium.memory("hello") +buffer.write_text("updated") +remote = medium.open("qdrant://localhost:6333/core_medium") +""" + +from __future__ import annotations + +import base64 +import binascii +from dataclasses import dataclass +import json as stdlib_json +from pathlib import Path +from typing import Any +from urllib import error as url_error +from urllib import parse as url_parse +from urllib import request as url_request + +from . import fs as core_fs + + +class MediumError(RuntimeError): + """Raised when a medium backend cannot complete an operation.""" + + +class QdrantError(MediumError): + """Raised when Qdrant returns an error or an unexpected response.""" + + +@dataclass(slots=True) +class Medium: + """Text or bytes transport for memory and filesystem targets. + + medium.memory("hello") + """ + + location: str | Path | None = None + text: str = "" + data: bytes = b"" + + def read_text(self) -> str: + """Read text from the medium. + + buffer.read_text() + """ + + if self.location is None: + return self.text + return Path(self.location).read_text(encoding="utf-8") + + def write_text(self, value: str) -> str: + """Write text into the medium. + + buffer.write_text("updated") + """ + + if self.location is None: + self.text = value + self.data = value.encode("utf-8") + return value + path = Path(self.location) + core_fs.ensure_dir(path.parent) + path.write_text(value, encoding="utf-8") + return value + + def read_bytes(self) -> bytes: + """Read bytes from the medium. + + buffer.read_bytes() + """ + + if self.location is None: + return self.data if self.data else self.text.encode("utf-8") + return Path(self.location).read_bytes() + + def write_bytes(self, value: bytes) -> bytes: + """Write bytes into the medium. + + buffer.write_bytes(b"updated") + """ + + if self.location is None: + self.data = value + try: + self.text = value.decode("utf-8") + except UnicodeDecodeError: + self.text = "" + return value + path = Path(self.location) + core_fs.ensure_dir(path.parent) + path.write_bytes(value) + return value + + +@dataclass(slots=True) +class QdrantMedium: + """Text or bytes transport backed by one Qdrant collection point. + + medium.open("qdrant://localhost:6333/core_medium") + """ + + base_url: str + collection: str + point_id: int | str = 1 + payload_field: str = "text" + bytes_field: str = "data_base64" + timeout: float = 10.0 + + @classmethod + def from_url(cls, url: str) -> QdrantMedium: + """Create a Qdrant medium from a qdrant://host:port/collection URL.""" + + parsed = url_parse.urlparse(url) + if parsed.scheme != "qdrant": + raise ValueError("qdrant medium URLs must use the qdrant scheme") + if not parsed.hostname: + raise ValueError("qdrant medium URLs must include a host") + try: + parsed_port = parsed.port + except ValueError as exc: + raise ValueError("qdrant medium URL has an invalid port") from exc + + collection_parts = [url_parse.unquote(part) for part in parsed.path.split("/") if part] + if len(collection_parts) != 1: + raise ValueError("qdrant medium URLs must be qdrant://host:port/collection") + + query = url_parse.parse_qs(parsed.query, keep_blank_values=True) + point_id = _parse_point_id(_query_value(query, "point", "id", default="1")) + payload_field = _query_value(query, "field", "payload_field", default="text") + bytes_field = _query_value(query, "bytes_field", default="data_base64") + timeout = _parse_timeout(_query_value(query, "timeout", default="10")) + + host = parsed.hostname + host_part = f"[{host}]" if ":" in host and not host.startswith("[") else host + netloc = f"{host_part}:{parsed_port}" if parsed_port is not None else host_part + + return cls( + base_url=f"http://{netloc}", + collection=collection_parts[0], + point_id=point_id, + payload_field=payload_field, + bytes_field=bytes_field, + timeout=timeout, + ) + + def read_text(self) -> str: + """Read text from a Qdrant point payload.""" + + payload = self._retrieve_payload() + value = payload.get(self.payload_field) + if isinstance(value, str): + return value + + encoded = payload.get(self.bytes_field) + if isinstance(encoded, str): + try: + return _decode_base64(encoded).decode("utf-8") + except UnicodeDecodeError as exc: + raise QdrantError(f"qdrant payload field {self.bytes_field!r} is not UTF-8 text") from exc + + raise QdrantError(f"qdrant payload field {self.payload_field!r} is missing or not text") + + def write_text(self, value: str) -> str: + """Write text into a Qdrant point payload.""" + + self._set_payload({self.payload_field: value}) + return value + + def read_bytes(self) -> bytes: + """Read bytes from a Qdrant point payload.""" + + payload = self._retrieve_payload() + encoded = payload.get(self.bytes_field) + if isinstance(encoded, str): + return _decode_base64(encoded) + + value = payload.get(self.payload_field) + if isinstance(value, str): + return value.encode("utf-8") + + raise QdrantError(f"qdrant payload fields {self.bytes_field!r} and {self.payload_field!r} are missing") + + def write_bytes(self, value: bytes) -> bytes: + """Write bytes into a Qdrant point payload.""" + + payload = {self.bytes_field: base64.b64encode(value).decode("ascii")} + try: + payload[self.payload_field] = value.decode("utf-8") + except UnicodeDecodeError: + payload[self.payload_field] = "" + self._set_payload(payload) + return value + + def _retrieve_payload(self) -> dict[str, Any]: + result = self._request( + "POST", + self._points_path(), + {"ids": [self.point_id], "with_payload": True, "with_vector": False}, + ).get("result") + if not isinstance(result, list): + raise QdrantError("qdrant retrieve response is missing a result list") + if not result: + raise QdrantError(f"qdrant point {self.point_id!r} was not found in collection {self.collection!r}") + + point = result[0] + if not isinstance(point, dict): + raise QdrantError("qdrant retrieve response contains an invalid point") + payload = point.get("payload") + if not isinstance(payload, dict): + raise QdrantError("qdrant retrieve response is missing a payload object") + return payload + + def _set_payload(self, payload: dict[str, Any]) -> None: + self._request("POST", self._payload_path(), {"payload": payload, "points": [self.point_id]}) + + def _points_path(self) -> str: + collection = url_parse.quote(self.collection, safe="") + return f"/collections/{collection}/points" + + def _payload_path(self) -> str: + collection = url_parse.quote(self.collection, safe="") + return f"/collections/{collection}/points/payload" + + def _request(self, method: str, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]: + url = f"{self.base_url}{path}" + data = None + headers = {"Accept": "application/json"} + if body is not None: + data = stdlib_json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + + request = url_request.Request(url, data=data, headers=headers, method=method) + try: + response = url_request.urlopen(request, timeout=self.timeout) + try: + status = getattr(response, "status", getattr(response, "code", 200)) + raw_body = response.read() + finally: + close = getattr(response, "close", None) + if close is not None: + close() + except url_error.HTTPError as exc: + detail = _read_error_detail(exc) + raise QdrantError(f"qdrant {method} {path} failed with HTTP {exc.code}: {detail}") from exc + except url_error.URLError as exc: + reason = getattr(exc, "reason", exc) + raise QdrantError(f"qdrant {method} {path} failed: {reason}") from exc + + if status < 200 or status >= 300: + raise QdrantError(f"qdrant {method} {path} failed with HTTP {status}: {_decode_body(raw_body)}") + if not raw_body: + return {} + + try: + payload = stdlib_json.loads(raw_body.decode("utf-8")) + except (UnicodeDecodeError, stdlib_json.JSONDecodeError) as exc: + raise QdrantError(f"qdrant {method} {path} returned invalid JSON") from exc + if not isinstance(payload, dict): + raise QdrantError(f"qdrant {method} {path} returned a non-object response") + + status_value = payload.get("status") + if status_value not in (None, "ok"): + raise QdrantError(f"qdrant {method} {path} returned status {status_value!r}") + return payload + + +def memory(initial_text: str = "") -> Medium: + """Create an in-memory medium. + + medium.memory("hello") + """ + + return Medium(text=initial_text, data=initial_text.encode("utf-8")) + + +def from_path(path: str | Path) -> Medium: + """Create a filesystem-backed medium. + + medium.from_path("/tmp/corepy.txt") + """ + + return Medium(location=path) + + +def open(target: str | Path) -> Medium | QdrantMedium: + """Open a filesystem or Qdrant-backed medium. + + medium.open("qdrant://localhost:6333/core_medium") + """ + + target_text = str(target) + parsed = url_parse.urlparse(target_text) + if parsed.scheme == "qdrant": + return QdrantMedium.from_url(target_text) + if parsed.scheme == "file": + return from_path(url_parse.unquote(parsed.path)) + if parsed.scheme: + raise ValueError(f"unsupported medium URL scheme {parsed.scheme!r}") + return from_path(target) + + +def read_text(medium_value: Medium | QdrantMedium) -> str: + """Read text from a medium handle. + + medium.read_text(buffer) + """ + + return medium_value.read_text() + + +def write_text(medium_value: Medium | QdrantMedium, value: str) -> str: + """Write text to a medium handle. + + medium.write_text(buffer, "updated") + """ + + return medium_value.write_text(value) + + +def read_bytes(medium_value: Medium | QdrantMedium) -> bytes: + """Read bytes from a medium handle. + + medium.read_bytes(buffer) + """ + + return medium_value.read_bytes() + + +def write_bytes(medium_value: Medium | QdrantMedium, value: bytes) -> bytes: + """Write bytes to a medium handle. + + medium.write_bytes(buffer, b"updated") + """ + + return medium_value.write_bytes(value) + + +def _query_value(query: dict[str, list[str]], *names: str, default: str) -> str: + for name in names: + values = query.get(name) + if values: + return values[0] + return default + + +def _parse_point_id(value: str) -> int | str: + return int(value) if value.isdecimal() else value + + +def _parse_timeout(value: str) -> float: + try: + timeout = float(value) + except ValueError as exc: + raise ValueError("qdrant medium URL timeout must be a number") from exc + if timeout <= 0: + raise ValueError("qdrant medium URL timeout must be positive") + return timeout + + +def _decode_base64(value: str) -> bytes: + try: + return base64.b64decode(value.encode("ascii"), validate=True) + except (UnicodeEncodeError, binascii.Error) as exc: + raise QdrantError("qdrant payload contains invalid base64 bytes") from exc + + +def _read_error_detail(error: url_error.HTTPError) -> str: + try: + return _decode_body(error.read()) + except OSError: + return error.reason + + +def _decode_body(body: bytes) -> str: + if not body: + return "" + try: + return body.decode("utf-8") + except UnicodeDecodeError: + return repr(body) diff --git a/py/core/options.py b/py/core/options.py new file mode 100644 index 0000000..2fd746e --- /dev/null +++ b/py/core/options.py @@ -0,0 +1,195 @@ +"""Typed option primitives with AX-style examples. + +from core import options + +opts = options.Options({"name": "corepy", "port": 8080}) +opts.set("debug", True) +""" + +from __future__ import annotations + +import builtins +from dataclasses import dataclass +from typing import Any, Iterable, Mapping + + +@dataclass(slots=True) +class Option: + """Single key-value pair. + + options.Option("name", "corepy") + """ + + key: str + value: Any + + +class Options: + """Core-shaped collection of key-value options. + + opts = options.Options({"name": "corepy"}) + """ + + def __init__(self, values: Mapping[str, Any] | Iterable[Option] | None = None) -> None: + self._items: dict[str, Any] = {} + if values is None: + return + if isinstance(values, Mapping): + for key, value in values.items(): + self._items[str(key)] = value + return + for item in values: + self._items[item.key] = item.value + + def set(self, key: str, value: Any) -> None: + """Add or replace an option. + + opts.set("port", 8080) + """ + + self._items[key] = value + + def get(self, key: str, default: Any = None) -> Any: + """Return an option value or the provided default. + + opts.get("name") + """ + + return self._items.get(key, default) + + def has(self, key: str) -> bool: + """Return True when the option exists. + + opts.has("debug") + """ + + return key in self._items + + def string(self, key: str) -> str: + """Return a string value or an empty string. + + opts.string("name") + """ + + value = self.get(key, "") + return value if isinstance(value, str) else "" + + def int(self, key: str) -> int: + """Return an integer value or zero. + + opts.int("port") + """ + + value = self.get(key, 0) + return value if isinstance(value, builtins.int) and not isinstance(value, builtins.bool) else 0 + + def bool(self, key: str) -> bool: + """Return a boolean value or False. + + opts.bool("debug") + """ + + value = self.get(key, False) + return value if isinstance(value, builtins.bool) else False + + def items(self) -> list[Option]: + """Return the option items in insertion order. + + opts.items() + """ + + return [Option(key=key, value=value) for key, value in self._items.items()] + + def to_dict(self) -> dict[str, Any]: + """Return a plain dictionary copy. + + opts.to_dict() + """ + + return dict(self._items) + + def __len__(self) -> int: + return len(self._items) + + def __contains__(self, key: str) -> bool: + return self.has(key) + + +def _coerce(value: Options | Mapping[str, Any]) -> Options: + if isinstance(value, Options): + return value + return Options(value) + + +def new(values: Mapping[str, Any] | Iterable[Option] | None = None) -> Options: + """Create an Options handle. + + options.new({"name": "corepy"}) + """ + + return Options(values) + + +def set(options_value: Options | Mapping[str, Any], key: str, value: Any) -> Options: + """Set an option on a handle and return it. + + options.set(opts, "port", 8080) + """ + + handle = _coerce(options_value) + handle.set(key, value) + return handle + + +def get(options_value: Options | Mapping[str, Any], key: str) -> Any: + """Read an option value from a handle. + + options.get(opts, "name") + """ + + return _coerce(options_value).get(key) + + +def has(options_value: Options | Mapping[str, Any], key: str) -> bool: + """Return True when an option exists on a handle. + + options.has(opts, "debug") + """ + + return _coerce(options_value).has(key) + + +def string(options_value: Options | Mapping[str, Any], key: str) -> str: + """Read a string option from a handle. + + options.string(opts, "name") + """ + + return _coerce(options_value).string(key) + + +def int(options_value: Options | Mapping[str, Any], key: str) -> builtins.int: + """Read an integer option from a handle. + + options.int(opts, "port") + """ + + return _coerce(options_value).int(key) + + +def bool(options_value: Options | Mapping[str, Any], key: str) -> builtins.bool: + """Read a boolean option from a handle. + + options.bool(opts, "debug") + """ + + return _coerce(options_value).bool(key) + + +def items(options_value: Options | Mapping[str, Any]) -> dict[str, Any]: + """Return a plain dictionary copy of the option items. + + options.items(opts) + """ + + return _coerce(options_value).to_dict() diff --git a/py/core/path.py b/py/core/path.py new file mode 100644 index 0000000..28fc25b --- /dev/null +++ b/py/core/path.py @@ -0,0 +1,92 @@ +"""Path helpers with slash-shaped examples. + +from core import path + +location = path.join("deploy", "to", "homelab") +name = path.base(location) +""" + +from __future__ import annotations + +from glob import glob as glob_paths +import posixpath + + +def join(*segments: str) -> str: + """Join path segments with `/`. + + path.join("deploy", "to", "homelab") + """ + + return "/".join(segments) + + +def base(value: str) -> str: + """Return the last path element. + + path.base("/tmp/corepy/config.json") + """ + + if value == "": + return "." + trimmed = value.rstrip("/") + if trimmed == "": + return "/" + return trimmed.split("/")[-1] + + +def dir(value: str) -> str: + """Return all but the last path element. + + path.dir("/tmp/corepy/config.json") + """ + + if value == "": + return "." + index = value.rfind("/") + if index < 0: + return "." + directory = value[:index] + return "/" if directory == "" else directory + + +def ext(value: str) -> str: + """Return the file extension including the dot. + + path.ext("config.json") + """ + + name = base(value) + index = name.rfind(".") + if index <= 0: + return "" + return name[index:] + + +def is_abs(value: str) -> bool: + """Return True when the path is absolute. + + path.is_abs("/tmp/corepy") + """ + + return value.startswith("/") or (len(value) >= 3 and value[1] == ":" and value[2] in ("/", "\\")) + + +def clean(value: str) -> str: + """Collapse duplicate separators and `..` segments. + + path.clean("deploy//to/../from") + """ + + if value == "": + return "." + return posixpath.normpath(value) + + +def glob(pattern: str) -> list[str]: + """Return filesystem paths that match a glob pattern. + + path.glob("/tmp/corepy/*.json") + """ + + return glob_paths(pattern) diff --git a/py/core/process.py b/py/core/process.py new file mode 100644 index 0000000..e08b674 --- /dev/null +++ b/py/core/process.py @@ -0,0 +1,163 @@ +"""Process helpers for Tier 2 and local validation. + +from core import process + +output = process.run("python3", "-c", "print('hello')") +""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +from collections.abc import Mapping, Sequence + + +def run( + command: str, + *arguments: str, + directory: str | Path | None = None, + env: Mapping[str, str] | Sequence[str] | None = None, + timeout: float | None = None, + check: bool = True, +) -> str: + """Run a command and return standard output. + + process.run("python3", "-c", "print('hello')") + """ + + result = run_result( + command, + *arguments, + directory=directory, + env=env, + timeout=timeout, + check=check, + ) + return str(result["stdout"]) + + +def run_result( + command: str, + *arguments: str, + directory: str | Path | None = None, + env: Mapping[str, str] | Sequence[str] | None = None, + timeout: float | None = None, + check: bool = False, +) -> dict[str, object]: + """Run a command and return stdout, stderr, exit code, and timeout state. + + process.run_result("python3", "-c", "print('hello')") + """ + + command_line = [command, *arguments] + try: + completed = subprocess.run( + command_line, + capture_output=True, + check=False, + cwd=None if directory is None else str(directory), + env=_merged_env(env), + timeout=timeout, + text=True, + ) + except subprocess.TimeoutExpired as exc: + result = { + "command": command_line, + "stdout": _text(exc.stdout), + "stderr": _text(exc.stderr), + "exit_code": -1, + "timed_out": True, + "ok": False, + } + if check: + raise + return result + + result = { + "command": command_line, + "stdout": completed.stdout, + "stderr": completed.stderr, + "exit_code": completed.returncode, + "timed_out": False, + "ok": completed.returncode == 0, + } + if check and completed.returncode != 0: + raise subprocess.CalledProcessError( + completed.returncode, + completed.args, + output=completed.stdout, + stderr=completed.stderr, + ) + return result + + +def run_in( + directory: str | Path, + command: str, + *arguments: str, + timeout: float | None = None, + check: bool = True, +) -> str: + """Run a command in a specific directory. + + process.run_in("/tmp", "python3", "-c", "print('hello')") + """ + + return run(command, *arguments, directory=directory, timeout=timeout, check=check) + + +def run_with_env( + directory: str | Path, + env: Mapping[str, str] | Sequence[str], + command: str, + *arguments: str, + timeout: float | None = None, + check: bool = True, +) -> str: + """Run a command with extra environment variables. + + process.run_with_env("/tmp", {"MODE": "test"}, "python3", "-c", "print('hello')") + """ + + return run(command, *arguments, directory=directory, env=env, timeout=timeout, check=check) + + +def exists() -> bool: + """Return True when subprocess execution is available. + + process.exists() + """ + + return True + + +def _text(value: str | bytes | None) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode(errors="replace") + return value + + +def _merged_env(env: Mapping[str, str] | Sequence[str] | None) -> dict[str, str] | None: + if env is None: + return None + + merged_env = os.environ.copy() + if isinstance(env, Mapping): + for key, value in env.items(): + merged_env[str(key)] = str(value) + return merged_env + + if isinstance(env, Sequence) and not isinstance(env, (str, bytes, bytearray)): + for entry in env: + if not isinstance(entry, str): + raise TypeError("environment entries must be strings") + key, separator, value = entry.partition("=") + if separator == "": + raise ValueError("environment entries must be KEY=value strings") + merged_env[key] = value + return merged_env + + raise TypeError("env must be a mapping or a sequence of KEY=value strings") diff --git a/py/core/py.typed b/py/core/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/py/core/py.typed @@ -0,0 +1 @@ + diff --git a/py/core/registry.py b/py/core/registry.py new file mode 100644 index 0000000..01e5565 --- /dev/null +++ b/py/core/registry.py @@ -0,0 +1,338 @@ +"""Thread-safe named collection helpers. + +from core import registry + +items = registry.new() +registry.set(items, "brain", {"enabled": True}) +""" + +from __future__ import annotations + +import builtins +from fnmatch import fnmatchcase +from typing import Any + + +class Registry: + """Named collection with insertion order and lock modes. + + items = registry.Registry() + """ + + def __init__(self) -> None: + self._items: dict[str, Any] = {} + self._disabled: set[str] = builtins.set() + self._order: list[str] = [] + self._mode = "open" + + def set(self, name: str, value: Any) -> None: + """Store or update a named value. + + items.set("brain", value) + """ + + if self._mode == "locked": + raise RuntimeError(f"registry is locked, cannot set: {name}") + if self._mode == "sealed" and name not in self._items: + raise RuntimeError(f"registry is sealed, cannot add new key: {name}") + if name not in self._items: + self._order.append(name) + self._items[name] = value + + def get(self, name: str, default: Any = None) -> Any: + """Return a named value or the provided default. + + items.get("brain") + """ + + return self._items.get(name, default) + + def has(self, name: str) -> bool: + """Return True when the name exists. + + items.has("brain") + """ + + return name in self._items + + def names(self) -> list[str]: + """Return names in insertion order. + + items.names() + """ + + return builtins.list(self._order) + + def list(self, pattern: str) -> list[Any]: + """Return enabled values whose names match a glob. + + items.list("process.*") + """ + + return [ + self._items[name] + for name in self._order + if name not in self._disabled and fnmatchcase(name, pattern) + ] + + def len(self) -> int: + """Return the number of stored values. + + items.len() + """ + + return builtins.len(self._items) + + def delete(self, name: str) -> bool: + """Delete a named value. + + items.delete("brain") + """ + + if self._mode == "locked": + raise RuntimeError(f"registry is locked, cannot delete: {name}") + if name not in self._items: + raise KeyError(name) + del self._items[name] + self._disabled.discard(name) + self._order = [item for item in self._order if item != name] + return True + + def disable(self, name: str) -> None: + """Soft-disable a named value. + + items.disable("brain") + """ + + if name not in self._items: + raise KeyError(name) + self._disabled.add(name) + + def enable(self, name: str) -> None: + """Re-enable a soft-disabled value. + + items.enable("brain") + """ + + if name not in self._items: + raise KeyError(name) + self._disabled.discard(name) + + def disabled(self, name: str) -> bool: + """Return True when the named value is disabled. + + items.disabled("brain") + """ + + return name in self._disabled + + def lock(self) -> None: + """Fully freeze the registry. + + items.lock() + """ + + self._mode = "locked" + + def locked(self) -> bool: + """Return True when the registry is fully locked. + + items.locked() + """ + + return self._mode == "locked" + + def seal(self) -> None: + """Disallow new keys while permitting updates. + + items.seal() + """ + + self._mode = "sealed" + + def sealed(self) -> bool: + """Return True when the registry is sealed. + + items.sealed() + """ + + return self._mode == "sealed" + + def open(self) -> None: + """Reset the registry to open mode. + + items.open() + """ + + self._mode = "open" + + +def new() -> Registry: + """Create a new Registry handle. + + registry.new() + """ + + return Registry() + + +def set(registry_value: Registry, name: str, value: Any) -> Registry: + """Store or update a named value and return the handle. + + registry.set(items, "brain", value) + """ + + registry_value.set(name, value) + return registry_value + + +def get(registry_value: Registry, name: str, default: Any = None) -> Any: + """Return a named value or the default. + + registry.get(items, "brain") + """ + + return registry_value.get(name, default) + + +def has(registry_value: Registry, name: str) -> bool: + """Return True when the name exists. + + registry.has(items, "brain") + """ + + return registry_value.has(name) + + +def names(registry_value: Registry) -> list[str]: + """Return names in insertion order. + + registry.names(items) + """ + + return registry_value.names() + + +def list(registry_value: Registry, pattern: str) -> list[Any]: + """Return enabled values that match a glob. + + registry.list(items, "process.*") + """ + + return registry_value.list(pattern) + + +def len(registry_value: Registry) -> int: + """Return the number of stored values. + + registry.len(items) + """ + + return registry_value.len() + + +def delete(registry_value: Registry, name: str) -> bool: + """Delete a named value. + + registry.delete(items, "brain") + """ + + return registry_value.delete(name) + + +def disable(registry_value: Registry, name: str) -> Registry: + """Soft-disable a named value and return the handle. + + registry.disable(items, "brain") + """ + + registry_value.disable(name) + return registry_value + + +def enable(registry_value: Registry, name: str) -> Registry: + """Re-enable a named value and return the handle. + + registry.enable(items, "brain") + """ + + registry_value.enable(name) + return registry_value + + +def disabled(registry_value: Registry, name: str) -> bool: + """Return True when a name is disabled. + + registry.disabled(items, "brain") + """ + + return registry_value.disabled(name) + + +def lock(registry_value: Registry) -> Registry: + """Lock the registry and return the handle. + + registry.lock(items) + """ + + registry_value.lock() + return registry_value + + +def locked(registry_value: Registry) -> bool: + """Return True when the registry is locked. + + registry.locked(items) + """ + + return registry_value.locked() + + +def seal(registry_value: Registry) -> Registry: + """Seal the registry and return the handle. + + registry.seal(items) + """ + + registry_value.seal() + return registry_value + + +def sealed(registry_value: Registry) -> bool: + """Return True when the registry is sealed. + + registry.sealed(items) + """ + + return registry_value.sealed() + + +def open(registry_value: Registry) -> Registry: + """Reset the registry to open mode and return the handle. + + registry.open(items) + """ + + registry_value.open() + return registry_value + + +__all__ = [ + "Registry", + "delete", + "disable", + "disabled", + "enable", + "get", + "has", + "len", + "list", + "lock", + "locked", + "names", + "new", + "open", + "seal", + "sealed", + "set", +] diff --git a/py/core/scm.py b/py/core/scm.py new file mode 100644 index 0000000..e95283c --- /dev/null +++ b/py/core/scm.py @@ -0,0 +1,106 @@ +"""Git-backed source-control helpers. + +from core import scm + +if scm.exists("."): + print(scm.branch(".")) +""" + +from __future__ import annotations + +from pathlib import Path +import shutil +import subprocess +from typing import Any + + +def exists(directory: str | Path = ".") -> bool: + """Return True when the directory is inside a Git worktree. + + scm.exists(".") + """ + + if shutil.which("git") is None: + return False + completed = _git(directory, "rev-parse", "--is-inside-work-tree", check=False) + return completed.strip() == "true" + + +def root(directory: str | Path = ".") -> str: + """Return the repository root directory. + + scm.root(".") + """ + + return _git(directory, "rev-parse", "--show-toplevel").strip() + + +def branch(directory: str | Path = ".") -> str: + """Return the current branch name. + + scm.branch(".") + """ + + return _git(directory, "rev-parse", "--abbrev-ref", "HEAD").strip() + + +def head(directory: str | Path = ".") -> str: + """Return the current HEAD commit hash. + + scm.head(".") + """ + + return _git(directory, "rev-parse", "HEAD").strip() + + +def tracked_files(directory: str | Path = ".") -> list[str]: + """Return tracked repository paths. + + scm.tracked_files(".") + """ + + output = _git(directory, "ls-files") + return [line for line in output.splitlines() if line] + + +def status(directory: str | Path = ".") -> dict[str, Any]: + """Return branch, cleanliness, and change lines. + + scm.status(".") + """ + + output = _git(directory, "status", "--short", "--branch") + lines = [line.rstrip() for line in output.splitlines() if line.rstrip()] + branch_name = "" + if lines and lines[0].startswith("## "): + branch_name = lines[0][3:].strip() + lines = lines[1:] + return { + "branch": branch_name, + "clean": len(lines) == 0, + "changes": lines, + } + + +def _git(directory: str | Path, *arguments: str, check: bool = True) -> str: + git_binary = shutil.which("git") + if git_binary is None: + raise RuntimeError("git is not available") + + completed = subprocess.run( + [git_binary, "-C", str(directory), *arguments], + capture_output=True, + check=False, + text=True, + ) + if check and completed.returncode != 0: + raise subprocess.CalledProcessError( + completed.returncode, + completed.args, + output=completed.stdout, + stderr=completed.stderr, + ) + return completed.stdout + + +__all__ = ["branch", "exists", "head", "root", "status", "tracked_files"] diff --git a/py/core/service.py b/py/core/service.py new file mode 100644 index 0000000..a3de36d --- /dev/null +++ b/py/core/service.py @@ -0,0 +1,148 @@ +"""Service registry helpers with concrete lifecycle examples. + +from core import service + +registry = service.ServiceRegistry() +registry.register("brain", service.Service(name="brain")) +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + + +@dataclass(slots=True) +class Service: + """Service DTO with optional lifecycle hooks. + + service.Service(name="brain") + """ + + name: str + instance: Any = None + on_start: Callable[[], Any] | None = None + on_stop: Callable[[], Any] | None = None + on_reload: Callable[[], Any] | None = None + + +class ServiceRegistry: + """Ordered service registry. + + registry = service.ServiceRegistry() + """ + + def __init__(self) -> None: + self._services: dict[str, Service] = {"cli": Service(name="cli")} + + def register(self, name: str, service_value: Service | Any) -> None: + """Register a service by name. + + registry.register("brain", service.Service(name="brain")) + """ + + if isinstance(service_value, Service): + service_object = service_value + service_object.name = name + else: + service_object = Service(name=name, instance=service_value) + self._services[name] = service_object + + def get(self, name: str) -> Any: + """Return the service instance or DTO. + + registry.get("brain") + """ + + service_object = self._services[name] + return service_object.instance if service_object.instance is not None else service_object + + def names(self) -> list[str]: + """Return registered service names. + + registry.names() + """ + + return list(self._services.keys()) + + def start_all(self) -> list[Any]: + """Run `on_start` hooks in registration order. + + registry.start_all() + """ + + results: list[Any] = [] + for service_object in self._services.values(): + if service_object.on_start is not None: + results.append(service_object.on_start()) + return results + + def stop_all(self) -> list[Any]: + """Run `on_stop` hooks in registration order. + + registry.stop_all() + """ + + results: list[Any] = [] + for service_object in self._services.values(): + if service_object.on_stop is not None: + results.append(service_object.on_stop()) + return results + + +def new(name: str = "") -> ServiceRegistry: + """Create a service registry handle. + + service.new("corepy") + """ + + _ = name + return ServiceRegistry() + + +def register(registry: ServiceRegistry, name: str, service_value: Service | Any | None = None) -> ServiceRegistry: + """Register a service on a handle and return it. + + service.register(registry, "brain") + """ + + registry.register(name, Service(name=name) if service_value is None else service_value) + return registry + + +def get(registry: ServiceRegistry, name: str) -> Any: + """Read a service from a handle. + + service.get(registry, "brain") + """ + + return registry.get(name) + + +def names(registry: ServiceRegistry) -> list[str]: + """Return service names from a handle. + + service.names(registry) + """ + + return registry.names() + + +def start_all(registry: ServiceRegistry) -> bool: + """Run startup hooks for a handle. + + service.start_all(registry) + """ + + registry.start_all() + return True + + +def stop_all(registry: ServiceRegistry) -> bool: + """Run shutdown hooks for a handle. + + service.stop_all(registry) + """ + + registry.stop_all() + return True diff --git a/py/core/store.py b/py/core/store.py new file mode 100644 index 0000000..1f8261d --- /dev/null +++ b/py/core/store.py @@ -0,0 +1,16 @@ +"""core.store — SQLite KV and workspace primitives. + + available() +""" + + +def available() -> bool: + """Return whether the native Store binding is active. + + available() + """ + + return False + + +__all__ = ["available"] diff --git a/py/core/strings.py b/py/core/strings.py new file mode 100644 index 0000000..fee5a08 --- /dev/null +++ b/py/core/strings.py @@ -0,0 +1,139 @@ +"""String helpers with Core-shaped naming. + +from core import strings + +strings.contains("hello world", "world") +strings.trim(" corepy ") +""" + +from __future__ import annotations + + +def contains(value: str, substring: str) -> bool: + """Return True when the substring exists. + + strings.contains("hello world", "world") + """ + + return substring in value + + +def trim(value: str) -> str: + """Trim surrounding whitespace. + + strings.trim(" corepy ") + """ + + return value.strip() + + +def trim_prefix(value: str, prefix: str) -> str: + """Trim a leading prefix when present. + + strings.trim_prefix("--debug", "--") + """ + + return value[len(prefix):] if value.startswith(prefix) else value + + +def trim_suffix(value: str, suffix: str) -> str: + """Trim a trailing suffix when present. + + strings.trim_suffix("config.json", ".json") + """ + + return value[:-len(suffix)] if suffix and value.endswith(suffix) else value + + +def has_prefix(value: str, prefix: str) -> bool: + """Return True when the value starts with the prefix. + + strings.has_prefix("--debug", "--") + """ + + return value.startswith(prefix) + + +def has_suffix(value: str, suffix: str) -> bool: + """Return True when the value ends with the suffix. + + strings.has_suffix("config.json", ".json") + """ + + return value.endswith(suffix) + + +def split(value: str, separator: str) -> list[str]: + """Split a string by a separator. + + strings.split("deploy/to/homelab", "/") + """ + + return value.split(separator) + + +def split_n(value: str, separator: str, limit: int) -> list[str]: + """Split a string into at most `limit` parts. + + strings.split_n("key=value=extra", "=", 2) + """ + + if limit == 0: + return [] + if limit < 0: + return value.split(separator) + return value.split(separator, limit - 1) + + +def join(separator: str, *parts: str) -> str: + """Join parts with a separator. + + strings.join("/", "deploy", "to", "homelab") + """ + + return separator.join(parts) + + +def replace(value: str, old: str, new: str) -> str: + """Replace all occurrences of one substring with another. + + strings.replace("deploy/to/homelab", "/", ".") + """ + + return value.replace(old, new) + + +def lower(value: str) -> str: + """Return lowercase text. + + strings.lower("HELLO") + """ + + return value.lower() + + +def upper(value: str) -> str: + """Return uppercase text. + + strings.upper("hello") + """ + + return value.upper() + + +def rune_count(value: str) -> int: + """Return the Unicode code point count. + + strings.rune_count("🔥") + """ + + return len(value) + + +def concat(*parts: str) -> str: + """Concatenate string parts without a separator. + + strings.concat("deploy", "/", "to") + """ + + return "".join(parts) diff --git a/py/core/task.py b/py/core/task.py new file mode 100644 index 0000000..b3cf809 --- /dev/null +++ b/py/core/task.py @@ -0,0 +1,255 @@ +"""Task composition helpers built from named actions. + +from core import action, task + +actions = action.new_registry() +tasks = task.new_registry() +""" + +from __future__ import annotations + +import builtins +from dataclasses import dataclass, field +from threading import Thread +from typing import Any + +from . import action as action_module + + +@dataclass(slots=True) +class Step: + """Single task step referencing a named action. + + step = task.Step(action="echo") + """ + + action: str + with_values: dict[str, Any] = field(default_factory=dict) + async_step: bool = False + input: str = "" + + +@dataclass(slots=True) +class Task: + """Named sequence of steps. + + plan = task.Task(name="deploy", steps=[step]) + """ + + name: str = "" + description: str = "" + steps: list[Step] = field(default_factory=list) + + def run( + self, + actions: action_module.ActionRegistry, + values: dict[str, Any] | None = None, + context: Any = None, + ) -> Any: + """Run task steps in order. + + plan.run(actions, {"text": "hello"}) + """ + + if not self.steps: + raise RuntimeError(f"task has no steps: {self.name or ''}") + + runtime_values = {} if values is None else dict(values) + previous: Any = None + previous_ok = False + + for step in self.steps: + step_values = dict(step.with_values) + if not step_values: + step_values = dict(runtime_values) + if step.input == "previous" and previous_ok: + step_values["_input"] = previous + + current_action = actions.get(step.action) + if not current_action.exists(): + raise RuntimeError(f"action not found: {step.action}") + + if step.async_step: + Thread( + target=_run_async, + args=(current_action, step_values, context), + daemon=True, + ).start() + continue + + previous = current_action.run(step_values, context) + previous_ok = True + + return previous + + +class TaskRegistry: + """Named registry of tasks in insertion order. + + items = task.TaskRegistry() + """ + + def __init__(self) -> None: + self._tasks: dict[str, Task] = {} + self._order: list[str] = [] + + def register(self, name: str, steps: list[Step | dict[str, Any]], description: str = "") -> Task: + """Register or replace a named task. + + items.register("deploy", steps) + """ + + if name not in self._tasks: + self._order.append(name) + plan = Task(name=name, description=description, steps=[_step(step) for step in steps]) + self._tasks[name] = plan + return plan + + def get(self, name: str) -> Task: + """Return a named task or a placeholder. + + items.get("deploy") + """ + + return self._tasks.get(name, Task(name=name)) + + def names(self) -> list[str]: + """Return task names in insertion order. + + items.names() + """ + + return builtins.list(self._order) + + +def new( + name: str = "", + steps: list[Step | dict[str, Any]] | None = None, + description: str = "", +) -> Task: + """Create a Task handle. + + task.new("deploy", [{"action": "echo"}]) + """ + + return Task(name=name, description=description, steps=[] if steps is None else [_step(step) for step in steps]) + + +def new_step( + action: str, + with_values: dict[str, Any] | None = None, + async_step: bool = False, + input: str = "", +) -> Step: + """Create a Step value. + + task.new_step("echo", {"text": "hello"}) + """ + + return Step(action=action, with_values={} if with_values is None else dict(with_values), async_step=async_step, input=input) + + +def new_registry() -> TaskRegistry: + """Create a TaskRegistry handle. + + task.new_registry() + """ + + return TaskRegistry() + + +def register( + registry_value: TaskRegistry, + name: str, + steps: list[Step | dict[str, Any]], + description: str = "", +) -> Task: + """Register or replace a named task. + + task.register(items, "deploy", steps) + """ + + return registry_value.register(name, steps, description) + + +def get(registry_value: TaskRegistry, name: str) -> Task: + """Return a task or a placeholder. + + task.get(items, "deploy") + """ + + return registry_value.get(name) + + +def names(registry_value: TaskRegistry) -> list[str]: + """Return registered task names. + + task.names(items) + """ + + return registry_value.names() + + +def run( + task_value: Task | TaskRegistry, + actions: action_module.ActionRegistry, + *arguments: Any, + **kwargs: Any, +) -> Any: + """Run a task handle or a named task from a registry. + + task.run(plan, actions, {"text": "hello"}) + task.run(items, actions, "deploy", {"text": "hello"}) + """ + + context = kwargs.get("context") + if isinstance(task_value, TaskRegistry): + if not arguments: + raise TypeError("task.run expected a task name") + name = str(arguments[0]) + values = arguments[1] if builtins.len(arguments) > 1 else None + return task_value.get(name).run(actions, values, context) + values = arguments[0] if arguments else None + return task_value.run(actions, values, context) + + +def exists(task_value: Task) -> bool: + """Return True when the task has at least one step. + + task.exists(plan) + """ + + return builtins.len(task_value.steps) > 0 + + +def _step(value: Step | dict[str, Any]) -> Step: + if isinstance(value, Step): + return value + return Step( + action=str(value["action"]), + with_values=dict(value.get("with_values", value.get("with", {}))), + async_step=bool(value.get("async_step", value.get("async", False))), + input=str(value.get("input", "")), + ) + + +def _run_async(current_action: action_module.Action, step_values: dict[str, Any], context: Any) -> None: + try: + current_action.run(step_values, context) + except Exception: + return + + +__all__ = [ + "Step", + "Task", + "TaskRegistry", + "exists", + "get", + "names", + "new", + "new_registry", + "new_step", + "register", + "run", +] diff --git a/py/core/ws.py b/py/core/ws.py new file mode 100644 index 0000000..732f96b --- /dev/null +++ b/py/core/ws.py @@ -0,0 +1,16 @@ +"""core.ws — WebSocket primitives. + + available() +""" + + +def available() -> bool: + """Return whether the native WebSocket binding is active. + + available() + """ + + return False + + +__all__ = ["available"] diff --git a/py/pyproject.toml b/py/pyproject.toml index 71beef4..ba2c6ea 100644 --- a/py/pyproject.toml +++ b/py/pyproject.toml @@ -1,9 +1,9 @@ [project] name = "core" -version = "0.1.0" -description = "Python binding for Core primitives — gpython-embedded (Tier 1) or CPython-via-uv (Tier 2)" +version = "0.2.0" +description = "Python binding for Core primitives — bootstrap Tier 1 runtime plus CPython package surface" license = { text = "EUPL-1.2" } -requires-python = ">=3.13" +requires-python = ">=3.12" authors = [ { name = "Lethean CIC" } ] diff --git a/py/tests/test_core.py b/py/tests/test_core.py new file mode 100644 index 0000000..c4cd916 --- /dev/null +++ b/py/tests/test_core.py @@ -0,0 +1,385 @@ +from __future__ import annotations + +import importlib +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from core import action, array, cache, config, crypto, data, dns, echo, entitlement, err, fs, i18n, info, json, log, math as core_math, medium, options, path, process, registry, scm, service, strings, task + + +class CorePyTests(unittest.TestCase): + def test_echo_and_json_round_trip(self) -> None: + self.assertEqual(echo("hello"), "hello") + + with tempfile.TemporaryDirectory() as directory_name: + filename = Path(directory_name) / "config.json" + fs.write_file(filename, json.dumps({"name": "corepy"})) + payload = fs.read_file(filename) + self.assertEqual(json.loads(payload)["name"], "corepy") + + def test_options_and_config(self) -> None: + values = options.Options({"name": "corepy", "port": 8080}) + values.set("debug", True) + self.assertEqual(values.string("name"), "corepy") + self.assertEqual(values.int("port"), 8080) + self.assertTrue(values.bool("debug")) + + runtime_config = config.Config() + runtime_config.set("debug", True) + runtime_config.enable("tier1") + self.assertTrue(runtime_config.bool("debug")) + self.assertTrue(runtime_config.enabled("tier1")) + self.assertEqual(runtime_config.enabled_features(), ["tier1"]) + + def test_config_reads_environment_when_setting_missing(self) -> None: + runtime_config = config.Config() + + with patch.dict(os.environ, {"DATABASE_HOST": "db.internal", "PORT": "8080", "DEBUG": "true"}, clear=False): + self.assertEqual(runtime_config.get("database.host"), "db.internal") + self.assertEqual(runtime_config.string("database.host"), "db.internal") + self.assertEqual(runtime_config.int("port"), 8080) + self.assertTrue(runtime_config.bool("debug")) + + runtime_config.set("database.host", "override.internal") + runtime_config.set("port", 9000) + runtime_config.set("debug", False) + with patch.dict(os.environ, {"DATABASE_HOST": "db.internal", "PORT": "8080", "DEBUG": "true"}, clear=False): + self.assertEqual(runtime_config.string("database.host"), "override.internal") + self.assertEqual(runtime_config.int("port"), 9000) + self.assertFalse(runtime_config.bool("debug")) + + def test_module_level_surface_matches_tier1_shape(self) -> None: + values = options.new({"name": "corepy", "port": 8080}) + options.set(values, "debug", True) + self.assertEqual(options.string(values, "name"), "corepy") + self.assertEqual(options.int(values, "port"), 8080) + self.assertTrue(options.bool(values, "debug")) + self.assertEqual(options.items(values)["name"], "corepy") + + runtime_config = config.new() + config.set(runtime_config, "debug", True) + config.enable(runtime_config, "tier1") + self.assertTrue(config.bool(runtime_config, "debug")) + self.assertTrue(config.enabled(runtime_config, "tier1")) + self.assertEqual(config.enabled_features(runtime_config), ["tier1"]) + + assets = data.new() + with tempfile.TemporaryDirectory() as directory_name: + fixture_directory = Path(directory_name) / "fixtures" + fixture_directory.mkdir() + (fixture_directory / "note.txt").write_text("hello from data", encoding="utf-8") + data.mount(assets, "fixtures", fixture_directory) + self.assertEqual(data.read_string(assets, "fixtures/note.txt"), "hello from data") + self.assertEqual(data.list_names(assets, "fixtures"), ["note"]) + self.assertEqual(data.mounts(assets), ["fixtures"]) + + registry = service.new("corepy") + service.register(registry, "brain") + self.assertEqual(service.names(registry), ["cli", "brain"]) + + def test_data_and_service_registry(self) -> None: + assets = data.Data() + with tempfile.TemporaryDirectory() as directory_name: + fixture_directory = Path(directory_name) / "fixtures" + fixture_directory.mkdir() + (fixture_directory / "note.txt").write_text("hello from data", encoding="utf-8") + template_directory = fixture_directory / "templates" + template_directory.mkdir() + (template_directory / "greeting-{{.Name}}.txt.tmpl").write_text("hello {{.Name}}", encoding="utf-8") + assets.mount("fixtures", fixture_directory) + self.assertEqual(assets.read_string("fixtures/note.txt"), "hello from data") + self.assertEqual(assets.list_names("fixtures"), ["note", "templates"]) + workspace = Path(directory_name) / "workspace" + self.assertEqual(assets.extract("fixtures/templates", workspace, {"Name": "corepy"}), str(workspace.resolve())) + self.assertEqual((workspace / "greeting-corepy.txt").read_text(encoding="utf-8"), "hello corepy") + + registry = service.ServiceRegistry() + registry.register("brain", service.Service(name="brain")) + self.assertEqual(registry.names(), ["cli", "brain"]) + + def test_medium_process_log_and_errors(self) -> None: + buffer = medium.memory("hello") + self.assertEqual(buffer.read_text(), "hello") + buffer.write_text("updated") + self.assertEqual(buffer.read_text(), "updated") + medium.write_text(buffer, "via module") + self.assertEqual(medium.read_text(buffer), "via module") + + output = process.run(sys.executable, "-c", "print('ok')") + self.assertEqual(output.strip(), "ok") + result = process.run_result( + sys.executable, + "-c", + "import sys; print('out'); print('err', file=sys.stderr); sys.exit(3)", + ) + self.assertEqual(result["stdout"].strip(), "out") + self.assertEqual(result["stderr"].strip(), "err") + self.assertEqual(result["exit_code"], 3) + self.assertFalse(result["ok"]) + self.assertFalse(result["timed_out"]) + timeout = process.run_result(sys.executable, "-c", "import time; time.sleep(5)", timeout=0.1) + self.assertTrue(timeout["timed_out"]) + env_output = process.run_with_env(Path.cwd(), ["COREPY_MODE=test"], sys.executable, "-c", "import os; print(os.environ['COREPY_MODE'])") + self.assertEqual(env_output.strip(), "test") + self.assertTrue(process.exists()) + + issue = err.e("core.test", "boom", None, "BOOM") + wrapped = err.wrap(issue, "core.outer", "outer boom", "OUTER") + self.assertIsNotNone(wrapped) + assert wrapped is not None + self.assertEqual(err.operation(wrapped), "core.outer") + self.assertEqual(err.error_code(wrapped), "OUTER") + self.assertEqual(err.message(wrapped), "outer boom") + self.assertEqual(str(wrapped), "core.outer: outer boom [OUTER]: core.test: boom [BOOM]") + + log.set_level("debug") + log.info("corepy test", "module", "core") + log.set_level("quiet") + with self.assertRaises(ValueError): + log.set_level("verbose") + + def test_path_and_strings_helpers(self) -> None: + self.assertEqual(path.join("deploy", "to", "homelab"), "deploy/to/homelab") + self.assertEqual(path.base("/tmp/corepy/config.json"), "config.json") + self.assertEqual(path.dir("/tmp/corepy/config.json"), "/tmp/corepy") + self.assertEqual(path.ext("config.json"), ".json") + self.assertFalse(path.is_abs("deploy/to/homelab")) + self.assertEqual(path.clean("deploy//to/../from"), "deploy/from") + + self.assertTrue(strings.contains("hello world", "world")) + self.assertEqual(strings.trim(" corepy "), "corepy") + self.assertEqual(strings.trim_prefix("--debug", "--"), "debug") + self.assertEqual(strings.trim_suffix("config.json", ".json"), "config") + self.assertEqual(strings.split_n("key=value=extra", "=", 2), ["key", "value=extra"]) + self.assertEqual(strings.join("/", "deploy", "to", "homelab"), "deploy/to/homelab") + self.assertEqual(strings.concat("deploy", "/", "to"), "deploy/to") + + def test_math_surface(self) -> None: + self.assertEqual(core_math.sort([3, 1, 2]), [1, 2, 3]) + self.assertEqual(core_math.binary_search([1, 2, 3], 2), 1) + self.assertAlmostEqual(core_math.mean([1, 2, 3]), 2.0) + self.assertAlmostEqual(core_math.median([1, 2, 3]), 2.0) + self.assertAlmostEqual(core_math.variance([1, 2, 3]), 2.0 / 3.0) + self.assertAlmostEqual(core_math.stdev([1, 2, 3]), (2.0 / 3.0) ** 0.5) + self.assertTrue(core_math.epsilon_equal(0.1 + 0.2, 0.3, 1e-9)) + self.assertEqual(core_math.normalize([10, 20, 30]), [0.0, 0.5, 1.0]) + self.assertEqual(core_math.rescale([10, 20, 30], -1.0, 1.0), [-1.0, 0.0, 1.0]) + self.assertEqual(core_math.moving_average([1, 3, 6, 10], window=2), [1.0, 2.0, 4.5, 8.0]) + self.assertEqual(core_math.difference([1, 3, 6, 10]), [2.0, 3.0, 4.0]) + + tree = core_math.kdtree.build([[0.0, 0.0], [1.0, 1.0], [3.0, 3.0]], metric="euclidean") + neighbours = tree.nearest([0.8, 0.8], k=2) + self.assertEqual([item["index"] for item in neighbours], [1, 0]) + + cosine_neighbours = core_math.knn.search( + [[1.0, 0.0], [0.0, 1.0], [0.8, 0.2]], + [1.0, 0.0], + k=2, + metric="cosine", + ) + self.assertEqual([item["index"] for item in cosine_neighbours], [0, 2]) + + def test_math_submodules_are_importable(self) -> None: + kdtree_module = importlib.import_module("core.math.kdtree") + knn_module = importlib.import_module("core.math.knn") + signal_module = importlib.import_module("core.math.signal") + + tree = kdtree_module.build([[0.0, 0.0], [1.0, 1.0]], metric="euclidean") + self.assertEqual([item["index"] for item in tree.nearest([0.8, 0.8], k=2)], [1, 0]) + + neighbours = knn_module.search( + [[1.0, 0.0], [0.0, 1.0], [0.8, 0.2]], + [1.0, 0.0], + k=2, + metric="cosine", + ) + self.assertEqual([item["index"] for item in neighbours], [0, 2]) + self.assertEqual(signal_module.moving_average([1, 3, 6, 10], window=2), [1.0, 2.0, 4.5, 8.0]) + self.assertEqual(signal_module.difference([1, 3, 6, 10], lag=2), [5.0, 7.0]) + + def test_cache_crypto_and_dns_surface(self) -> None: + with tempfile.TemporaryDirectory() as directory_name: + store = cache.new(directory_name, 60) + cache.set(store, "greeting", {"name": "corepy", "debug": True}) + self.assertTrue(cache.has(store, "greeting")) + self.assertEqual(cache.get(store, "greeting")["name"], "corepy") + self.assertEqual(cache.get(store, "missing", "fallback"), "fallback") + cache.set_with_ttl(store, "nested/config", {"enabled": True}, 60) + self.assertEqual(cache.keys(store), ["greeting", "nested/config"]) + self.assertEqual(cache.keys(store, "nested"), ["nested/config"]) + self.assertEqual(cache.clear(store, "nested"), 1) + self.assertTrue(cache.delete(store, "greeting")) + self.assertFalse(cache.has(store, "greeting")) + + self.assertEqual(crypto.sha1("hello"), "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d") + self.assertEqual( + crypto.sha256("hello"), + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + ) + self.assertEqual( + crypto.hmac_sha256("secret", "hello"), + "88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b", + ) + self.assertTrue(crypto.compare_digest("corepy", "corepy")) + self.assertFalse(crypto.compare_digest("corepy", "core")) + encoded = crypto.base64_encode("hello") + self.assertEqual(encoded, "aGVsbG8=") + self.assertEqual(crypto.base64_decode(encoded), b"hello") + self.assertEqual(len(crypto.random_bytes(16)), 16) + + self.assertEqual(dns.lookup_port("tcp", "http"), 80) + self.assertTrue(any(address in {"127.0.0.1", "::1"} for address in dns.lookup_host("localhost"))) + self.assertTrue(dns.lookup_ip("localhost")) + self.assertTrue(dns.reverse_lookup("127.0.0.1")) + + def test_scm_surface(self) -> None: + if shutil.which("git") is None: + self.skipTest("git is not available") + + with tempfile.TemporaryDirectory() as directory_name: + repo = Path(directory_name) + _git(repo, "init") + _git(repo, "config", "user.email", "corepy@example.com") + _git(repo, "config", "user.name", "CorePy Tests") + (repo / "README.md").write_text("hello\n", encoding="utf-8") + _git(repo, "add", "README.md") + _git(repo, "commit", "-m", "initial") + + self.assertTrue(scm.exists(repo)) + self.assertEqual(scm.root(repo), str(repo.resolve())) + self.assertTrue(scm.branch(repo)) + self.assertEqual(len(scm.head(repo)), 40) + self.assertIn("README.md", scm.tracked_files(repo)) + + clean_status = scm.status(repo) + self.assertTrue(clean_status["clean"]) + self.assertEqual(clean_status["changes"], []) + + (repo / "README.md").write_text("updated\n", encoding="utf-8") + dirty_status = scm.status(repo) + self.assertFalse(dirty_status["clean"]) + self.assertTrue(dirty_status["changes"]) + + def test_array_registry_info_and_entitlement_surface(self) -> None: + values = array.new("a", "b") + array.add(values, "c") + array.add_unique(values, "c", "d") + self.assertTrue(array.contains(values, "d")) + array.remove(values, "b") + array.deduplicate(values) + self.assertEqual(array.as_list(values), ["a", "c", "d"]) + self.assertEqual(array.len(values), 3) + array.clear(values) + self.assertEqual(array.as_list(values), []) + + items = registry.new() + registry.set(items, "alpha", 1) + registry.set(items, "beta", 2) + self.assertTrue(registry.has(items, "alpha")) + self.assertEqual(registry.get(items, "alpha"), 1) + self.assertEqual(registry.get(items, "missing", "fallback"), "fallback") + self.assertEqual(registry.names(items), ["alpha", "beta"]) + registry.disable(items, "beta") + self.assertTrue(registry.disabled(items, "beta")) + self.assertEqual(registry.list(items, "*"), [1]) + registry.enable(items, "beta") + self.assertEqual(registry.list(items, "*"), [1, 2]) + registry.seal(items) + self.assertTrue(registry.sealed(items)) + with self.assertRaises(RuntimeError): + registry.set(items, "gamma", 3) + registry.open(items) + registry.set(items, "gamma", 3) + registry.lock(items) + self.assertTrue(registry.locked(items)) + with self.assertRaises(RuntimeError): + registry.delete(items, "alpha") + + snapshot = info.snapshot() + self.assertEqual(info.env("OS"), snapshot["OS"]) + self.assertIn("DIR_HOME", info.keys()) + self.assertTrue(info.env("DIR_TMP")) + + grant = entitlement.new(True, False, 5, 4, 1, "") + self.assertTrue(entitlement.near_limit(grant, 0.8)) + self.assertEqual(entitlement.usage_percent(grant), 80.0) + self.assertTrue(grant.near_limit(0.8)) + self.assertEqual(grant.usage_percent(), 80.0) + + def test_action_task_and_i18n_surface(self) -> None: + actions = action.new_registry() + action.register(actions, "produce", lambda _ctx, _values: "payload") + action.register(actions, "consume", lambda _ctx, values: f"got:{values['_input']}") + + self.assertEqual(action.names(actions), ["produce", "consume"]) + self.assertTrue(action.exists(action.get(actions, "produce"))) + self.assertEqual(action.run(actions, "produce"), "payload") + action.disable(actions, "produce") + with self.assertRaises(RuntimeError): + action.run(actions, "produce") + action.enable(actions, "produce") + + plan = task.new( + "pipeline", + [ + {"action": "produce"}, + {"action": "consume", "input": "previous"}, + ], + ) + self.assertTrue(task.exists(plan)) + self.assertEqual(task.run(plan, actions), "got:payload") + + tasks = task.new_registry() + task.register(tasks, "pipeline", [{"action": "produce"}]) + self.assertEqual(task.names(tasks), ["pipeline"]) + + messages = i18n.new() + self.assertEqual(i18n.translate(messages, "hello.world"), "hello.world") + self.assertEqual(i18n.language(messages), "en") + self.assertEqual(i18n.available_languages(messages), ["en"]) + i18n.add_locales(messages, "locales/core") + self.assertEqual(i18n.locales(messages), ["locales/core"]) + + class MockTranslator: + def __init__(self) -> None: + self._language = "en" + + def translate(self, message_id: str, *args: object) -> str: + return f"translated:{message_id}" + + def set_language(self, lang: str) -> None: + self._language = lang + + def language(self) -> str: + return self._language + + def available_languages(self) -> list[str]: + return ["en", "de", "fr"] + + translator = MockTranslator() + i18n.set_translator(messages, translator) + self.assertEqual(i18n.translate(messages, "hello.world"), "translated:hello.world") + i18n.set_language(messages, "de") + self.assertEqual(i18n.language(messages), "de") + self.assertEqual(i18n.available_languages(messages), ["en", "de", "fr"]) + + +if __name__ == "__main__": + unittest.main() + + +def _git(directory: Path, *arguments: str) -> None: + subprocess.run( + ["git", "-C", str(directory), *arguments], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) diff --git a/py/tests/test_medium_qdrant.py b/py/tests/test_medium_qdrant.py new file mode 100644 index 0000000..eddbb7e --- /dev/null +++ b/py/tests/test_medium_qdrant.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import json +from pathlib import Path +import sys +import unittest +from urllib import error as url_error +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from core import medium + + +class FakeResponse: + def __init__(self, payload: dict[str, object] | bytes, status: int = 200) -> None: + self.status = status + self._body = json.dumps(payload).encode("utf-8") if isinstance(payload, dict) else payload + self.closed = False + + def read(self) -> bytes: + return self._body + + def close(self) -> None: + self.closed = True + + +class FakeQdrantHTTP: + def __init__(self, *responses: FakeResponse | Exception) -> None: + self.responses = list(responses) + self.requests: list[object] = [] + self.timeouts: list[float] = [] + + def __call__(self, request: object, timeout: float) -> FakeResponse: + self.requests.append(request) + self.timeouts.append(timeout) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + +class MediumQdrantTests(unittest.TestCase): + def test_open_parses_qdrant_url(self) -> None: + handle = medium.open("qdrant://qdrant.local:6333/articles?point=42&field=body&timeout=2.5") + + self.assertIsInstance(handle, medium.QdrantMedium) + self.assertEqual(handle.base_url, "http://qdrant.local:6333") + self.assertEqual(handle.collection, "articles") + self.assertEqual(handle.point_id, 42) + self.assertEqual(handle.payload_field, "body") + self.assertEqual(handle.bytes_field, "data_base64") + self.assertEqual(handle.timeout, 2.5) + + def test_qdrant_read_text_retrieves_point_payload(self) -> None: + fake_http = FakeQdrantHTTP( + FakeResponse({"status": "ok", "result": [{"id": 1, "payload": {"text": "hello"}}]}) + ) + + with patch.object(medium.url_request, "urlopen", fake_http): + handle = medium.open("qdrant://qdrant.local:6333/articles") + self.assertEqual(medium.read_text(handle), "hello") + + request = fake_http.requests[0] + self.assertEqual(request.get_method(), "POST") + self.assertEqual(request.full_url, "http://qdrant.local:6333/collections/articles/points") + self.assertEqual( + json.loads(request.data.decode("utf-8")), + { + "ids": [1], + "with_payload": True, + "with_vector": False, + }, + ) + self.assertEqual(_headers(request)["content-type"], "application/json") + self.assertEqual(fake_http.timeouts, [10.0]) + + def test_qdrant_write_text_sets_point_payload(self) -> None: + fake_http = FakeQdrantHTTP( + FakeResponse({"status": "ok", "result": {"operation_id": 7, "status": "acknowledged"}}) + ) + + with patch.object(medium.url_request, "urlopen", fake_http): + handle = medium.open("qdrant://qdrant.local:6333/articles") + self.assertEqual(medium.write_text(handle, "updated"), "updated") + + request = fake_http.requests[0] + self.assertEqual(request.get_method(), "POST") + self.assertEqual(request.full_url, "http://qdrant.local:6333/collections/articles/points/payload") + self.assertEqual( + json.loads(request.data.decode("utf-8")), + { + "payload": {"text": "updated"}, + "points": [1], + }, + ) + + def test_qdrant_write_bytes_sets_base64_payload(self) -> None: + fake_http = FakeQdrantHTTP(FakeResponse({"status": "ok", "result": {"status": "acknowledged"}})) + + with patch.object(medium.url_request, "urlopen", fake_http): + handle = medium.open("qdrant://qdrant.local:6333/articles") + self.assertEqual(medium.write_bytes(handle, b"\xff\xfe"), b"\xff\xfe") + + request = fake_http.requests[0] + self.assertEqual(request.full_url, "http://qdrant.local:6333/collections/articles/points/payload") + self.assertEqual( + json.loads(request.data.decode("utf-8")), + { + "payload": {"data_base64": "//4=", "text": ""}, + "points": [1], + }, + ) + + def test_qdrant_open_rejects_missing_collection(self) -> None: + with self.assertRaisesRegex(ValueError, "qdrant://host:port/collection"): + medium.open("qdrant://qdrant.local:6333") + + def test_qdrant_http_errors_raise_qdrant_error(self) -> None: + fake_http = FakeQdrantHTTP(url_error.URLError("connection refused")) + + with patch.object(medium.url_request, "urlopen", fake_http): + handle = medium.open("qdrant://qdrant.local:6333/articles") + with self.assertRaisesRegex(medium.QdrantError, "connection refused"): + handle.read_text() + + def test_qdrant_missing_point_raises_qdrant_error(self) -> None: + fake_http = FakeQdrantHTTP(FakeResponse({"status": "ok", "result": []})) + + with patch.object(medium.url_request, "urlopen", fake_http): + handle = medium.open("qdrant://qdrant.local:6333/articles") + with self.assertRaisesRegex(medium.QdrantError, "was not found"): + handle.read_text() + + +def _headers(request: object) -> dict[str, str]: + return {key.lower(): value for key, value in request.header_items()} diff --git a/py/tests/test_rfc_stub_modules.py b/py/tests/test_rfc_stub_modules.py new file mode 100644 index 0000000..6af9465 --- /dev/null +++ b/py/tests/test_rfc_stub_modules.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import importlib +from pathlib import Path +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from core import agent, api, container, mcp, store, ws + + +class RFCStubModuleTests(unittest.TestCase): + def test_rfc_stub_modules_available_good(self) -> None: + modules = [agent, api, container, mcp, store, ws] + for module in modules: + imported = importlib.import_module(module.__name__) + self.assertIs(imported, module) + self.assertFalse(module.available()) + + +if __name__ == "__main__": + unittest.main() diff --git a/runtime/bootstrap/interpreter.go b/runtime/bootstrap/interpreter.go new file mode 100644 index 0000000..9d0f3e8 --- /dev/null +++ b/runtime/bootstrap/interpreter.go @@ -0,0 +1,808 @@ +// Package bootstrap hosts the Tier 1 CorePy bootstrap interpreter. +// +// This runtime implements the binding contract described in +// plans/code/core/py/RFC.md so CorePy can validate module registration, +// import shape, and round-trip execution before the gpython dependency lands. +// TODO(corepy-gpython): replace this subset interpreter with LetheanNetwork/gpython. +// +// interpreter := bootstrap.New() +// output, err := interpreter.Run(` +// from core import echo +// print(echo("hello")) +// `) +package bootstrap + +import ( + "bytes" + "fmt" // AX-6-exception: bootstrap parser uses fmt error formatting until gpython owns exception construction. + "reflect" + "slices" + "strconv" + "strings" // AX-6-exception: bootstrap parser needs tokenizer helpers beyond the current core string wrapper set. + + "dappco.re/go/py/runtime/internal/contract" +) + +type Module = contract.Module +type Function = contract.Function + +type functionReference struct { + moduleName string + functionName string +} + +type callableReference struct { + moduleName string + functionName string + boundArguments []any +} + +type KeywordArguments = contract.KeywordArguments +type BoundMethod = contract.BoundMethod +type AttributeResolver = contract.AttributeResolver +type ModuleReference = contract.ModuleReference +type UnsupportedImportError = contract.UnsupportedImportError + +// Interpreter executes a small Python subset against registered modules. +type Interpreter struct { + modules map[string]*Module + order []string +} + +// Session executes CorePy source against a persistent namespace. +type Session struct { + interpreter *Interpreter + namespace map[string]any +} + +// New creates an empty bootstrap interpreter with a root `core` module. +// +// interpreter := bootstrap.New() +func New() *Interpreter { + interpreter := &Interpreter{ + modules: map[string]*Module{}, + } + _ = interpreter.RegisterModule(Module{ + Name: "core", + Documentation: "Root CorePy module", + }) + return interpreter +} + +// Close releases interpreter resources. The bootstrap backend is in-memory only. +func (interpreter *Interpreter) Close() error { + return nil +} + +// RegisterModule registers or extends a module by name. +// +// interpreter.RegisterModule(runtime.Module{Name: "core", Functions: functions}) +func (interpreter *Interpreter) RegisterModule(module Module) error { + moduleName := strings.TrimSpace(module.Name) + if moduleName == "" { + return fmt.Errorf("runtime.RegisterModule: module name cannot be empty") + } + + names := moduleLineage(moduleName) + for _, name := range names { + if _, ok := interpreter.modules[name]; ok { + continue + } + interpreter.modules[name] = &Module{ + Name: name, + Functions: map[string]Function{}, + } + interpreter.order = append(interpreter.order, name) + } + + registered := interpreter.modules[moduleName] + if module.Documentation != "" { + registered.Documentation = module.Documentation + } + for functionName, function := range module.Functions { + if strings.TrimSpace(functionName) == "" { + return fmt.Errorf("runtime.RegisterModule(%s): function name cannot be empty", moduleName) + } + if function == nil { + return fmt.Errorf("runtime.RegisterModule(%s): function %s is nil", moduleName, functionName) + } + registered.Functions[functionName] = function + } + return nil +} + +// Modules returns registered module names in registration order. +// +// names := interpreter.Modules() +func (interpreter *Interpreter) Modules() []string { + return slices.Clone(interpreter.order) +} + +// NewSession creates a stateful execution session for REPL-style callers. +func (interpreter *Interpreter) NewSession() contract.Session { + return &Session{ + interpreter: interpreter, + namespace: map[string]any{}, + } +} + +// Run executes source while preserving namespace state between calls. +func (session *Session) Run(script string) (string, error) { + return session.interpreter.run(script, session.namespace) +} + +// Call invokes a registered function directly. +// +// value, err := interpreter.Call("core.fs", "read_file", "/tmp/demo.txt") +func (interpreter *Interpreter) Call(moduleName, functionName string, arguments ...any) (any, error) { + module, ok := interpreter.modules[moduleName] + if !ok { + return nil, fmt.Errorf("runtime.Call: module %q is not registered", moduleName) + } + function, ok := module.Functions[functionName] + if !ok { + return nil, fmt.Errorf("runtime.Call: function %q is not registered in %q", functionName, moduleName) + } + return function(arguments...) +} + +// Run executes a small Python subset used by the bootstrap integration tests. +// +// Supported statements: +// - `import core` +// - `import core.fs as filesystem` +// - `from core import echo, fs` +// - `name = expression` +// - `print(expression)` +// +// output, err := interpreter.Run(` +// from core import echo +// print(echo("hello")) +// `) +func (interpreter *Interpreter) Run(script string) (string, error) { + return interpreter.run(script, map[string]any{}) +} + +func (interpreter *Interpreter) run(script string, namespace map[string]any) (string, error) { + output := &bytes.Buffer{} + for lineNumber, rawLine := range strings.Split(script, "\n") { + statements, err := splitTopLevel(rawLine, ';') + if err != nil { + return "", fmt.Errorf("runtime.Run line %d: %w", lineNumber+1, err) + } + for _, rawStatement := range statements { + line := strings.TrimSpace(rawStatement) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + switch { + case strings.HasPrefix(line, "import "): + if err := interpreter.executeDirectImport(line, namespace); err != nil { + return "", fmt.Errorf("runtime.Run line %d: %w", lineNumber+1, err) + } + case strings.HasPrefix(line, "from "): + if err := interpreter.executeImport(line, namespace); err != nil { + return "", fmt.Errorf("runtime.Run line %d: %w", lineNumber+1, err) + } + case strings.HasPrefix(line, "print(") && strings.HasSuffix(line, ")"): + expression := strings.TrimSuffix(strings.TrimPrefix(line, "print("), ")") + value, err := interpreter.evaluateExpression(expression, namespace) + if err != nil { + return "", fmt.Errorf("runtime.Run line %d: %w", lineNumber+1, err) + } + if _, err := fmt.Fprintln(output, formatValue(value)); err != nil { + return "", fmt.Errorf("runtime.Run line %d: write output: %w", lineNumber+1, err) + } + default: + index := topLevelIndex(line, '=') + if index == -1 { + if _, err := interpreter.evaluateExpression(line, namespace); err != nil { + return "", fmt.Errorf("runtime.Run line %d: %w", lineNumber+1, err) + } + continue + } + + name := strings.TrimSpace(line[:index]) + if name == "" { + return "", fmt.Errorf("runtime.Run line %d: assignment target cannot be empty", lineNumber+1) + } + expression := strings.TrimSpace(line[index+1:]) + value, err := interpreter.evaluateExpression(expression, namespace) + if err != nil { + return "", fmt.Errorf("runtime.Run line %d: %w", lineNumber+1, err) + } + namespace[name] = value + } + } + } + + return output.String(), nil +} + +func (interpreter *Interpreter) executeDirectImport(line string, namespace map[string]any) error { + body := strings.TrimSpace(strings.TrimPrefix(line, "import ")) + if body == "" { + return fmt.Errorf("import module cannot be empty") + } + + for _, rawTarget := range strings.Split(body, ",") { + moduleName, bindingName, hasAlias, err := parseImportBinding(rawTarget) + if err != nil { + return err + } + if _, ok := interpreter.modules[moduleName]; !ok { + return UnsupportedImportError{Module: moduleName} + } + + if hasAlias { + namespace[bindingName] = ModuleReference{Name: moduleName} + continue + } + + rootName := strings.Split(moduleName, ".")[0] + namespace[rootName] = ModuleReference{Name: rootName} + } + return nil +} + +func (interpreter *Interpreter) executeImport(line string, namespace map[string]any) error { + body := strings.TrimSpace(strings.TrimPrefix(line, "from ")) + parts := strings.SplitN(body, " import ", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid import syntax: %q", line) + } + + moduleName := strings.TrimSpace(parts[0]) + if moduleName == "" { + return fmt.Errorf("import module cannot be empty") + } + if _, ok := interpreter.modules[moduleName]; !ok { + return UnsupportedImportError{Module: moduleName} + } + + for _, rawName := range strings.Split(parts[1], ",") { + name, bindingName, _, err := parseImportBinding(rawName) + if err != nil { + return err + } + exported, err := interpreter.resolveImport(moduleName, name) + if err != nil { + return err + } + namespace[bindingName] = exported + } + return nil +} + +func (interpreter *Interpreter) resolveImport(moduleName, name string) (any, error) { + module := interpreter.modules[moduleName] + if _, ok := module.Functions[name]; ok { + return functionReference{moduleName: moduleName, functionName: name}, nil + } + + childName := moduleName + "." + name + if _, ok := interpreter.modules[childName]; ok { + return ModuleReference{Name: childName}, nil + } + + if moduleName != "core" && !strings.HasPrefix(moduleName, "core.") { + return nil, UnsupportedImportError{Module: childName} + } + return nil, fmt.Errorf("module %q does not export %q", moduleName, name) +} + +func (interpreter *Interpreter) evaluateExpression(expression string, namespace map[string]any) (any, error) { + expression = strings.TrimSpace(expression) + if expression == "" { + return nil, fmt.Errorf("expression cannot be empty") + } + + if isQuoted(expression) { + value, err := unquoteStringLiteral(expression) + if err != nil { + return nil, fmt.Errorf("invalid string literal %q: %w", expression, err) + } + return value, nil + } + + if expression == "True" { + return true, nil + } + if expression == "False" { + return false, nil + } + if expression == "None" { + return nil, nil + } + if integerValue, err := strconv.Atoi(expression); err == nil { + return integerValue, nil + } + if floatValue, err := strconv.ParseFloat(expression, 64); err == nil && strings.ContainsAny(expression, ".eE") { + return floatValue, nil + } + if strings.HasPrefix(expression, "[") && strings.HasSuffix(expression, "]") { + return interpreter.evaluateListLiteral(expression[1:len(expression)-1], namespace) + } + if strings.HasPrefix(expression, "{") && strings.HasSuffix(expression, "}") { + return interpreter.evaluateDictLiteral(expression[1:len(expression)-1], namespace) + } + + if openIndex := topLevelIndex(expression, '('); openIndex != -1 && strings.HasSuffix(expression, ")") { + callableExpression := strings.TrimSpace(expression[:openIndex]) + argumentBody := strings.TrimSpace(expression[openIndex+1 : len(expression)-1]) + arguments, err := interpreter.evaluateArguments(argumentBody, namespace) + if err != nil { + return nil, err + } + callable, err := interpreter.resolveCallable(callableExpression, namespace) + if err != nil { + return nil, err + } + callArguments := append(append([]any(nil), callable.boundArguments...), arguments...) + return interpreter.Call(callable.moduleName, callable.functionName, callArguments...) + } + + if value, ok := namespace[expression]; ok { + return value, nil + } + if strings.Contains(expression, ".") { + return interpreter.resolveValue(expression, namespace) + } + return nil, fmt.Errorf("unknown identifier %q", expression) +} + +func (interpreter *Interpreter) evaluateListLiteral(body string, namespace map[string]any) (any, error) { + parts, err := splitTopLevel(body, ',') + if err != nil { + return nil, err + } + if len(parts) == 0 { + return []any{}, nil + } + + values := make([]any, 0, len(parts)) + for _, part := range parts { + if strings.TrimSpace(part) == "" { + continue + } + value, err := interpreter.evaluateExpression(part, namespace) + if err != nil { + return nil, err + } + values = append(values, value) + } + return values, nil +} + +func (interpreter *Interpreter) evaluateDictLiteral(body string, namespace map[string]any) (any, error) { + parts, err := splitTopLevel(body, ',') + if err != nil { + return nil, err + } + if len(parts) == 0 { + return map[string]any{}, nil + } + + values := map[string]any{} + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + separatorIndex := topLevelIndex(part, ':') + if separatorIndex == -1 { + return nil, fmt.Errorf("invalid dict item %q", part) + } + + keyValue, err := interpreter.evaluateExpression(part[:separatorIndex], namespace) + if err != nil { + return nil, err + } + key, ok := keyValue.(string) + if !ok { + return nil, fmt.Errorf("dict key %q must evaluate to string, got %T", part[:separatorIndex], keyValue) + } + + value, err := interpreter.evaluateExpression(part[separatorIndex+1:], namespace) + if err != nil { + return nil, err + } + values[key] = value + } + return values, nil +} + +func (interpreter *Interpreter) evaluateArguments(argumentBody string, namespace map[string]any) ([]any, error) { + if strings.TrimSpace(argumentBody) == "" { + return nil, nil + } + + parts, err := splitArguments(argumentBody) + if err != nil { + return nil, err + } + values := make([]any, 0, len(parts)) + keywordArguments := KeywordArguments{} + seenKeywordArguments := false + for _, part := range parts { + if name, valueExpression, ok := splitKeywordArgument(part); ok { + if _, exists := keywordArguments[name]; exists { + return nil, fmt.Errorf("duplicate keyword argument %q", name) + } + value, err := interpreter.evaluateExpression(valueExpression, namespace) + if err != nil { + return nil, err + } + keywordArguments[name] = value + seenKeywordArguments = true + continue + } + if seenKeywordArguments { + return nil, fmt.Errorf("positional argument cannot follow keyword arguments") + } + value, err := interpreter.evaluateExpression(part, namespace) + if err != nil { + return nil, err + } + values = append(values, value) + } + if len(keywordArguments) > 0 { + values = append(values, keywordArguments) + } + return values, nil +} + +func (interpreter *Interpreter) resolveCallable(expression string, namespace map[string]any) (callableReference, error) { + value, err := interpreter.resolveValue(expression, namespace) + if err != nil { + return callableReference{}, err + } + + switch typed := value.(type) { + case functionReference: + return callableReference{ + moduleName: typed.moduleName, + functionName: typed.functionName, + }, nil + case BoundMethod: + return callableReference{ + moduleName: typed.ModuleName, + functionName: typed.FunctionName, + boundArguments: append([]any(nil), typed.Arguments...), + }, nil + default: + return callableReference{}, fmt.Errorf("%q is not callable", expression) + } +} + +func (interpreter *Interpreter) resolveValue(expression string, namespace map[string]any) (any, error) { + parts := strings.Split(strings.TrimSpace(expression), ".") + if len(parts) == 0 || parts[0] == "" { + return nil, fmt.Errorf("unknown identifier %q", expression) + } + + value, ok := namespace[parts[0]] + if !ok { + return nil, fmt.Errorf("unknown identifier %q", expression) + } + if len(parts) == 1 { + return value, nil + } + + currentPath := parts[0] + for _, segment := range parts[1:] { + switch typed := value.(type) { + case ModuleReference: + nextValue, err := interpreter.resolveImport(typed.Name, segment) + if err != nil { + return nil, err + } + value = nextValue + case AttributeResolver: + nextValue, ok := typed.ResolveAttribute(segment) + if !ok { + return nil, fmt.Errorf("%q does not export %q", currentPath, segment) + } + value = nextValue + default: + return nil, fmt.Errorf("%q does not export %q", currentPath, segment) + } + currentPath += "." + segment + } + return value, nil +} + +func moduleLineage(moduleName string) []string { + parts := strings.Split(moduleName, ".") + var names []string + for index := range parts { + names = append(names, strings.Join(parts[:index+1], ".")) + } + return names +} + +func parseImportBinding(raw string) (moduleName string, bindingName string, hasAlias bool, err error) { + fields := strings.Fields(strings.TrimSpace(raw)) + switch len(fields) { + case 0: + return "", "", false, fmt.Errorf("import name cannot be empty") + case 1: + return fields[0], fields[0], false, nil + case 3: + if fields[1] != "as" { + return "", "", false, fmt.Errorf("invalid import syntax: %q", raw) + } + if fields[0] == "" || fields[2] == "" { + return "", "", false, fmt.Errorf("invalid import syntax: %q", raw) + } + return fields[0], fields[2], true, nil + default: + return "", "", false, fmt.Errorf("invalid import syntax: %q", raw) + } +} + +func splitArguments(argumentBody string) ([]string, error) { + return splitTopLevel(argumentBody, ',') +} + +// SplitKeywordArguments separates positional arguments from a trailing +// KeywordArguments payload. +// +// positional, keywordArguments := runtime.SplitKeywordArguments(arguments) +func splitTopLevel(value string, separator rune) ([]string, error) { + var ( + parts []string + builder strings.Builder + stack []rune + quote rune + escaped bool + ) + + for _, character := range value { + switch { + case quote != 0: + builder.WriteRune(character) + if escaped { + escaped = false + continue + } + if character == '\\' { + escaped = true + continue + } + if character == quote { + quote = 0 + } + case character == '"' || character == '\'': + quote = character + builder.WriteRune(character) + case isOpenGrouping(character): + stack = append(stack, character) + builder.WriteRune(character) + case isCloseGrouping(character): + if len(stack) == 0 || stack[len(stack)-1] != matchingOpenGrouping(character) { + return nil, fmt.Errorf("unbalanced grouping in %q", value) + } + stack = stack[:len(stack)-1] + builder.WriteRune(character) + case character == separator && len(stack) == 0: + parts = append(parts, strings.TrimSpace(builder.String())) + builder.Reset() + default: + builder.WriteRune(character) + } + } + + if quote != 0 { + return nil, fmt.Errorf("unterminated string literal in %q", value) + } + if len(stack) != 0 { + return nil, fmt.Errorf("unbalanced grouping in %q", value) + } + + last := strings.TrimSpace(builder.String()) + if last != "" || strings.TrimSpace(value) == "" { + parts = append(parts, last) + } + return parts, nil +} + +func topLevelIndex(value string, target rune) int { + quote := rune(0) + escaped := false + var stack []rune + + for index, character := range value { + switch { + case quote != 0: + if escaped { + escaped = false + continue + } + if character == '\\' { + escaped = true + continue + } + if character == quote { + quote = 0 + } + case character == '"' || character == '\'': + quote = character + case character == target && len(stack) == 0: + return index + case isOpenGrouping(character): + stack = append(stack, character) + case isCloseGrouping(character): + if len(stack) > 0 && stack[len(stack)-1] == matchingOpenGrouping(character) { + stack = stack[:len(stack)-1] + } + } + } + + return -1 +} + +func splitKeywordArgument(part string) (name string, value string, ok bool) { + index := topLevelIndex(part, '=') + if index == -1 { + return "", "", false + } + + name = strings.TrimSpace(part[:index]) + if !isIdentifier(name) { + return "", "", false + } + return name, strings.TrimSpace(part[index+1:]), true +} + +func isQuoted(value string) bool { + return len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) +} + +func unquoteStringLiteral(value string) (string, error) { + if len(value) >= 2 && value[0] == '"' { + return strconv.Unquote(value) + } + if len(value) < 2 || value[0] != '\'' || value[len(value)-1] != '\'' { + return "", fmt.Errorf("expected quoted string") + } + + var builder strings.Builder + escaped := false + for _, character := range value[1 : len(value)-1] { + if escaped { + switch character { + case '\\', '\'': + builder.WriteRune(character) + case 'n': + builder.WriteByte('\n') + case 'r': + builder.WriteByte('\r') + case 't': + builder.WriteByte('\t') + default: + builder.WriteRune('\\') + builder.WriteRune(character) + } + escaped = false + continue + } + if character == '\\' { + escaped = true + continue + } + builder.WriteRune(character) + } + if escaped { + return "", fmt.Errorf("unterminated escape sequence") + } + return builder.String(), nil +} + +func isIdentifier(value string) bool { + if value == "" { + return false + } + + for index, character := range value { + if index == 0 { + if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && character != '_' { + return false + } + continue + } + if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && (character < '0' || character > '9') && character != '_' { + return false + } + } + return true +} + +func isOpenGrouping(character rune) bool { + return character == '(' || character == '[' || character == '{' +} + +func isCloseGrouping(character rune) bool { + return character == ')' || character == ']' || character == '}' +} + +func matchingOpenGrouping(character rune) rune { + switch character { + case ')': + return '(' + case ']': + return '[' + case '}': + return '{' + default: + return 0 + } +} + +func formatValue(value any) string { + switch typed := value.(type) { + case nil: + return "None" + case bool: + if typed { + return "True" + } + return "False" + case string: + return typed + default: + return formatCompositeValue(typed, false) + } +} + +func formatCompositeValue(value any, nested bool) string { + switch typed := value.(type) { + case nil: + return "None" + case bool: + if typed { + return "True" + } + return "False" + case string: + if nested { + return strconv.Quote(typed) + } + return typed + } + + reflected := reflect.ValueOf(value) + if !reflected.IsValid() { + return "None" + } + + switch reflected.Kind() { + case reflect.Slice, reflect.Array: + parts := make([]string, 0, reflected.Len()) + for index := 0; index < reflected.Len(); index++ { + parts = append(parts, formatCompositeValue(reflected.Index(index).Interface(), true)) + } + return "[" + strings.Join(parts, ", ") + "]" + case reflect.Map: + if reflected.Type().Key().Kind() != reflect.String { + return fmt.Sprint(value) + } + + keys := make([]string, 0, reflected.Len()) + for _, keyValue := range reflected.MapKeys() { + keys = append(keys, keyValue.String()) + } + slices.Sort(keys) + + parts := make([]string, 0, len(keys)) + for _, key := range keys { + part := strconv.Quote(key) + ": " + formatCompositeValue(reflected.MapIndex(reflect.ValueOf(key)).Interface(), true) + parts = append(parts, part) + } + return "{" + strings.Join(parts, ", ") + "}" + default: + return fmt.Sprint(value) + } +} diff --git a/runtime/examples_test.go b/runtime/examples_test.go new file mode 100644 index 0000000..3af5056 --- /dev/null +++ b/runtime/examples_test.go @@ -0,0 +1,49 @@ +package runtime_test + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestExamples_Echo_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + script, err := os.ReadFile(filepath.Join("..", "examples", "echo.py")) + if err != nil { + t.Fatalf("read echo example: %v", err) + } + + output, err := interpreter.Run(string(script)) + if err != nil { + t.Fatalf("run echo example: %v", err) + } + if strings.TrimSpace(output) != "hello" { + t.Fatalf("unexpected echo output %q", output) + } +} + +func TestExamples_PrimitivePipeline_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + script, err := os.ReadFile(filepath.Join("..", "examples", "primitive_pipeline.py")) + if err != nil { + t.Fatalf("read primitive pipeline example: %v", err) + } + + output, err := interpreter.Run(string(script)) + if err != nil { + t.Fatalf("run primitive pipeline example: %v", err) + } + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) != 2 { + t.Fatalf("expected two output lines, got %#v", lines) + } + if len(lines[0]) != 64 { + t.Fatalf("expected sha256 digest, got %q", lines[0]) + } + if lines[1] != "True" { + t.Fatalf("expected cache hit output, got %q", lines[1]) + } +} diff --git a/runtime/gpython/interpreter.go b/runtime/gpython/interpreter.go new file mode 100644 index 0000000..5a37514 --- /dev/null +++ b/runtime/gpython/interpreter.go @@ -0,0 +1,71 @@ +//go:build gpython + +// Package gpython owns the build-tagged Tier 1 backend boundary. +package gpython + +import ( + "dappco.re/go/py/runtime/bootstrap" + "dappco.re/go/py/runtime/internal/contract" +) + +// Interpreter is the gpython backend shell. +// +// TODO(corepy-gpython): replace the bootstrap delegate with +// LetheanNetwork/gpython and the py.RegisterModule adapter once the fork is +// vendored. Keeping this package behind the gpython build tag makes that swap a +// package-local change instead of a public runtime contract change. +type Interpreter struct { + delegate *bootstrap.Interpreter +} + +// Session preserves namespace state for the current build-tagged shell. +type Session struct { + delegate contract.Session +} + +// New creates the build-tagged gpython backend shell. +func New(modules []contract.Module) (*Interpreter, error) { + delegate := bootstrap.New() + for _, module := range modules { + if err := delegate.RegisterModule(module); err != nil { + _ = delegate.Close() + return nil, err + } + } + return &Interpreter{delegate: delegate}, nil +} + +// Run executes source through the current gpython shell. +func (interpreter *Interpreter) Run(source string) (string, error) { + return interpreter.delegate.Run(source) +} + +// RegisterModule registers a CorePy module. +func (interpreter *Interpreter) RegisterModule(module contract.Module) error { + return interpreter.delegate.RegisterModule(module) +} + +// NewSession creates a stateful execution session. +func (interpreter *Interpreter) NewSession() contract.Session { + return &Session{delegate: interpreter.delegate.NewSession()} +} + +// Run executes source in the session namespace. +func (session *Session) Run(source string) (string, error) { + return session.delegate.Run(source) +} + +// Close releases interpreter resources. +func (interpreter *Interpreter) Close() error { + return interpreter.delegate.Close() +} + +// Modules returns registered module names. +func (interpreter *Interpreter) Modules() []string { + return interpreter.delegate.Modules() +} + +// Call invokes a registered binding directly. +func (interpreter *Interpreter) Call(moduleName, functionName string, arguments ...any) (any, error) { + return interpreter.delegate.Call(moduleName, functionName, arguments...) +} diff --git a/runtime/gpython_disabled.go b/runtime/gpython_disabled.go new file mode 100644 index 0000000..8e5a36a --- /dev/null +++ b/runtime/gpython_disabled.go @@ -0,0 +1,7 @@ +//go:build !gpython + +package runtime + +func newGPython(modules []Module) (Interpreter, error) { + return nil, BackendNotBuiltError{Backend: BackendGPython, BuildTag: BackendGPython} +} diff --git a/runtime/gpython_enabled.go b/runtime/gpython_enabled.go new file mode 100644 index 0000000..34957c7 --- /dev/null +++ b/runtime/gpython_enabled.go @@ -0,0 +1,9 @@ +//go:build gpython + +package runtime + +import "dappco.re/go/py/runtime/gpython" + +func newGPython(modules []Module) (Interpreter, error) { + return gpython.New(modules) +} diff --git a/runtime/gpython_smoke_test.go b/runtime/gpython_smoke_test.go new file mode 100644 index 0000000..337d6c1 --- /dev/null +++ b/runtime/gpython_smoke_test.go @@ -0,0 +1,61 @@ +//go:build gpython + +package runtime_test + +import ( + "os/exec" + "reflect" + goruntime "runtime" + "strings" + "testing" + + "dappco.re/go/py/bindings/register" + corepyruntime "dappco.re/go/py/runtime" +) + +func TestGPythonBackend_StdlibShadowRoundTrip_Good(t *testing.T) { + interpreter, err := corepyruntime.New(corepyruntime.Options{Backend: corepyruntime.BackendGPython}) + if err != nil { + t.Fatalf("create gpython backend: %v", err) + } + defer interpreter.Close() + + if err := register.DefaultModules(interpreter); err != nil { + t.Fatalf("register gpython backend modules: %v", err) + } + + goBinary, err := exec.LookPath("go") + if err != nil { + t.Fatalf("find go binary: %v", err) + } + + output, err := interpreter.Run(` +import base64 +import hashlib +import json +import os +import subprocess +payload = json.loads('{"name":"corepy"}') +print(json.dumps(payload)) +print(os.path.basename(os.path.join("tmp", "corepy.json"))) +print(base64.b64encode("hello")) +digest = hashlib.sha256("hello") +print(digest.hexdigest()) +print(subprocess.check_output(["` + goBinary + `", "env", "GOOS"])) +`) + if err != nil { + t.Fatalf("run gpython stdlib shadow script: %v", err) + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + expected := []string{ + `{"name":"corepy"}`, + "corepy.json", + "aGVsbG8=", + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + goruntime.GOOS, + } + if !reflect.DeepEqual(lines, expected) { + t.Fatalf("unexpected gpython stdlib shadow output %#v", lines) + } +} diff --git a/runtime/internal/contract/types.go b/runtime/internal/contract/types.go new file mode 100644 index 0000000..0a75e30 --- /dev/null +++ b/runtime/internal/contract/types.go @@ -0,0 +1,99 @@ +// Package contract carries the CorePy runtime types shared by backend +// implementations and the public runtime facade. +package contract + +// Function is a Python-callable binding exposed by a module. +// +// module := runtime.Module{ +// Name: "core", +// Functions: map[string]runtime.Function{ +// "echo": func(arguments ...any) (any, error) { return arguments[0], nil }, +// }, +// } +type Function func(arguments ...any) (any, error) + +// Module defines a registered CorePy module. +// +// runtime.Module{ +// Name: "core.fs", +// Documentation: "Filesystem primitives", +// Functions: map[string]runtime.Function{"read_file": readFile}, +// } +type Module struct { + Name string + Documentation string + Functions map[string]Function +} + +// Interpreter is the backend-neutral CorePy execution contract. +type Interpreter interface { + Run(source string) (string, error) + RegisterModule(m Module) error + Close() error +} + +// Session executes source while preserving Python namespace state between runs. +type Session interface { + Run(source string) (string, error) +} + +// SessionCreator is implemented by backends that support stateful interactive +// execution. +type SessionCreator interface { + NewSession() Session +} + +// ModuleLister is implemented by backends that can report their registered +// module names. +type ModuleLister interface { + Modules() []string +} + +// DirectCaller is implemented by backends that support direct Go invocation of +// registered CorePy functions. +type DirectCaller interface { + Call(moduleName, functionName string, arguments ...any) (any, error) +} + +// KeywordArguments carries Python-style `name=value` arguments for bindings that +// opt into keyword handling. +// +// bindings := runtime.KeywordArguments{"metric": "cosine", "k": 2} +type KeywordArguments map[string]any + +// BoundMethod describes a method resolved from an object handle. +// +// method := runtime.BoundMethod{ModuleName: "core.math.kdtree", FunctionName: "nearest", Arguments: []any{tree}} +type BoundMethod struct { + ModuleName string + FunctionName string + Arguments []any +} + +// AttributeResolver exposes Python-style attributes from a Go-backed handle. +// +// attribute, ok := tree.ResolveAttribute("nearest") +type AttributeResolver interface { + ResolveAttribute(name string) (any, bool) +} + +// ModuleReference is an imported module handle inside the bootstrap runtime. +// +// from core import fs +// print(fs.read_file("/tmp/demo.txt")) +type ModuleReference struct { + Name string +} + +// UnsupportedImportError reports an import that the selected Tier 1 backend +// cannot satisfy from its registered module table. +type UnsupportedImportError struct { + Module string +} + +func (err UnsupportedImportError) Error() string { + if err.Module == "" { + return "unsupported import" + } + return "unsupported import " + err.Module +} diff --git a/runtime/interpreter.go b/runtime/interpreter.go index 569f68d..faaae61 100644 --- a/runtime/interpreter.go +++ b/runtime/interpreter.go @@ -1,4 +1,133 @@ -// Package runtime hosts the gpython interpreter for Tier 1 CorePy. +// Package runtime hosts backend-neutral CorePy interpreters. // -// See plans/code/core/py/RFC.md §7 for the gpython integration contract. +// The default backend remains the bootstrap Tier 1 interpreter until the real +// LetheanNetwork/gpython integration lands behind the `gpython` build tag. +// +// interp, err := runtime.New(runtime.Options{Backend: "bootstrap"}) +// if err != nil { +// return err +// } +// defer interp.Close() +// out, err := interp.Run(`from core import echo; print(echo("hi"))`) package runtime + +import ( + "errors" + "fmt" + "strings" + + "dappco.re/go/py/runtime/bootstrap" + "dappco.re/go/py/runtime/internal/contract" +) + +const ( + // BackendBootstrap selects the bootstrap interpreter. + BackendBootstrap = "bootstrap" + // BackendGPython selects the planned real gpython interpreter. + BackendGPython = "gpython" +) + +// Function is a Python-callable binding exposed by a module. +type Function = contract.Function + +// Module defines a registered CorePy module. +type Module = contract.Module + +// Interpreter is the backend-neutral CorePy execution contract. +type Interpreter = contract.Interpreter + +// Session executes source while preserving Python namespace state between runs. +type Session = contract.Session + +// SessionCreator is implemented by backends that support stateful interactive +// execution. +type SessionCreator = contract.SessionCreator + +// ModuleLister is implemented by backends that can report registered modules. +type ModuleLister = contract.ModuleLister + +// DirectCaller is implemented by backends that support direct binding calls. +type DirectCaller = contract.DirectCaller + +// KeywordArguments carries Python-style `name=value` arguments for bindings that +// opt into keyword handling. +type KeywordArguments = contract.KeywordArguments + +// BoundMethod describes a method resolved from an object handle. +type BoundMethod = contract.BoundMethod + +// AttributeResolver exposes Python-style attributes from a Go-backed handle. +type AttributeResolver = contract.AttributeResolver + +// ModuleReference is an imported module handle inside the bootstrap runtime. +type ModuleReference = contract.ModuleReference + +// UnsupportedImportError reports a Tier 1 import that can be retried in Tier 2. +type UnsupportedImportError = contract.UnsupportedImportError + +// Options selects a CorePy runtime backend and optional pre-registered modules. +type Options struct { + // Backend is "bootstrap" by default. "gpython" is reserved for the real + // LetheanNetwork/gpython backend and currently reports BackendNotBuiltError. + Backend string + Modules []Module +} + +// BackendNotBuiltError reports a selected backend that is known but absent from +// the current build. +type BackendNotBuiltError struct { + Backend string + BuildTag string +} + +func (err BackendNotBuiltError) Error() string { + return fmt.Sprintf("runtime.New: backend %q not built; recompile with -tags %s", err.Backend, err.BuildTag) +} + +// New creates an interpreter for the requested backend. +func New(opts Options) (Interpreter, error) { + backend := strings.TrimSpace(opts.Backend) + if backend == "" { + backend = BackendBootstrap + } + + switch backend { + case BackendBootstrap: + interpreter := bootstrap.New() + for _, module := range opts.Modules { + if err := interpreter.RegisterModule(module); err != nil { + _ = interpreter.Close() + return nil, err + } + } + return interpreter, nil + case BackendGPython: + return newGPython(opts.Modules) + default: + return nil, fmt.Errorf("runtime.New: unknown backend %q", backend) + } +} + +// SplitKeywordArguments separates positional arguments from a trailing +// KeywordArguments payload. +// +// positional, keywordArguments := runtime.SplitKeywordArguments(arguments) +func SplitKeywordArguments(arguments []any) ([]any, KeywordArguments) { + if len(arguments) == 0 { + return nil, nil + } + + keywordArguments, ok := arguments[len(arguments)-1].(KeywordArguments) + if !ok { + return append([]any(nil), arguments...), nil + } + return append([]any(nil), arguments[:len(arguments)-1]...), keywordArguments +} + +// IsTier2FallbackCandidate reports whether an interpreter error is caused by a +// missing Tier 1 import. Callers can use this to retry through the Tier 2 +// CPython subprocess runner. +func IsTier2FallbackCandidate(err error) bool { + var importErr UnsupportedImportError + return errors.As(err, &importErr) +} diff --git a/runtime/interpreter_test.go b/runtime/interpreter_test.go new file mode 100644 index 0000000..db5ec92 --- /dev/null +++ b/runtime/interpreter_test.go @@ -0,0 +1,1569 @@ +package runtime_test + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + goruntime "runtime" + "strconv" + "strings" + "testing" + "time" + + core "dappco.re/go/core" + "dappco.re/go/py/bindings/register" + corepyruntime "dappco.re/go/py/runtime" +) + +type lifecycleService struct { + started bool + stopped bool +} + +type mockI18nTranslator struct { + language string +} + +func (translator *mockI18nTranslator) Translate(id string, args ...any) core.Result { + return core.Result{Value: "translated:" + id, OK: true} +} + +func (translator *mockI18nTranslator) SetLanguage(lang string) error { + translator.language = lang + return nil +} + +func (translator *mockI18nTranslator) Language() string { + return translator.language +} + +func (translator *mockI18nTranslator) AvailableLanguages() []string { + return []string{"en", "de", "fr"} +} + +func (service *lifecycleService) OnStartup(ctx context.Context) core.Result { + service.started = true + return core.Result{OK: ctx.Err() == nil} +} + +func (service *lifecycleService) OnShutdown(ctx context.Context) core.Result { + service.stopped = true + return core.Result{OK: ctx.Err() == nil} +} + +type testInterpreter interface { + corepyruntime.Interpreter + corepyruntime.DirectCaller +} + +func newTestInterpreter(t *testing.T) testInterpreter { + t.Helper() + + interpreter, err := corepyruntime.New(corepyruntime.Options{}) + if err != nil { + t.Fatalf("create interpreter: %v", err) + } + if err := register.DefaultModules(interpreter); err != nil { + t.Fatalf("register modules: %v", err) + } + caller, ok := interpreter.(testInterpreter) + if !ok { + t.Fatalf("interpreter does not expose direct calls: %T", interpreter) + } + return caller +} + +func TestNew_DefaultBackendBootstrap_Good(t *testing.T) { + interpreter, err := corepyruntime.New(corepyruntime.Options{}) + if err != nil { + t.Fatalf("create default interpreter: %v", err) + } + defer interpreter.Close() + + if err := register.DefaultModules(interpreter); err != nil { + t.Fatalf("register modules: %v", err) + } + + output, err := interpreter.Run(`from core import echo; print(echo('hello'))`) + if err != nil { + t.Fatalf("run script: %v", err) + } + if strings.TrimSpace(output) != "hello" { + t.Fatalf("unexpected output %q", output) + } +} + +func TestNew_GPythonBackendNotBuilt_Bad(t *testing.T) { + interpreter, err := corepyruntime.New(corepyruntime.Options{Backend: corepyruntime.BackendGPython}) + if err == nil { + defer interpreter.Close() + if err := register.DefaultModules(interpreter); err != nil { + t.Fatalf("register gpython shim modules: %v", err) + } + output, err := interpreter.Run(`from core import echo; print(echo("hello"))`) + if err != nil { + t.Fatalf("run gpython shim smoke path: %v", err) + } + if strings.TrimSpace(output) != "hello" { + t.Fatalf("unexpected gpython shim output %q", output) + } + return + } + + var backendErr corepyruntime.BackendNotBuiltError + if !errors.As(err, &backendErr) { + t.Fatalf("expected BackendNotBuiltError, got %T: %v", err, err) + } + if backendErr.Backend != corepyruntime.BackendGPython { + t.Fatalf("unexpected backend in error %#v", backendErr) + } +} + +func TestNew_UnknownBackend_Ugly(t *testing.T) { + interpreter, err := corepyruntime.New(corepyruntime.Options{Backend: "cpython"}) + if err == nil { + _ = interpreter.Close() + t.Fatal("expected unknown backend to fail") + } + if !strings.Contains(err.Error(), "unknown backend") { + t.Fatalf("unexpected error %v", err) + } +} + +func TestRegister_DefaultModuleCatalog_Good(t *testing.T) { + names := register.DefaultModuleNames() + if len(names) < 20 { + t.Fatalf("expected default module catalog to cover RFC surface, got %#v", names) + } + + seen := map[string]struct{}{} + for _, name := range names { + if !strings.HasPrefix(name, "core.") { + t.Fatalf("expected core.* module name, got %q", name) + } + if _, ok := seen[name]; ok { + t.Fatalf("duplicate module name %q", name) + } + seen[name] = struct{}{} + } + for _, required := range []string{"core.fs", "core.process", "core.math", "core.crypto", "core.ws"} { + if _, ok := seen[required]; !ok { + t.Fatalf("default catalog missing %s in %#v", required, names) + } + } +} + +func TestRegister_DefaultShadowModuleCatalog_Good(t *testing.T) { + names := register.DefaultShadowModuleNames() + seen := map[string]struct{}{} + for _, name := range names { + if strings.HasPrefix(name, "core.") { + t.Fatalf("stdlib shadow module should not use core.* name: %q", name) + } + if _, ok := seen[name]; ok { + t.Fatalf("duplicate shadow module name %q", name) + } + seen[name] = struct{}{} + } + for _, required := range []string{"os", "os.path", "json", "subprocess", "logging", "hashlib", "base64", "socket"} { + if _, ok := seen[required]; !ok { + t.Fatalf("shadow catalog missing %s in %#v", required, names) + } + } +} + +func TestInterpreter_Run_EchoRoundTrip_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + output, err := interpreter.Run(` +from core import echo +print(echo("hello")) +`) + if err != nil { + t.Fatalf("run script: %v", err) + } + if strings.TrimSpace(output) != "hello" { + t.Fatalf("unexpected output %q", output) + } +} + +func TestInterpreter_Session_PreservesNamespace_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + sessionCreator, ok := interpreter.(corepyruntime.SessionCreator) + if !ok { + t.Fatalf("interpreter does not expose sessions: %T", interpreter) + } + session := sessionCreator.NewSession() + + if output, err := session.Run(`from core import echo`); err != nil { + t.Fatalf("import echo in session: %v", err) + } else if output != "" { + t.Fatalf("expected no import output, got %q", output) + } + if output, err := session.Run(`message = echo("session")`); err != nil { + t.Fatalf("assign session value: %v", err) + } else if output != "" { + t.Fatalf("expected no assignment output, got %q", output) + } + + output, err := session.Run(`print(message)`) + if err != nil { + t.Fatalf("print session value: %v", err) + } + if strings.TrimSpace(output) != "session" { + t.Fatalf("unexpected session output %q", output) + } +} + +func TestInterpreter_Run_SubmoduleImport_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + directory := t.TempDir() + filename := filepath.Join(directory, "sample.json") + if err := os.WriteFile(filename, []byte(`{"name":"corepy"}`), 0600); err != nil { + t.Fatalf("write fixture: %v", err) + } + + script := fmt.Sprintf(` +from core import fs, json +data = fs.read_file(%q) +print(json.dumps(json.loads(data))) +`, filename) + + output, err := interpreter.Run(script) + if err != nil { + t.Fatalf("run script: %v", err) + } + if strings.TrimSpace(output) != `{"name":"corepy"}` { + t.Fatalf("unexpected output %q", output) + } +} + +func TestInterpreter_Run_ImportModuleForms_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + filename := filepath.Join(t.TempDir(), "sample.txt") + if err := os.WriteFile(filename, []byte("hello"), 0600); err != nil { + t.Fatalf("write fixture: %v", err) + } + + script := fmt.Sprintf(` +import core +import core.fs as filesystem +print(core.echo("hello")) +print(filesystem.read_file(%q)) +`, filename) + + output, err := interpreter.Run(script) + if err != nil { + t.Fatalf("run script: %v", err) + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + if !reflect.DeepEqual(lines, []string{"hello", "hello"}) { + t.Fatalf("unexpected output lines %#v", lines) + } +} + +func TestInterpreter_Run_StdlibShadowImports_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + goBinary, err := exec.LookPath("go") + if err != nil { + t.Fatalf("find go binary: %v", err) + } + + script := fmt.Sprintf(` +import base64 +import hashlib +import json +import os +import socket +import subprocess +payload = json.loads('{"name":"corepy"}') +print(json.dumps(payload)) +print(os.path.basename(os.path.join("tmp", "corepy.json"))) +print(os.getenv("COREPY_MISSING", "fallback")) +print(socket.getservbyname("http", "tcp")) +print(base64.b64encode("hello")) +digest = hashlib.sha256("hello") +print(digest.hexdigest()) +print(subprocess.check_output([%q, "env", "GOOS"])) +`, goBinary) + + output, err := interpreter.Run(script) + if err != nil { + t.Fatalf("run stdlib shadow imports: %v", err) + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + expectedPrefix := []string{ + `{"name":"corepy"}`, + "corepy.json", + "fallback", + "80", + "aGVsbG8=", + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + } + if len(lines) != len(expectedPrefix)+1 { + t.Fatalf("unexpected output lines %#v", lines) + } + if !reflect.DeepEqual(lines[:len(expectedPrefix)], expectedPrefix) { + t.Fatalf("unexpected stdlib output lines %#v", lines) + } + if lines[len(lines)-1] != goruntime.GOOS { + t.Fatalf("unexpected subprocess output %q", lines[len(lines)-1]) + } +} + +func TestInterpreter_Run_UnsupportedImportFallbackCandidate_Bad(t *testing.T) { + interpreter := newTestInterpreter(t) + + _, err := interpreter.Run(`import sys; print("needs tier2")`) + if err == nil { + t.Fatal("expected unsupported import") + } + if !corepyruntime.IsTier2FallbackCandidate(err) { + t.Fatalf("expected Tier 2 fallback candidate, got %T: %v", err, err) + } + var importErr corepyruntime.UnsupportedImportError + if !errors.As(err, &importErr) { + t.Fatalf("expected UnsupportedImportError, got %T: %v", err, err) + } + if importErr.Module != "sys" { + t.Fatalf("unexpected unsupported module %#v", importErr) + } + + _, err = interpreter.Run(`from os import environ`) + if err == nil { + t.Fatal("expected unsupported shadow export") + } + if !corepyruntime.IsTier2FallbackCandidate(err) { + t.Fatalf("expected stdlib shadow export to be a Tier 2 fallback candidate, got %T: %v", err, err) + } +} + +func TestInterpreter_Run_MediumImport_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + output, err := interpreter.Run(` +from core import medium +buffer = medium.memory("hello") +medium.write_text(buffer, "updated") +print(medium.read_text(buffer)) +`) + if err != nil { + t.Fatalf("run script: %v", err) + } + if strings.TrimSpace(output) != "updated" { + t.Fatalf("unexpected output %q", output) + } +} + +func TestInterpreter_Run_ProcessImport_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + goBinary, err := exec.LookPath("go") + if err != nil { + t.Fatalf("find go binary: %v", err) + } + + script := fmt.Sprintf(` +from core import process +print(process.run(%q, "env", "GOOS")) +`, goBinary) + + output, err := interpreter.Run(script) + if err != nil { + t.Fatalf("run script: %v", err) + } + if strings.TrimSpace(output) != goruntime.GOOS { + t.Fatalf("unexpected output %q", output) + } +} + +func TestInterpreter_Run_ConfigEnvFallback_Good(t *testing.T) { + t.Setenv("DATABASE_HOST", "db.internal") + t.Setenv("PORT", "8080") + t.Setenv("DEBUG", "true") + + interpreter := newTestInterpreter(t) + + output, err := interpreter.Run(` +from core import config +cfg = config.new() +print(config.get(cfg, "database.host")) +print(config.int(cfg, "port")) +print(config.bool(cfg, "debug")) +config.set(cfg, "database.host", "override.internal") +config.set(cfg, "port", 9000) +config.set(cfg, "debug", False) +print(config.string(cfg, "database.host")) +print(config.int(cfg, "port")) +print(config.bool(cfg, "debug")) +`) + if err != nil { + t.Fatalf("run config env fallback: %v", err) + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + if !reflect.DeepEqual(lines, []string{"db.internal", "8080", "True", "override.internal", "9000", "False"}) { + t.Fatalf("unexpected output lines %#v", lines) + } +} + +func TestInterpreter_Run_PathAndStringsImport_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + output, err := interpreter.Run(` +from core import path, strings +location = path.join("deploy", "to", "homelab") +print(strings.concat(location, ":", path.base(location))) +`) + if err != nil { + t.Fatalf("run script: %v", err) + } + if strings.TrimSpace(output) != "deploy/to/homelab:homelab" { + t.Fatalf("unexpected output %q", output) + } +} + +func TestInterpreter_Run_ListAndDictTypeMapping_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + output, err := interpreter.Run(` +from core import options, math +values = [3, 1, 2] +items = {"name": "corepy", "port": 8080} +handle = options.new(items) +print(options.string(handle, "name")) +print(math.mean(values)) +print(math.sort(values)) +`) + if err != nil { + t.Fatalf("run script: %v", err) + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + if !reflect.DeepEqual(lines, []string{"corepy", "2", "[1, 2, 3]"}) { + t.Fatalf("unexpected output lines %#v", lines) + } +} + +func TestInterpreter_Run_MathExample_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + script, err := os.ReadFile(filepath.Join("..", "examples", "math.py")) + if err != nil { + t.Fatalf("read math example: %v", err) + } + + output, err := interpreter.Run(string(script)) + if err != nil { + t.Fatalf("run math example: %v", err) + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 output lines, got %#v", lines) + } + if lines[0] != "0.5" { + t.Fatalf("unexpected mean output %q", lines[0]) + } + if !strings.Contains(lines[1], `"index": 1`) || !strings.Contains(lines[1], `"index": 0`) { + t.Fatalf("unexpected nearest-neighbour output %q", lines[1]) + } +} + +func TestInterpreter_Run_RFCMathImports_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + output, err := interpreter.Run(` +from core.math import kdtree, knn, mean, stdev +embeddings = [[1.0, 0.0], [0.0, 1.0], [0.8, 0.2]] +tree = kdtree.build(embeddings, metric="cosine") +print(mean([1, 2, 3])) +print(stdev([1, 2, 3])) +print(tree.nearest([1.0, 0.0], k=2)) +print(knn.search(embeddings, [1.0, 0.0], k=2, metric="cosine")) +`) + if err != nil { + t.Fatalf("run RFC math imports: %v", err) + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) != 4 { + t.Fatalf("expected 4 output lines, got %#v", lines) + } + if lines[0] != "2" { + t.Fatalf("unexpected mean output %q", lines[0]) + } + if !strings.HasPrefix(lines[1], "0.81649") { + t.Fatalf("unexpected stdev output %q", lines[1]) + } + if !strings.Contains(lines[2], `"index": 0`) || !strings.Contains(lines[2], `"index": 2`) { + t.Fatalf("unexpected tree nearest output %q", lines[2]) + } + if !strings.Contains(lines[3], `"index": 0`) || !strings.Contains(lines[3], `"index": 2`) { + t.Fatalf("unexpected knn output %q", lines[3]) + } +} + +func TestInterpreter_Run_DirectNestedMathImports_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + output, err := interpreter.Run(` +import core.math.kdtree as kdtree +from core.math.knn import search +tree = kdtree.build([[0.0, 0.0], [1.0, 1.0], [3.0, 3.0]], metric="euclidean") +print(tree.nearest([0.8, 0.8], k=2)) +print(search([[1.0, 0.0], [0.0, 1.0], [0.8, 0.2]], [1.0, 0.0], k=2, metric="cosine")) +`) + if err != nil { + t.Fatalf("run nested math imports: %v", err) + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 output lines, got %#v", lines) + } + if !strings.Contains(lines[0], `"index": 1`) || !strings.Contains(lines[0], `"index": 0`) { + t.Fatalf("unexpected kdtree output %q", lines[0]) + } + if !strings.Contains(lines[1], `"index": 0`) || !strings.Contains(lines[1], `"index": 2`) { + t.Fatalf("unexpected knn output %q", lines[1]) + } +} + +func TestInterpreter_Run_MathSignalImports_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + output, err := interpreter.Run(` +from core import math +from core.math import signal +values = [1, 3, 6, 10] +print(math.moving_average(values, window=2)) +print(signal.difference(values)) +print(math.signal.difference(values, lag=2)) +`) + if err != nil { + t.Fatalf("run math signal imports: %v", err) + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + if !reflect.DeepEqual(lines, []string{"[1, 2, 4.5, 8]", "[2, 3, 4]", "[5, 7]"}) { + t.Fatalf("unexpected signal output lines %#v", lines) + } +} + +func TestInterpreter_Call_Primitives_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + optionsHandle, err := interpreter.Call("core.options", "new", map[string]any{ + "name": "corepy", + "port": 8080, + }) + if err != nil { + t.Fatalf("create options: %v", err) + } + + name, err := interpreter.Call("core.options", "string", optionsHandle, "name") + if err != nil { + t.Fatalf("options string: %v", err) + } + if name != "corepy" { + t.Fatalf("unexpected option name %#v", name) + } + + configHandle, err := interpreter.Call("core.config", "new") + if err != nil { + t.Fatalf("create config: %v", err) + } + if _, err := interpreter.Call("core.config", "set", configHandle, "debug", true); err != nil { + t.Fatalf("set config: %v", err) + } + debugEnabled, err := interpreter.Call("core.config", "bool", configHandle, "debug") + if err != nil { + t.Fatalf("config bool: %v", err) + } + if debugEnabled != true { + t.Fatalf("unexpected debug flag %#v", debugEnabled) + } + + dataHandle, err := interpreter.Call("core.data", "new") + if err != nil { + t.Fatalf("create data registry: %v", err) + } + fixtureDirectory := filepath.Join(t.TempDir(), "fixtures") + if err := os.MkdirAll(fixtureDirectory, 0755); err != nil { + t.Fatalf("create fixture directory: %v", err) + } + if err := os.WriteFile(filepath.Join(fixtureDirectory, "note.txt"), []byte("hello from data"), 0600); err != nil { + t.Fatalf("write data fixture: %v", err) + } + if _, err := interpreter.Call("core.data", "mount_path", dataHandle, "fixtures", fixtureDirectory); err != nil { + t.Fatalf("mount data path: %v", err) + } + content, err := interpreter.Call("core.data", "read_string", dataHandle, "fixtures/note.txt") + if err != nil { + t.Fatalf("read mounted data: %v", err) + } + if content != "hello from data" { + t.Fatalf("unexpected mounted content %#v", content) + } + + serviceHandle, err := interpreter.Call("core.service", "new", "corepy") + if err != nil { + t.Fatalf("create service core: %v", err) + } + if _, err := interpreter.Call("core.service", "register", serviceHandle, "brain"); err != nil { + t.Fatalf("register service: %v", err) + } + serviceNames, err := interpreter.Call("core.service", "names", serviceHandle) + if err != nil { + t.Fatalf("list services: %v", err) + } + names := serviceNames.([]string) + if len(names) == 0 || names[0] != "cli" { + t.Fatalf("expected built-in cli service first, got %#v", names) + } + if names[len(names)-1] != "brain" { + t.Fatalf("expected registered service in names, got %#v", names) + } +} + +func TestInterpreter_Call_MathPrimitives_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + sortedValues, err := interpreter.Call("core.math", "sort", []any{3, 1, 2}) + if err != nil { + t.Fatalf("sort values: %v", err) + } + if !reflect.DeepEqual(sortedValues, []any{1, 2, 3}) { + t.Fatalf("unexpected sorted values %#v", sortedValues) + } + + index, err := interpreter.Call("core.math", "binary_search", []any{1, 2, 3}, 2) + if err != nil { + t.Fatalf("binary search: %v", err) + } + if index != 1 { + t.Fatalf("unexpected binary search index %#v", index) + } + + tree, err := interpreter.Call("core.math.kdtree", "build", []any{ + []any{0.0, 0.0}, + []any{1.0, 1.0}, + []any{3.0, 3.0}, + }, corepyruntime.KeywordArguments{"metric": "euclidean"}) + if err != nil { + t.Fatalf("build kdtree: %v", err) + } + + defaultNearest, err := interpreter.Call("core.math.kdtree", "nearest", tree, []any{0.8, 0.8}) + if err != nil { + t.Fatalf("kdtree nearest default k: %v", err) + } + if len(defaultNearest.([]map[string]any)) != 1 { + t.Fatalf("expected default nearest search to return one neighbour, got %#v", defaultNearest) + } + + nearest, err := interpreter.Call("core.math.kdtree", "nearest", tree, []any{0.8, 0.8}, corepyruntime.KeywordArguments{"k": 2}) + if err != nil { + t.Fatalf("kdtree nearest: %v", err) + } + + neighbors := nearest.([]map[string]any) + if len(neighbors) != 2 { + t.Fatalf("expected 2 neighbours, got %#v", neighbors) + } + if neighbors[0]["index"] != 1 || neighbors[1]["index"] != 0 { + t.Fatalf("unexpected neighbour order %#v", neighbors) + } + + cosine, err := interpreter.Call("core.math.knn", "search", []any{ + []any{1.0, 0.0}, + []any{0.0, 1.0}, + []any{0.8, 0.2}, + }, []any{1.0, 0.0}, corepyruntime.KeywordArguments{"k": 2, "metric": "cosine"}) + if err != nil { + t.Fatalf("knn search: %v", err) + } + + cosineNeighbors := cosine.([]map[string]any) + if cosineNeighbors[0]["index"] != 0 || cosineNeighbors[1]["index"] != 2 { + t.Fatalf("unexpected cosine neighbour order %#v", cosineNeighbors) + } + + smoothed, err := interpreter.Call("core.math", "moving_average", []any{1, 3, 6, 10}, corepyruntime.KeywordArguments{"window": 2}) + if err != nil { + t.Fatalf("moving average: %v", err) + } + if !reflect.DeepEqual(smoothed, []float64{1, 2, 4.5, 8}) { + t.Fatalf("unexpected smoothed values %#v", smoothed) + } + + delta, err := interpreter.Call("core.math.signal", "difference", []any{1, 3, 6, 10}, corepyruntime.KeywordArguments{"lag": 2}) + if err != nil { + t.Fatalf("difference: %v", err) + } + if !reflect.DeepEqual(delta, []float64{5, 7}) { + t.Fatalf("unexpected difference values %#v", delta) + } +} + +func TestInterpreter_Call_FilesystemAndMediumBytes_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + filename := filepath.Join(t.TempDir(), "payload.bin") + if _, err := interpreter.Call("core.fs", "write_bytes", filename, []byte("corepy")); err != nil { + t.Fatalf("write bytes: %v", err) + } + + content, err := interpreter.Call("core.fs", "read_bytes", filename) + if err != nil { + t.Fatalf("read bytes: %v", err) + } + if string(content.([]byte)) != "corepy" { + t.Fatalf("unexpected byte payload %#v", content) + } + + mediumHandle, err := interpreter.Call("core.medium", "from_path", filename) + if err != nil { + t.Fatalf("create file-backed medium: %v", err) + } + if _, err := interpreter.Call("core.medium", "write_bytes", mediumHandle, []byte("updated")); err != nil { + t.Fatalf("write medium bytes: %v", err) + } + mediumContent, err := interpreter.Call("core.medium", "read_bytes", mediumHandle) + if err != nil { + t.Fatalf("read medium bytes: %v", err) + } + if string(mediumContent.([]byte)) != "updated" { + t.Fatalf("unexpected medium payload %#v", mediumContent) + } + + memoryHandle, err := interpreter.Call("core.medium", "memory", "hello") + if err != nil { + t.Fatalf("create memory medium: %v", err) + } + if _, err := interpreter.Call("core.medium", "write_text", memoryHandle, "world"); err != nil { + t.Fatalf("write memory medium: %v", err) + } + text, err := interpreter.Call("core.medium", "read_text", memoryHandle) + if err != nil { + t.Fatalf("read memory medium: %v", err) + } + if text != "world" { + t.Fatalf("unexpected memory medium text %#v", text) + } +} + +func TestInterpreter_Call_ProcessHelpers_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + goBinary, err := exec.LookPath("go") + if err != nil { + t.Fatalf("find go binary: %v", err) + } + repositoryRoot, err := filepath.Abs("..") + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + + output, err := interpreter.Call("core.process", "run", goBinary, "env", "GOOS") + if err != nil { + t.Fatalf("process run: %v", err) + } + if strings.TrimSpace(output.(string)) != goruntime.GOOS { + t.Fatalf("unexpected process output %#v", output) + } + + inDirectoryOutput, err := interpreter.Call("core.process", "run_in", repositoryRoot, goBinary, "env", "GOMOD") + if err != nil { + t.Fatalf("process run_in: %v", err) + } + if strings.TrimSpace(inDirectoryOutput.(string)) != filepath.Join(repositoryRoot, "go.mod") { + t.Fatalf("unexpected process run_in output %#v", inDirectoryOutput) + } + + envOutput, err := interpreter.Call("core.process", "run_with_env", repositoryRoot, map[string]string{"GOWORK": "off"}, goBinary, "env", "GOWORK") + if err != nil { + t.Fatalf("process run_with_env: %v", err) + } + if strings.TrimSpace(envOutput.(string)) != "off" { + t.Fatalf("unexpected process run_with_env output %#v", envOutput) + } + + exists, err := interpreter.Call("core.process", "exists") + if err != nil { + t.Fatalf("process exists: %v", err) + } + if exists != true { + t.Fatalf("expected process capability to exist, got %#v", exists) + } +} + +func TestInterpreter_Call_ProcessRunResult_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + value, err := interpreter.Call( + "core.process", + "run_result", + os.Args[0], + "-test.run=TestProcessHelper", + "--", + corepyruntime.KeywordArguments{ + "env": map[string]any{ + "COREPY_PROCESS_HELPER": "1", + "COREPY_STDOUT": "hello stdout\n", + "COREPY_STDERR": "hello stderr\n", + "COREPY_EXIT": "7", + }, + }, + ) + if err != nil { + t.Fatalf("run process result: %v", err) + } + + result := value.(map[string]any) + if result["ok"] != false { + t.Fatalf("expected failed ok flag, got %#v", result) + } + if result["exit_code"] != 7 { + t.Fatalf("unexpected exit code %#v", result) + } + if result["stdout"] != "hello stdout\n" || result["stderr"] != "hello stderr\n" { + t.Fatalf("unexpected output capture %#v", result) + } + if result["timed_out"] != false { + t.Fatalf("unexpected timeout flag %#v", result) + } +} + +func TestInterpreter_Call_ProcessRun_CheckFailure_Bad(t *testing.T) { + interpreter := newTestInterpreter(t) + + _, err := interpreter.Call( + "core.process", + "run", + os.Args[0], + "-test.run=TestProcessHelper", + "--", + corepyruntime.KeywordArguments{ + "env": map[string]any{ + "COREPY_PROCESS_HELPER": "1", + "COREPY_STDERR": "checked failure\n", + "COREPY_EXIT": "9", + }, + }, + ) + if err == nil { + t.Fatal("expected checked process failure") + } + if !strings.Contains(err.Error(), "checked failure") { + t.Fatalf("expected stderr in error, got %v", err) + } +} + +func TestInterpreter_Call_ProcessRunResultTimeout_Ugly(t *testing.T) { + interpreter := newTestInterpreter(t) + + value, err := interpreter.Call( + "core.process", + "run_result", + os.Args[0], + "-test.run=TestProcessHelper", + "--", + corepyruntime.KeywordArguments{ + "timeout": "100ms", + "env": map[string]any{ + "COREPY_PROCESS_HELPER": "1", + "COREPY_SLEEP_MS": "5000", + }, + }, + ) + if err != nil { + t.Fatalf("timeout result should be inspectable without check: %v", err) + } + + result := value.(map[string]any) + if result["timed_out"] != true || result["ok"] != false { + t.Fatalf("expected timeout result, got %#v", result) + } +} + +func TestInterpreter_Call_DataExtract_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + fixtureDirectory := filepath.Join(t.TempDir(), "fixtures") + templateDirectory := filepath.Join(fixtureDirectory, "templates") + if err := os.MkdirAll(templateDirectory, 0755); err != nil { + t.Fatalf("create fixture directories: %v", err) + } + if err := os.WriteFile(filepath.Join(fixtureDirectory, "note.txt"), []byte("hello from data"), 0600); err != nil { + t.Fatalf("write data note: %v", err) + } + if err := os.WriteFile(filepath.Join(templateDirectory, "greeting.txt.tmpl"), []byte("hello {{.Name}}"), 0600); err != nil { + t.Fatalf("write template file: %v", err) + } + + dataHandle, err := interpreter.Call("core.data", "new") + if err != nil { + t.Fatalf("create data registry: %v", err) + } + if _, err := interpreter.Call("core.data", "mount", dataHandle, "fixtures", fixtureDirectory); err != nil { + t.Fatalf("mount data path: %v", err) + } + + fileContent, err := interpreter.Call("core.data", "read_file", dataHandle, "fixtures/note.txt") + if err != nil { + t.Fatalf("read data file: %v", err) + } + if string(fileContent.([]byte)) != "hello from data" { + t.Fatalf("unexpected mounted bytes %#v", fileContent) + } + + listed, err := interpreter.Call("core.data", "list", dataHandle, "fixtures") + if err != nil { + t.Fatalf("list mounted data: %v", err) + } + if !strings.Contains(strings.Join(listed.([]string), ","), "note.txt") { + t.Fatalf("expected note.txt in mounted list, got %#v", listed) + } + + targetDirectory := filepath.Join(t.TempDir(), "workspace") + if _, err := interpreter.Call("core.data", "extract", dataHandle, "fixtures/templates", targetDirectory, map[string]string{"Name": "corepy"}); err != nil { + t.Fatalf("extract mounted data: %v", err) + } + extracted, err := os.ReadFile(filepath.Join(targetDirectory, "greeting.txt")) + if err != nil { + t.Fatalf("read extracted file: %v", err) + } + if string(extracted) != "hello corepy" { + t.Fatalf("unexpected extracted content %q", extracted) + } +} + +func TestInterpreter_Call_ServiceLifecycle_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + serviceHandle, err := interpreter.Call("core.service", "new", "corepy") + if err != nil { + t.Fatalf("create service core: %v", err) + } + + runner := &lifecycleService{} + if _, err := interpreter.Call("core.service", "register", serviceHandle, "runner", runner); err != nil { + t.Fatalf("register lifecycle service: %v", err) + } + + serviceValue, err := interpreter.Call("core.service", "get", serviceHandle, "runner") + if err != nil { + t.Fatalf("get service: %v", err) + } + if serviceValue != runner { + t.Fatalf("unexpected service instance %#v", serviceValue) + } + + if _, err := interpreter.Call("core.service", "start_all", serviceHandle); err != nil { + t.Fatalf("start services: %v", err) + } + if !runner.started { + t.Fatal("expected lifecycle service to start") + } + + if _, err := interpreter.Call("core.service", "stop_all", serviceHandle); err != nil { + t.Fatalf("stop services: %v", err) + } + if !runner.stopped { + t.Fatal("expected lifecycle service to stop") + } +} + +func TestInterpreter_Call_ErrorHelpers_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + issue, err := interpreter.Call("core.err", "e", "core.save", "write failed", nil, "WRITE_FAIL") + if err != nil { + t.Fatalf("create structured error: %v", err) + } + + code, err := interpreter.Call("core.err", "error_code", issue) + if err != nil { + t.Fatalf("read error code: %v", err) + } + if code != "WRITE_FAIL" { + t.Fatalf("unexpected error code %#v", code) + } + + wrapped, err := interpreter.Call("core.err", "wrap", issue, "core.deploy", "deploy failed", "DEPLOY_FAIL") + if err != nil { + t.Fatalf("wrap structured error: %v", err) + } + root, err := interpreter.Call("core.err", "root", wrapped) + if err != nil { + t.Fatalf("read root error: %v", err) + } + if !errors.Is(wrapped.(error), root.(error)) { + t.Fatalf("expected root error to be part of the wrapped chain, got %#v", root) + } + + nilWrapped, err := interpreter.Call("core.err", "wrap", nil, "core.deploy", "deploy failed") + if err != nil { + t.Fatalf("wrap nil error: %v", err) + } + if nilWrapped != nil { + t.Fatalf("expected nil wrapped error, got %#v", nilWrapped) + } +} + +func TestInterpreter_Call_PathAndStringHelpers_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + joined, err := interpreter.Call("core.path", "join", "deploy", "to", "homelab") + if err != nil { + t.Fatalf("path join: %v", err) + } + if joined != "deploy/to/homelab" { + t.Fatalf("unexpected joined path %#v", joined) + } + + baseName, err := interpreter.Call("core.path", "base", "/tmp/corepy/config.json") + if err != nil { + t.Fatalf("path base: %v", err) + } + if baseName != "config.json" { + t.Fatalf("unexpected base name %#v", baseName) + } + + cleaned, err := interpreter.Call("core.path", "clean", "deploy//to/../from") + if err != nil { + t.Fatalf("path clean: %v", err) + } + if cleaned != "deploy/from" { + t.Fatalf("unexpected cleaned path %#v", cleaned) + } + + contains, err := interpreter.Call("core.strings", "contains", "hello world", "world") + if err != nil { + t.Fatalf("strings contains: %v", err) + } + if contains != true { + t.Fatalf("expected contains to be true, got %#v", contains) + } + + parts, err := interpreter.Call("core.strings", "split_n", "key=value=extra", "=", 2) + if err != nil { + t.Fatalf("strings split_n: %v", err) + } + if !reflect.DeepEqual(parts, []string{"key", "value=extra"}) { + t.Fatalf("unexpected split parts %#v", parts) + } +} + +func TestInterpreter_Run_AdditionalRFCModules_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + cacheDirectory := filepath.Join(t.TempDir(), "cache") + script := fmt.Sprintf(` +from core import cache, crypto, dns +store = cache.new(%q, 60) +cache.set(store, "greeting", {"name": "corepy"}) +print(cache.has(store, "greeting")) +print(crypto.sha256("hello")) +print(dns.lookup_port("tcp", "http")) +`, cacheDirectory) + + output, err := interpreter.Run(script) + if err != nil { + t.Fatalf("run additional RFC imports: %v", err) + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + expected := []string{ + "True", + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + "80", + } + if !reflect.DeepEqual(lines, expected) { + t.Fatalf("unexpected output lines %#v", lines) + } +} + +func TestInterpreter_Call_AdditionalRFCModules_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + cacheHandle, err := interpreter.Call("core.cache", "new", filepath.Join(t.TempDir(), "cache"), 60) + if err != nil { + t.Fatalf("create cache: %v", err) + } + if _, err := interpreter.Call("core.cache", "set", cacheHandle, "greeting", map[string]any{"name": "corepy", "debug": true}); err != nil { + t.Fatalf("set cache value: %v", err) + } + + cachedValue, err := interpreter.Call("core.cache", "get", cacheHandle, "greeting") + if err != nil { + t.Fatalf("get cache value: %v", err) + } + cachedMap := cachedValue.(map[string]any) + if cachedMap["name"] != "corepy" || cachedMap["debug"] != true { + t.Fatalf("unexpected cached value %#v", cachedMap) + } + + missingValue, err := interpreter.Call("core.cache", "get", cacheHandle, "missing", "fallback") + if err != nil { + t.Fatalf("get missing cache value: %v", err) + } + if missingValue != "fallback" { + t.Fatalf("unexpected missing cache default %#v", missingValue) + } + + cacheKeys, err := interpreter.Call("core.cache", "keys", cacheHandle) + if err != nil { + t.Fatalf("list cache keys: %v", err) + } + if !reflect.DeepEqual(cacheKeys, []string{"greeting"}) { + t.Fatalf("unexpected cache keys %#v", cacheKeys) + } + + sha1Digest, err := interpreter.Call("core.crypto", "sha1", "hello") + if err != nil { + t.Fatalf("sha1 digest: %v", err) + } + if sha1Digest != "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d" { + t.Fatalf("unexpected sha1 digest %#v", sha1Digest) + } + + encoded, err := interpreter.Call("core.crypto", "base64_encode", "hello") + if err != nil { + t.Fatalf("base64 encode: %v", err) + } + if encoded != "aGVsbG8=" { + t.Fatalf("unexpected base64 encoded value %#v", encoded) + } + + decoded, err := interpreter.Call("core.crypto", "base64_decode", "aGVsbG8=") + if err != nil { + t.Fatalf("base64 decode: %v", err) + } + if string(decoded.([]byte)) != "hello" { + t.Fatalf("unexpected base64 decoded value %#v", decoded) + } + + random, err := interpreter.Call("core.crypto", "random_bytes", 16) + if err != nil { + t.Fatalf("random bytes: %v", err) + } + if len(random.([]byte)) != 16 { + t.Fatalf("unexpected random byte length %#v", len(random.([]byte))) + } + + port, err := interpreter.Call("core.dns", "lookup_port", "tcp", "http") + if err != nil { + t.Fatalf("lookup port: %v", err) + } + if port != 80 { + t.Fatalf("unexpected lookup port %#v", port) + } + + hosts, err := interpreter.Call("core.dns", "lookup_host", "localhost") + if err != nil { + t.Fatalf("lookup host: %v", err) + } + if len(hosts.([]string)) == 0 { + t.Fatalf("expected localhost lookup to return addresses, got %#v", hosts) + } +} + +func TestInterpreter_Call_SCMHelpers_Good(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not available") + } + + interpreter := newTestInterpreter(t) + repository := t.TempDir() + + runGitCommand(t, repository, "init") + runGitCommand(t, repository, "config", "user.email", "corepy@example.com") + runGitCommand(t, repository, "config", "user.name", "CorePy Tests") + filename := filepath.Join(repository, "README.md") + if err := os.WriteFile(filename, []byte("hello\n"), 0600); err != nil { + t.Fatalf("write tracked file: %v", err) + } + runGitCommand(t, repository, "add", "README.md") + runGitCommand(t, repository, "commit", "-m", "initial") + + existsValue, err := interpreter.Call("core.scm", "exists", repository) + if err != nil { + t.Fatalf("check scm existence: %v", err) + } + if existsValue != true { + t.Fatalf("expected repository to exist, got %#v", existsValue) + } + + rootValue, err := interpreter.Call("core.scm", "root", repository) + if err != nil { + t.Fatalf("read repository root: %v", err) + } + expectedRoot := repository + if resolved, err := filepath.EvalSymlinks(repository); err == nil { + expectedRoot = resolved + } + if rootValue != expectedRoot { + t.Fatalf("unexpected repository root %#v", rootValue) + } + + branchValue, err := interpreter.Call("core.scm", "branch", repository) + if err != nil { + t.Fatalf("read repository branch: %v", err) + } + if strings.TrimSpace(branchValue.(string)) == "" { + t.Fatalf("expected branch name, got %#v", branchValue) + } + + headValue, err := interpreter.Call("core.scm", "head", repository) + if err != nil { + t.Fatalf("read repository head: %v", err) + } + if len(headValue.(string)) != 40 { + t.Fatalf("unexpected head hash %#v", headValue) + } + + trackedValue, err := interpreter.Call("core.scm", "tracked_files", repository) + if err != nil { + t.Fatalf("read tracked files: %v", err) + } + if !reflect.DeepEqual(trackedValue, []string{"README.md"}) { + t.Fatalf("unexpected tracked files %#v", trackedValue) + } + + statusValue, err := interpreter.Call("core.scm", "status", repository) + if err != nil { + t.Fatalf("read clean status: %v", err) + } + cleanStatus := statusValue.(map[string]any) + if cleanStatus["clean"] != true { + t.Fatalf("expected clean status, got %#v", cleanStatus) + } + + if err := os.WriteFile(filename, []byte("updated\n"), 0600); err != nil { + t.Fatalf("update tracked file: %v", err) + } + statusValue, err = interpreter.Call("core.scm", "status", repository) + if err != nil { + t.Fatalf("read dirty status: %v", err) + } + dirtyStatus := statusValue.(map[string]any) + if dirtyStatus["clean"] != false { + t.Fatalf("expected dirty status, got %#v", dirtyStatus) + } + if len(dirtyStatus["changes"].([]string)) == 0 { + t.Fatalf("expected change entries in dirty status, got %#v", dirtyStatus) + } +} + +func TestInterpreter_Run_CorePrimitivePorts_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + output, err := interpreter.Run(` +from core import array, entitlement, info, registry +values = array.new("a", "b") +array.add(values, "c") +array.add_unique(values, "c", "d") +items = registry.new() +registry.set(items, "alpha", 1) +registry.set(items, "beta", 2) +registry.disable(items, "beta") +grant = entitlement.new(True, False, 5, 4, 1, "") +print(array.as_list(values)) +print(registry.list(items, "*")) +print(info.env("OS")) +print(entitlement.near_limit(grant, 0.8)) +print(entitlement.usage_percent(grant)) +`) + if err != nil { + t.Fatalf("run core primitive ports: %v", err) + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) != 5 { + t.Fatalf("expected 5 output lines, got %#v", lines) + } + if lines[0] != `["a", "b", "c", "d"]` { + t.Fatalf("unexpected array output %q", lines[0]) + } + if lines[1] != `[1]` { + t.Fatalf("unexpected registry output %q", lines[1]) + } + if lines[2] != goruntime.GOOS { + t.Fatalf("unexpected OS output %q", lines[2]) + } + if lines[3] != "True" { + t.Fatalf("unexpected entitlement near-limit output %q", lines[3]) + } + if lines[4] != "80" { + t.Fatalf("unexpected entitlement usage output %q", lines[4]) + } +} + +func TestInterpreter_Call_CorePrimitivePorts_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + arrayHandle, err := interpreter.Call("core.array", "new", "a", "b") + if err != nil { + t.Fatalf("create array: %v", err) + } + if _, err := interpreter.Call("core.array", "add", arrayHandle, "c"); err != nil { + t.Fatalf("add array value: %v", err) + } + if _, err := interpreter.Call("core.array", "add_unique", arrayHandle, "c", "d"); err != nil { + t.Fatalf("add unique array values: %v", err) + } + arrayValues, err := interpreter.Call("core.array", "as_list", arrayHandle) + if err != nil { + t.Fatalf("list array values: %v", err) + } + if !reflect.DeepEqual(arrayValues, []any{"a", "b", "c", "d"}) { + t.Fatalf("unexpected array values %#v", arrayValues) + } + + registryHandle, err := interpreter.Call("core.registry", "new") + if err != nil { + t.Fatalf("create registry: %v", err) + } + if _, err := interpreter.Call("core.registry", "set", registryHandle, "alpha", 1); err != nil { + t.Fatalf("set registry alpha: %v", err) + } + if _, err := interpreter.Call("core.registry", "set", registryHandle, "beta", 2); err != nil { + t.Fatalf("set registry beta: %v", err) + } + if _, err := interpreter.Call("core.registry", "disable", registryHandle, "beta"); err != nil { + t.Fatalf("disable registry beta: %v", err) + } + listed, err := interpreter.Call("core.registry", "list", registryHandle, "*") + if err != nil { + t.Fatalf("list registry values: %v", err) + } + if !reflect.DeepEqual(listed, []any{1}) { + t.Fatalf("unexpected registry list %#v", listed) + } + if _, err := interpreter.Call("core.registry", "seal", registryHandle); err != nil { + t.Fatalf("seal registry: %v", err) + } + if _, err := interpreter.Call("core.registry", "set", registryHandle, "gamma", 3); err == nil { + t.Fatal("expected setting new key on sealed registry to fail") + } + if _, err := interpreter.Call("core.registry", "open", registryHandle); err != nil { + t.Fatalf("open registry: %v", err) + } + if _, err := interpreter.Call("core.registry", "set", registryHandle, "gamma", 3); err != nil { + t.Fatalf("set registry gamma after reopen: %v", err) + } + + snapshot, err := interpreter.Call("core.info", "snapshot") + if err != nil { + t.Fatalf("snapshot info: %v", err) + } + if snapshot.(map[string]any)["OS"] != goruntime.GOOS { + t.Fatalf("unexpected info snapshot %#v", snapshot) + } + + grant, err := interpreter.Call("core.entitlement", "new", true, false, 5, 4, 1, "") + if err != nil { + t.Fatalf("create entitlement: %v", err) + } + nearLimit, err := interpreter.Call("core.entitlement", "near_limit", grant, 0.8) + if err != nil { + t.Fatalf("read entitlement near-limit: %v", err) + } + if nearLimit != true { + t.Fatalf("expected near limit, got %#v", nearLimit) + } + usage, err := interpreter.Call("core.entitlement", "usage_percent", grant) + if err != nil { + t.Fatalf("read entitlement usage: %v", err) + } + if usage != 80.0 { + t.Fatalf("unexpected entitlement usage %#v", usage) + } +} + +func TestInterpreter_Run_ActionTaskAndI18nPorts_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + output, err := interpreter.Run(` +from core import action, i18n, task +actions = action.new_registry() +missing = action.get(actions, "missing") +steps = [task.new_step("produce"), task.new_step("consume", input="previous")] +plan = task.new("pipeline", steps) +messages = i18n.new() +print(action.exists(missing)) +print(task.exists(plan)) +print(i18n.translate(messages, "hello.world")) +print(i18n.available_languages(messages)) +`) + if err != nil { + t.Fatalf("run action/task/i18n ports: %v", err) + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + expected := []string{"False", "True", "hello.world", "[\"en\"]"} + if !reflect.DeepEqual(lines, expected) { + t.Fatalf("unexpected action/task/i18n output %#v", lines) + } +} + +func TestInterpreter_Call_ActionTaskAndI18nPorts_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + actions, err := interpreter.Call("core.action", "new_registry") + if err != nil { + t.Fatalf("create action registry: %v", err) + } + if _, err := interpreter.Call( + "core.action", + "register", + actions, + "produce", + corepyruntime.Function(func(arguments ...any) (any, error) { + return "payload", nil + }), + ); err != nil { + t.Fatalf("register produce action: %v", err) + } + if _, err := interpreter.Call( + "core.action", + "register", + actions, + "consume", + corepyruntime.Function(func(arguments ...any) (any, error) { + if len(arguments) == 0 { + return "missing", nil + } + values := arguments[0].(map[string]any) + return "got:" + values["_input"].(string), nil + }), + ); err != nil { + t.Fatalf("register consume action: %v", err) + } + + actionNames, err := interpreter.Call("core.action", "names", actions) + if err != nil { + t.Fatalf("list action names: %v", err) + } + if !reflect.DeepEqual(actionNames, []string{"produce", "consume"}) { + t.Fatalf("unexpected action names %#v", actionNames) + } + + produced, err := interpreter.Call("core.action", "run", mustAction(t, interpreter, actions, "produce"), map[string]any{}) + if err != nil { + t.Fatalf("run produce action: %v", err) + } + if produced != "payload" { + t.Fatalf("unexpected produce result %#v", produced) + } + + steps := []any{ + map[string]any{"action": "produce"}, + map[string]any{"action": "consume", "input": "previous"}, + } + plan, err := interpreter.Call("core.task", "new", "pipeline", steps) + if err != nil { + t.Fatalf("create task: %v", err) + } + result, err := interpreter.Call("core.task", "run", plan, actions, map[string]any{}) + if err != nil { + t.Fatalf("run task: %v", err) + } + if result != "got:payload" { + t.Fatalf("unexpected task result %#v", result) + } + + messages, err := interpreter.Call("core.i18n", "new") + if err != nil { + t.Fatalf("create i18n handle: %v", err) + } + translated, err := interpreter.Call("core.i18n", "translate", messages, "hello.world") + if err != nil { + t.Fatalf("translate without translator: %v", err) + } + if translated != "hello.world" { + t.Fatalf("unexpected untranslated value %#v", translated) + } + + translator := &mockI18nTranslator{language: "en"} + if _, err := interpreter.Call("core.i18n", "set_translator", messages, translator); err != nil { + t.Fatalf("set translator: %v", err) + } + translated, err = interpreter.Call("core.i18n", "translate", messages, "hello.world") + if err != nil { + t.Fatalf("translate with translator: %v", err) + } + if translated != "translated:hello.world" { + t.Fatalf("unexpected translated value %#v", translated) + } + if _, err := interpreter.Call("core.i18n", "set_language", messages, "de"); err != nil { + t.Fatalf("set language: %v", err) + } + language, err := interpreter.Call("core.i18n", "language", messages) + if err != nil { + t.Fatalf("read language: %v", err) + } + if language != "de" { + t.Fatalf("unexpected language %#v", language) + } + available, err := interpreter.Call("core.i18n", "available_languages", messages) + if err != nil { + t.Fatalf("read available languages: %v", err) + } + if !reflect.DeepEqual(available, []string{"en", "de", "fr"}) { + t.Fatalf("unexpected available languages %#v", available) + } +} + +func mustAction(t *testing.T, interpreter testInterpreter, actions any, name string) any { + t.Helper() + + item, err := interpreter.Call("core.action", "get", actions, name) + if err != nil { + t.Fatalf("get action %s: %v", name, err) + } + return item +} + +func runGitCommand(t *testing.T, directory string, arguments ...string) { + t.Helper() + + gitBinary, err := exec.LookPath("git") + if err != nil { + t.Fatalf("find git binary: %v", err) + } + + command := exec.Command(gitBinary, append([]string{"-C", directory}, arguments...)...) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("run git %v: %v: %s", arguments, err, strings.TrimSpace(string(output))) + } +} + +func TestProcessHelper(t *testing.T) { + if os.Getenv("COREPY_PROCESS_HELPER") != "1" { + return + } + + if value := os.Getenv("COREPY_SLEEP_MS"); value != "" { + milliseconds, err := strconv.Atoi(value) + if err == nil && milliseconds > 0 { + time.Sleep(time.Duration(milliseconds) * time.Millisecond) + } + } + fmt.Fprint(os.Stdout, os.Getenv("COREPY_STDOUT")) + fmt.Fprint(os.Stderr, os.Getenv("COREPY_STDERR")) + exitCode, _ := strconv.Atoi(os.Getenv("COREPY_EXIT")) + os.Exit(exitCode) +} diff --git a/runtime/rfc_stub_modules_test.go b/runtime/rfc_stub_modules_test.go new file mode 100644 index 0000000..666e436 --- /dev/null +++ b/runtime/rfc_stub_modules_test.go @@ -0,0 +1,30 @@ +package runtime_test + +import ( + "reflect" + "strings" + "testing" +) + +func TestRFCStubModules_Available_Good(t *testing.T) { + interpreter := newTestInterpreter(t) + + output, err := interpreter.Run(` +from core import agent, api, container, mcp, store, ws +print(agent.available()) +print(api.available()) +print(container.available()) +print(mcp.available()) +print(store.available()) +print(ws.available()) +`) + if err != nil { + t.Fatalf("run RFC stub module imports: %v", err) + } + + lines := strings.Split(strings.TrimSpace(output), "\n") + expected := []string{"False", "False", "False", "False", "False", "False"} + if !reflect.DeepEqual(lines, expected) { + t.Fatalf("unexpected availability output %#v", lines) + } +} diff --git a/runtime/tier2/runner.go b/runtime/tier2/runner.go new file mode 100644 index 0000000..7c6c55d --- /dev/null +++ b/runtime/tier2/runner.go @@ -0,0 +1,286 @@ +// Package tier2 runs host CPython as CorePy's subprocess escape hatch. +// +// Tier 1 remains the embedded gpython path. Tier 2 is intentionally explicit: +// callers choose a host Python executable, get stdout/stderr/exit semantics +// back, and can stream output through Core mediums or CLI writers. +package tier2 + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const EnvPython = "COREPY_TIER2_PYTHON" + +// Options controls a Tier 2 CPython subprocess. +type Options struct { + Python string + WorkingDirectory string + Environment map[string]string + PythonPath []string + Timeout time.Duration + Stdout io.Writer + Stderr io.Writer +} + +// Runner executes Python source or files with consistent process semantics. +type Runner struct { + options Options +} + +// Result captures a completed or interrupted Tier 2 subprocess. +type Result struct { + Command []string + Stdout string + Stderr string + ExitCode int + TimedOut bool +} + +// OK reports whether the subprocess completed successfully. +func (result Result) OK() bool { + return !result.TimedOut && result.ExitCode == 0 +} + +// ExitError reports a subprocess failure while preserving captured output. +type ExitError struct { + Result Result + Timeout time.Duration + Cause error +} + +func (err ExitError) Error() string { + command := compactCommand(err.Result.Command) + if err.Result.TimedOut { + if err.Timeout > 0 { + return fmt.Sprintf("corepy tier2: command timed out after %s: %s", err.Timeout, command) + } + return fmt.Sprintf("corepy tier2: command timed out: %s", command) + } + + if stderr := firstLine(err.Result.Stderr); stderr != "" { + return fmt.Sprintf("corepy tier2: command exited with status %d: %s", err.Result.ExitCode, stderr) + } + if err.Cause != nil { + return fmt.Sprintf("corepy tier2: command exited with status %d: %v", err.Result.ExitCode, err.Cause) + } + return fmt.Sprintf("corepy tier2: command exited with status %d: %s", err.Result.ExitCode, command) +} + +func (err ExitError) Unwrap() error { + return err.Cause +} + +// NewRunner creates a Tier 2 subprocess runner. +func NewRunner(options Options) *Runner { + return &Runner{options: options} +} + +// ResolvePython locates the Python executable for Tier 2. +func ResolvePython(requested string) (string, error) { + if strings.TrimSpace(requested) != "" { + path, err := exec.LookPath(strings.TrimSpace(requested)) + if err != nil { + return "", fmt.Errorf("corepy tier2: python executable %q not found", requested) + } + return path, nil + } + if envPython := strings.TrimSpace(os.Getenv(EnvPython)); envPython != "" { + path, err := exec.LookPath(envPython) + if err != nil { + return "", fmt.Errorf("corepy tier2: python executable %q from %s not found", envPython, EnvPython) + } + return path, nil + } + + candidates := []string{"python3.14", "python3.13", "python3", "python"} + seen := map[string]struct{}{} + for _, candidate := range candidates { + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + + path, err := exec.LookPath(candidate) + if err == nil { + return path, nil + } + } + + return "", fmt.Errorf("corepy tier2: no Python executable found; set %s", EnvPython) +} + +// RunSource executes a Python source string. +func (runner *Runner) RunSource(ctx context.Context, source string, arguments ...string) (Result, error) { + command := append([]string{"-c", source}, arguments...) + return runner.run(ctx, command) +} + +// RunFile executes a Python file. +func (runner *Runner) RunFile(ctx context.Context, filename string, arguments ...string) (Result, error) { + if strings.TrimSpace(filename) == "" { + return Result{}, fmt.Errorf("corepy tier2: filename cannot be empty") + } + command := append([]string{filename}, arguments...) + return runner.run(ctx, command) +} + +func (runner *Runner) run(ctx context.Context, pythonArguments []string) (Result, error) { + python, err := ResolvePython(runner.options.Python) + if err != nil { + return Result{}, err + } + + if ctx == nil { + ctx = context.Background() + } + timeout := runner.options.Timeout + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + + commandArguments := append([]string(nil), pythonArguments...) + cmd := exec.CommandContext(ctx, python, commandArguments...) + if runner.options.WorkingDirectory != "" { + cmd.Dir = runner.options.WorkingDirectory + } + cmd.Env = runner.environment() + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = outputWriter(&stdout, runner.options.Stdout) + cmd.Stderr = outputWriter(&stderr, runner.options.Stderr) + + runErr := cmd.Run() + timedOut := errors.Is(ctx.Err(), context.DeadlineExceeded) + exitCode := 0 + if runErr != nil { + exitCode = -1 + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + exitCode = exitErr.ExitCode() + } + } + + result := Result{ + Command: append([]string{python}, commandArguments...), + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + TimedOut: timedOut, + } + if runErr != nil || timedOut { + return result, ExitError{Result: result, Timeout: timeout, Cause: runErr} + } + return result, nil +} + +func (runner *Runner) environment() []string { + env := os.Environ() + if len(runner.options.PythonPath) > 0 { + existing := os.Getenv("PYTHONPATH") + parts := append([]string(nil), runner.options.PythonPath...) + if existing != "" { + parts = append(parts, existing) + } + env = append(env, "PYTHONPATH="+strings.Join(parts, string(os.PathListSeparator))) + } + if len(runner.options.Environment) == 0 { + return env + } + + keys := make([]string, 0, len(runner.options.Environment)) + for key := range runner.options.Environment { + keys = append(keys, key) + } + sortStrings(keys) + for _, key := range keys { + env = append(env, key+"="+runner.options.Environment[key]) + } + return env +} + +func outputWriter(capture *bytes.Buffer, stream io.Writer) io.Writer { + if stream == nil { + return capture + } + return io.MultiWriter(capture, stream) +} + +func compactCommand(command []string) string { + const maxPart = 80 + parts := make([]string, 0, len(command)) + for _, part := range command { + cleaned := strings.ReplaceAll(part, "\n", `\n`) + if len(cleaned) > maxPart { + cleaned = cleaned[:maxPart] + "..." + } + if strings.ContainsAny(cleaned, " \t\n\"'") { + cleaned = strconvQuote(cleaned) + } + parts = append(parts, cleaned) + } + return strings.Join(parts, " ") +} + +func firstLine(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if index := strings.IndexByte(value, '\n'); index != -1 { + return strings.TrimSpace(value[:index]) + } + return value +} + +func sortStrings(values []string) { + for i := 1; i < len(values); i++ { + for j := i; j > 0 && values[j] < values[j-1]; j-- { + values[j], values[j-1] = values[j-1], values[j] + } + } +} + +func strconvQuote(value string) string { + return `"` + strings.ReplaceAll(strings.ReplaceAll(value, `\`, `\\`), `"`, `\"`) + `"` +} + +// LocalPythonPath returns the repository-local CPython package path when this +// command is executed from the CorePy source tree. +func LocalPythonPath(start string) (string, bool) { + if strings.TrimSpace(start) == "" { + cwd, err := os.Getwd() + if err != nil { + return "", false + } + start = cwd + } + + current, err := filepath.Abs(start) + if err != nil { + return "", false + } + for { + candidate := filepath.Join(current, "py", "core", "__init__.py") + if _, err := os.Stat(candidate); err == nil { + return filepath.Join(current, "py"), true + } + + parent := filepath.Dir(current) + if parent == current { + return "", false + } + current = parent + } +} diff --git a/runtime/tier2/runner_test.go b/runtime/tier2/runner_test.go new file mode 100644 index 0000000..85d483d --- /dev/null +++ b/runtime/tier2/runner_test.go @@ -0,0 +1,155 @@ +package tier2_test + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "dappco.re/go/py/runtime/tier2" +) + +func TestRunner_RunSource_StreamsAndCaptures_Good(t *testing.T) { + python := requirePython(t) + var stdout bytes.Buffer + var stderr bytes.Buffer + + runner := tier2.NewRunner(tier2.Options{ + Python: python, + Environment: map[string]string{ + "COREPY_TIER2_TEST": "enabled", + }, + Stdout: &stdout, + Stderr: &stderr, + }) + result, err := runner.RunSource(context.Background(), ` +import os +import sys +print("out:" + os.environ["COREPY_TIER2_TEST"]) +print("err:stream", file=sys.stderr) +`) + if err != nil { + t.Fatalf("run tier2 source: %v", err) + } + + if !result.OK() { + t.Fatalf("expected successful result, got %#v", result) + } + if strings.TrimSpace(result.Stdout) != "out:enabled" { + t.Fatalf("unexpected captured stdout %q", result.Stdout) + } + if strings.TrimSpace(result.Stderr) != "err:stream" { + t.Fatalf("unexpected captured stderr %q", result.Stderr) + } + if stdout.String() != result.Stdout { + t.Fatalf("stdout stream/capture mismatch %q != %q", stdout.String(), result.Stdout) + } + if stderr.String() != result.Stderr { + t.Fatalf("stderr stream/capture mismatch %q != %q", stderr.String(), result.Stderr) + } +} + +func TestRunner_RunFile_UsesWorkingDirectoryAndPythonPath_Good(t *testing.T) { + python := requirePython(t) + directory := t.TempDir() + packageDirectory := filepath.Join(directory, "pkg") + if err := os.MkdirAll(packageDirectory, 0755); err != nil { + t.Fatalf("create package directory: %v", err) + } + if err := os.WriteFile(filepath.Join(packageDirectory, "samplemod.py"), []byte(`VALUE = "from-pythonpath"`), 0600); err != nil { + t.Fatalf("write module: %v", err) + } + script := filepath.Join(directory, "script.py") + if err := os.WriteFile(script, []byte(` +from pathlib import Path +import samplemod +print(Path.cwd().name + ":" + samplemod.VALUE) +`), 0600); err != nil { + t.Fatalf("write script: %v", err) + } + + runner := tier2.NewRunner(tier2.Options{ + Python: python, + WorkingDirectory: directory, + PythonPath: []string{packageDirectory}, + }) + result, err := runner.RunFile(context.Background(), script) + if err != nil { + t.Fatalf("run tier2 file: %v", err) + } + if strings.TrimSpace(result.Stdout) != filepath.Base(directory)+":from-pythonpath" { + t.Fatalf("unexpected stdout %q", result.Stdout) + } +} + +func TestRunner_RunSource_NonZeroExit_Bad(t *testing.T) { + python := requirePython(t) + runner := tier2.NewRunner(tier2.Options{Python: python}) + + result, err := runner.RunSource(context.Background(), ` +import sys +print("about to fail", file=sys.stderr) +sys.exit(7) +`) + if err == nil { + t.Fatal("expected nonzero exit to fail") + } + + var exitErr tier2.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %T: %v", err, err) + } + if result.ExitCode != 7 || exitErr.Result.ExitCode != 7 { + t.Fatalf("unexpected exit result %#v / %#v", result, exitErr.Result) + } + if !strings.Contains(err.Error(), "about to fail") { + t.Fatalf("expected stderr in error, got %v", err) + } +} + +func TestRunner_RunSource_Timeout_Ugly(t *testing.T) { + python := requirePython(t) + runner := tier2.NewRunner(tier2.Options{ + Python: python, + Timeout: 100 * time.Millisecond, + }) + + result, err := runner.RunSource(context.Background(), ` +import time +time.sleep(5) +`) + if err == nil { + t.Fatal("expected timeout to fail") + } + var exitErr tier2.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %T: %v", err, err) + } + if !result.TimedOut || !exitErr.Result.TimedOut { + t.Fatalf("expected timed out result, got %#v / %#v", result, exitErr.Result) + } +} + +func TestResolvePython_Missing_Bad(t *testing.T) { + _, err := tier2.ResolvePython("definitely-not-a-corepy-python") + if err == nil { + t.Fatal("expected missing python to fail") + } + if !strings.Contains(err.Error(), "not found") { + t.Fatalf("unexpected error %v", err) + } +} + +func requirePython(t *testing.T) string { + t.Helper() + + python, err := tier2.ResolvePython("") + if err != nil { + t.Skipf("Tier 2 CPython is not available in this environment: %v", err) + } + return python +}