Metadata-Version: 2.5
Name: pycordis-sdk
Version: 1.0.1
Summary: Write Cordis plugins in Python: a bridge to the original Node Cordis runtime, with HMR
Author: Felix-Zhenghao
License-Expression: MIT
License-File: LICENSE
License-File: src/pycordis/_node/runtime/THIRD_PARTY_LICENSES.txt
Keywords: agent,cordis,deepseek-harness,hmr,hot-reload,plugin
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX
Classifier: Programming Language :: JavaScript
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Requires-Python: >=3.12
Description-Content-Type: text/markdown

# pycordis

Distribution `pycordis-sdk`, import name `pycordis`.

Write [Cordis](https://github.com/cordiverse/cordis) plugins in Python. Python plugins run inside the **original Node Cordis runtime**: every Python plugin is a real Cordis fiber with its own effects, listeners, services and disposal, and Cordis HMR hot-reloads Python modules.

```sh
uv pip install pycordis-sdk
```

```python
import asyncio, pycordis

def greeter(ctx, config):
    ctx.on('greet', lambda name: print(f"{config['greeting']}, {name}"))
    return lambda: print('greeter disposed')

async def main():
    async with pycordis.App() as app:
        await app.ctx.plugin(greeter, {'greeting': 'Hello'})
        app.ctx.emit('greet', 'Cordis')

asyncio.run(main())
```

**Requirements:** Python ≥ 3.12 and Node.js ≥ 22.15 on `PATH` (or `PYCORDIS_NODE=/path/to/node`). Linux and macOS. pycordis has no Python dependencies. The wheel bundles the Cordis runtime used by [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): `cordis` 4.1, the Loader, Include, Group, Timer, HMR and logger-console plugins, with their dependencies and license notices. To use a DeepSeek Harness checkout's built `vendor/` packages instead, pass `App(vendor_dir=...)` or set `PYCORDIS_VENDOR_DIR`.

## Model

`App` spawns a Node process that hosts one root Cordis `Context`. The two runtimes exchange newline-delimited JSON frames over a pair of pipes.

- **Synchronous, re-entrant calls.**
  - Every cross-runtime call blocks its caller until the other side answers. While waiting, each side serves the other side's nested calls.
  - This keeps Cordis's synchronous APIs exact: `emit`, `bail`, `waterfall`, accessors, `Service.check` and Standard Schema validation.
  - Node serves every request while it waits. Python serves only requests nested in its own pending call. Other Node-originated calls, such as timers or plugin activation, run once Python's event loop is idle. Python code therefore keeps JS-like run-to-completion between `await`s.
- **Async values.** A Python `async def` called from JS starts eagerly, like a JS async function, and returns a Promise. A JS Promise becomes an awaitable.
- **Reference semantics.**
  - Only immutable values are copied: `None`/`null`, bools, numbers, strings, bytes and symbols.
  - Every mutable value crosses as a live proxy of the object in its home runtime. A Python dict provided as a service, an event payload mutated by a waterfall listener, or a JS config object is therefore one shared object.
  - Sending a proxy back to its home runtime returns the original object, so `is` holds.
  - `pycordis.js.copy(value)` and `pycordis.to_py(js_value)` make explicit snapshots.
- **Threading.** Bridge calls must run on the `App` event-loop thread. From other threads, use `app.call_soon_threadsafe`.
- **Lifetime.** A proxy releases its remote object when it is garbage-collected. Reference cycles that cross the bridge are not collected.

## API

Python names mirror the Cordis API. Methods use snake_case, with camelCase aliases where the JS name differs.

| Cordis (JS) | pycordis |
| --- | --- |
| `ctx.extend/isolate/intercept`, `ctx.root`, `ctx.baseUrl` | same; also `ctx.base_url` |
| `Context.effect/filter/isolate/intercept`, `Context.is` | class attributes (symbols); `Context.is_(value)` |
| `ctx.get/set/provide/accessor/mixin` | same; `accessor(name, get=fn(ctx, receiver, error), set=fn(ctx, value, receiver, error))` |
| `ctx.foo` (service access) | `ctx.foo`, `ctx['foo']`, `ctx.foo = value`; Cordis inject rules apply |
| `ctx.effect(execute, label)` | same; `execute` may return a disposer, `None`, a coroutine, a generator or an async generator of disposers; returns a `Disposer` whose call result is always awaitable |
| `ctx.plugin/inject` | same; a plugin is a function, a class, an object with `apply`, or a module with `apply`; metadata comes from attributes (`name`, `Config`, `inject`, `provide`, `intercept`) or `@pycordis.plugin(...)` |
| `ctx.emit/parallel/serial/bail/waterfall`, `ctx.on/once` | same; `parallel` and `serial` return awaitables; options are `True` or `{'prepend': …, 'global': …}` |
| `Fiber` | `Fiber` with `FiberState` (IntEnum), `await fiber`, `fiber.await_()`, `raw_config`, `effective_config`, `resolve_config`, `update`, `restart`, and `get_effects()` returning `EffectMeta` |
| `Service` (`init`, `check`, `invoke`, `extend`, `tracker`, `resolveConfig`) | `pycordis.Service`; members `__cordis_init__`, `__cordis_check__`, `__cordis_invoke__`, …, or decorators such as `@Service.init` |
| `Config` (Standard Schema) | `pycordis.schema` builders, or JS Schemastery schemas |
| `CordisError`, `ValidationError`, `AggregateError` | `pycordis.CordisError`, `ValidationError`, `JsAggregateError` (an `ExceptionGroup`); other JS errors raise `JsError` |
| any JS module | `await app.js.import_(spec)`, where `spec` is a bundled package name, a `node:` builtin, a path or a data URL |

```python
from pycordis import Service, schema

class Counter(Service):
    Config = schema.object({'start': schema.number().default(0)})

    def __init__(self, ctx, config):
        super().__init__(ctx, 'counter')
        self.value = config['start']

    def increment(self):
        self.value += 1
        return self.value

def consumer(ctx):
    print(ctx.counter.increment())
consumer.inject = ['counter']
```

Services read through a context are *traced*, as in Cordis. Inside a method of a `Service` reached as `ctx.svc`, `self.ctx` is the caller's context, so effects the service registers belong to the caller's fiber.

Reading a missing JS property raises `AttributeError`. Use `getattr(obj, key, None)` when the property may be absent.

## Configuration files and HMR

```python
async with pycordis.App() as app:
    await app.load_config('cordis.yml')             # Cordis Loader + Include
    await app.enable_hmr(root=['.'], debounce=100)  # Cordis HMR
```

```yaml
# cordis.yml
- id: counter
  name: ./plugins/counter.py
  config: { start: 10 }
- id: consumer
  name: python:my_package.consumer     # a module on sys.path
  inject: [counter]
```

`!!js` expressions in config can read Python services.

Each Python module joins Node's module graph as a synthetic ES module. Its static imports mirror the module's local Python imports. The Cordis HMR therefore handles Python plugins unchanged:
- watching and debounce
- accept/decline analysis
- plugin swaps that keep the raw config
- rollback with a code frame on syntax or execution errors
- Include refresh
- `hmr/change` and `hmr/reload`

A changed Python module that the host imported directly, not through Loader, calls `ctx.loader.exit()`. This is the counterpart of HMR externals.

Reload semantics are those of Cordis HMR: a plugin whose provided service still has live dependents cannot be re-provided by a swap. Pure-JS plugins behave the same way.

## License

MIT. The bundled Cordis runtime and its dependencies are MIT or ISC; their notices ship in `pycordis/_node/runtime/THIRD_PARTY_LICENSES.txt`.
