diff --git a/AGENTS.md b/AGENTS.md index b26e30c..aa10701 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,16 +22,16 @@ minisky server [--reload] # REST API server minisky console # interactive console against the API ``` -The FastAPI app lives in `minisky/server.py`; `minisky server` is the CLI entry point +The FastAPI app lives in `packages/minisky/minisky/server.py`; `minisky server` is the CLI entry point (`MINISKY_HOST`/`MINISKY_PORT` env vars, default `0.0.0.0:8000`). ## Architecture Full details in `docs/architecture.md` — read it before making structural changes. The essentials: -**Runtime ownership.** [`MiniSky`][minisky.runtime.MiniSky] owns one simulator object graph: settings, simulation, traffic, runner, console, navigation, command stack, plugins, replaceables, areas, variable explorer, random generators, and streaming hub. Unlike `bluesky`, there is no package-level `traf`, `sim`, `scr`, `runner`, or `navdb`. +**Runtime ownership.** [`MiniSky`][minisky.runtime.MiniSky] owns one simulator object graph: config, simulation, traffic, runner, console, navigation, command stack, plugins, replaceables, areas, variable explorer, random generators, and streaming hub. Unlike `bluesky`, there is no package-level `traf`, `sim`, `scr`, `runner`, or `navdb`. -**Lifecycle.** Use `with MiniSky(settings)` for manually stepped synchronous work. Use `async with MiniSky(settings)` and `await runtime.run()` when running the async loop. The FastAPI lifespan owns its background runner task and awaits asynchronous cleanup. +**Lifecycle.** Use `with MiniSky()` for manually stepped synchronous work with the default user config, or pass `config=` explicitly. Use `async with MiniSky()` and `await runtime.run()` when running the async loop. The FastAPI lifespan owns its background runner task and awaits asynchronous cleanup. **Simulation loop.** `sim.step()` runs, in order: stack processing → time advance (only in `OP` state) → plugin `preupdate` → `traf.update()` (autopilot/FMS, conflict detection+resolution, performance limits, wind, position integration) → plugin `update`. States: `INIT`, `OP`, `HOLD`, `END`. Drive it either by calling `sim.step()` manually (embedding) or via `runner.run()` (wall-clock paced; `runner.speed` and `runner.forward()`). @@ -45,11 +45,11 @@ Full details in `docs/architecture.md` — read it before making structural chan ## The command stack (critical convention) -Every text command — scenario file, REST `stack/` endpoint, or console — goes through `minisky.stack`. The built-in command table is `minisky/stack/commands.py`; plugins add commands with the `@command` decorator. +Every text command — scenario file, REST `stack/` endpoint, or console — goes through `minisky.stack`. The built-in command table is `packages/minisky/minisky/stack/commands.py`; plugins add commands with the `@command` decorator. -**Stack command arguments are parsed at runtime from the parameter annotations.** `minisky/stack/argparser.py` inspects `param.annotation` when a command is registered: -- **`Annotated` aliases (preferred):** `minisky/stack/argparser.py` exports `Acid`, `Wpt`, `Alt`, `Spd`, `Vspd`, `Hdg`, `Time`, `Txt`, `String`, `OnOff`, `Lat`, `Lon` — e.g. `def selaltcmd(self, idx: int, alt: Alt, vspd: Vspd | None = None)`. These are real type hints (lint- and pyright-clean) carrying the parser key as `Annotated` metadata; unions with `None` are unwrapped. -- **Argument-spec strings in the command table** (`minisky/stack/commands.py`, e.g. `"callsign,alt,[vspd]"`) are plain data looked up in the `argparsers` dict and *override* function annotations. +**Stack command arguments are parsed at runtime from the parameter annotations.** `packages/minisky/minisky/stack/argparser.py` inspects `param.annotation` when a command is registered: +- **`Annotated` aliases (preferred):** `packages/minisky/minisky/stack/argparser.py` exports `Acid`, `Wpt`, `Alt`, `Spd`, `Vspd`, `Hdg`, `Time`, `Txt`, `String`, `OnOff`, `Lat`, `Lon` — e.g. `def selaltcmd(self, idx: int, alt: Alt, vspd: Vspd | None = None)`. These are real type hints (lint- and pyright-clean) carrying the parser key as `Annotated` metadata; unions with `None` are unwrapped. +- **Argument-spec strings in the command table** (`packages/minisky/minisky/stack/commands.py`, e.g. `"callsign,alt,[vspd]"`) are plain data looked up in the `argparsers` dict and *override* function annotations. - **Legacy DSL strings** as annotations (`alt: "alt"`) still parse, but don't write new ones — use the `Annotated` aliases so linting works. - A real `type` annotation gets wrapped in `Parser(type)` (called on the argument text — fine for `int`/`float`/`str`, wrong for `bool`; use `OnOff`). @@ -57,10 +57,10 @@ Every text command — scenario file, REST `stack/` endpoint, or console — goe `E711`/`E712`/`E721` are ignored in `pyproject.toml` because numpy overrides `==`/`is` elementwise, so `arr == None` is intentional and *not* equivalent to `arr is None`. -`minisky/traffic/asas/__init__.py` has a deliberately non-alphabetical import block wrapped in `# isort: off/on` (resolution before mvp, since MVP subclasses ConflictResolution) — don't "fix" it. +`packages/minisky/minisky/traffic/asas/__init__.py` has a deliberately non-alphabetical import block wrapped in `# isort: off/on` (resolution before mvp, since MVP subclasses ConflictResolution) — don't "fix" it. ## Conventions - Package/dependency management is **uv**. Prefix Python invocations with `uv run`. - After adding or changing a stack command, regenerate `docs/reference/commands.md` with the gen script. -- `settings.toml` holds runtime config (ASAS protected-zone margins, plugin path, `enabled_plugins`). +- MiniSky config defaults live only on `MiniSkyConfig`. The CLI optionally reads `default_user_config_toml_path()`; `--config` selects another file explicitly. `[plugins.]` tables enable and configure plugins. diff --git a/docs/api/core.md b/docs/api/core.md index 2e2a950..1c1cdd6 100644 --- a/docs/api/core.md +++ b/docs/api/core.md @@ -1,15 +1,15 @@ # `minisky.core` -Core infrastructure: settings loading and the per-aircraft array bookkeeping that keeps +Core infrastructure: configuration loading and the per-aircraft array bookkeeping that keeps all state index-aligned across the simulator. ## TrafficArrays ::: minisky.core.trafficarrays -## Settings +## Configuration -::: minisky.core.settings +::: minisky.core.config ## Variable explorer diff --git a/docs/api/minisky.md b/docs/api/minisky.md index b285586..0eacdc8 100644 --- a/docs/api/minisky.md +++ b/docs/api/minisky.md @@ -1,7 +1,7 @@ # `minisky` -The top-level package exposes the explicit runtime owner, validated settings, -default settings path, and simulation-state constants. +The top-level package exposes the explicit runtime owner, validated configuration, +default user config path helpers, and simulation-state constants. ## Runtime @@ -11,6 +11,6 @@ default settings path, and simulation-state constants. ::: minisky.SimulationState -## Settings +## Configuration -::: minisky.MiniSkySettings +::: minisky.MiniSkyConfig diff --git a/docs/api/plugin.md b/docs/api/plugin.md index a727f46..b2e8ef5 100644 --- a/docs/api/plugin.md +++ b/docs/api/plugin.md @@ -1,26 +1,34 @@ # `minisky.plugin` -Runtime-owned plugin discovery, loading, timed hooks, per-aircraft entities, -and command declarations. See the [plugin guide](../guides/plugins.md). +Runtime-local plugin declarations, loading, hooks, entities, replacements, and +lifespan capabilities. See the [plugin guide](../guides/plugins.md). -## Plugin management +## Declarations -::: minisky.plugin.plugin.PluginManager +::: minisky.plugin.plugin.Plugin -## Plugin records +::: minisky.plugin.plugin.PluginContext -::: minisky.plugin.plugin.Plugin +::: minisky.plugin.plugin.PluginSpec -## Entity +## Runtime capabilities -::: minisky.plugin.entity.Entity +::: minisky.plugin.plugin.PluginRuntime + +::: minisky.plugin.plugin.PluginStatus -## Timed hooks +## Management -::: minisky.plugin.timedfunction.TimedFunctionManager +::: minisky.plugin.plugin.PluginManager + +## Entity -::: minisky.plugin.timedfunction.Timer +::: minisky.plugin.entity.Entity -## Stack command declarations +## Decorators ::: minisky.plugin.plugin_decorators.command + +::: minisky.plugin.plugin_decorators.hook + +::: minisky.plugin.plugin_decorators.replacement diff --git a/docs/architecture.md b/docs/architecture.md index fed246e..7fc7340 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,7 +1,7 @@ # Architecture MiniSky keeps BlueSky's core simulation model but removes the GUI, networking, -and node management. The mutabl state is owned by one [`MiniSky`][minisky.MiniSky] runtime. +and node management. The mutabl state is owned by a [`MiniSky`][minisky.MiniSky] runtime. ## Runtime ownership @@ -15,18 +15,24 @@ Constructing `MiniSky` creates an independent object graph: | [`runtime.console`][minisky.simulation.console.ConsoleIO] | [`ConsoleIO`][minisky.simulation.console.ConsoleIO] | Buffered text output | | [`runtime.navigation`][minisky.tools.navdata.Navdatabase] | [`Navdatabase`][minisky.tools.navdata.Navdatabase] | Waypoints, airports, and airways | | [`runtime.commands`][minisky.stack.CommandStack] | [`CommandStack`][minisky.stack.CommandStack] | Command registry, queue, and scenario state | -| [`runtime.plugins`][minisky.plugin.plugin.PluginManager] | [`PluginManager`][minisky.plugin.plugin.PluginManager] | Plugin records, hooks, timers, and state | +| [`runtime.plugins`][minisky.plugin.plugin.PluginManager] | [`PluginManager`][minisky.plugin.plugin.PluginManager] | Plugin declarations, hooks, and lifespans | | `runtime.areas` | [`AreaFilter`][minisky.tools.areafilter.AreaFilter] | Named geographic areas | | `runtime.variables` | [`VariableExplorer`][minisky.core.varexplorer.VariableExplorer] | Runtime data inspection | | `runtime.streaming` | `StreamHub` | Rate-capped snapshot fan-out | ```python -from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings +import asyncio -settings = MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE) -with MiniSky(settings) as runtime: - runtime.load_plugins() - runtime.simulation.step() +from minisky import MiniSky + + +async def main() -> None: + async with MiniSky() as runtime: + await runtime.plugins.load_configured() + runtime.simulation.step() + + +asyncio.run(main()) ``` ## The simulation loop @@ -39,11 +45,11 @@ call to [`Simulation.step`][minisky.simulation.simulation.Simulation.step] does, ([`CommandStack.process`][minisky.stack.CommandStack.process]). 3. [`runtime.simulation.simt`][minisky.simulation.simulation.Simulation] and the simulated UTC clock move forward by `simdt` (only in the `OP` state). -4. Timed plugin functions registered with the `preupdate` hook. +4. Plugin callbacks registered for the `preupdate` phase. 5. [`Traffic.update`][minisky.traffic.traffic.Traffic.update] integrates aircraft state: autopilot/FMS logic, conflict detection and resolution, aircraft performance limits, wind, and finally position integration. -6. Timed plugin functions registered with the `update` hook. +6. Plugin callbacks registered for the `update` phase. 7. The runtime-owned hub publishes when subscribers are present. The simulation state machine uses [`SimulationState`][minisky.simulation.simulation.SimulationState]: @@ -75,8 +81,8 @@ Classes that hold per-aircraft data derive from it and register their arrays: ```python class Example(Entity): - def __init__(self, traffic): - super().__init__(traffic) + def __init__(self): + super().__init__() with self.settrafarrays(): self.npassengers = np.array([]) ``` @@ -97,7 +103,7 @@ or the console — goes through the same interpreter: [`minisky.stack`](api/stac [`minisky.stack.argparser`](api/stack.md#argument-parsing), which knows aviation types (`alt` accepts `FL100`, ft, or m; `spd` accepts CAS knots or Mach; `latlon` resolves navaid names to coordinates). -- The built-in command table lives in `minisky/stack/commands.py`; plugins add commands +- The built-in command table lives in `packages/minisky/minisky/stack/commands.py`; plugins add commands with the [`@command`][minisky.plugin.plugin_decorators.command] decorator. - Scenario files (`.scn`) are simply time-stamped stack commands; `IC filename` loads one. @@ -112,7 +118,7 @@ subsystems that act on it each timestep: LNAV/VNAV logic following a [`Route`][minisky.traffic.route.Route] of waypoints. - **Conflict detection** (`traffic/asas/detection.py`) — pairwise state-based detection within a lookahead time against a protected zone (default 5 NM / 1000 ft, configurable - in `settings.toml`). Candidate pairs are pre-selected with a KD-tree on projected + in the [config file](guides/configuration.md)). Candidate pairs are pre-selected with a KD-tree on projected positions plus a vertical reachability filter, so cost scales with local traffic density rather than N². - **Conflict resolution** (`traffic/asas/mvp.py`) — Modified Voltage Potential resolution diff --git a/docs/getting-started.md b/docs/getting-started.md index 39bed88..c1eb75f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -15,6 +15,12 @@ cd minisky uv sync ``` +## Configuration + +MiniSky runs with the defaults defined by [`MiniSkyConfig`][minisky.MiniSkyConfig]. You only need a `config.toml` when you want to override a value or load a plugin automatically. + +See [Configuration](guides/configuration.md) for the platform-specific default path and `--config` overrides. + ## Your first simulation Run one of the bundled scenarios to completion: @@ -60,10 +66,9 @@ See the [command-line interface](guides/cli.md), [REST API](guides/rest-api.md), ## From Python ```python -from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings +from minisky import MiniSky -settings = MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE) -with MiniSky(settings) as runtime: +with MiniSky() as runtime: runtime.simulation.reset() runtime.traffic.cre( "KL315", lat=52.0, lon=4.0, hdg=45, alt=5000, spd=250 @@ -83,22 +88,6 @@ with MiniSky(settings) as runtime: See the [Python library guide](guides/python-api.md) for details on runtime ownership and stepping the simulation yourself. -## Configuration - -Runtime settings live in `settings.toml` at the repository root, e.g. conflict-detection -lookahead time and protected-zone sizes, plus the current plugin search directory and startup list: - -```toml -asas_dtlookahead = 300 -asas_pzr = 5 -asas_pzh = 1000 -asas_marh = 1.05 -asas_marv = 1.05 - -plugin_path = "example_plugins" -enabled_plugins = [] -``` - ## Running the tests ```bash diff --git a/docs/guides/cli.md b/docs/guides/cli.md index ee3f071..cfa514f 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -1,6 +1,6 @@ # Command-line interface -MiniSky installs one top-level command, `minisky`, with subcommands for running +MiniSky installs a top-level command, `minisky`, with subcommands for running scenarios, serving the API, using the console, and streaming snapshots. ```bash @@ -11,18 +11,11 @@ uv run minisky --help | Command | Purpose | | --- | --- | -| `minisky run --scenario FILE [--speed N]` | Run a scenario file without interaction. | -| `minisky server [--host HOST] [--port PORT] [--reload]` | Start the REST and WebSocket API server. | +| `minisky run --scenario FILE [--speed N] [--config FILE]` | Run a scenario file without interaction. | +| `minisky server [--host HOST] [--port PORT] [--reload] [--config FILE]` | Start the REST and WebSocket API server. | | `minisky console [--server URL] [--port PORT]` | Open an interactive console against a running server. | | `minisky stream [--url URL] [--raw]` | Print snapshots from the `/stream` WebSocket. | -## Developer commands +## Config file -| Command | Purpose | -| --- | --- | -| `just check` | Run repository linting and type checks. | -| `just test` | Run the default test suite. | -| `just test-unit` | Run fast unit tests. | -| `just test-api` | Run opt-in REST API tests. | -| `just docs-serve` | Serve this documentation site locally. | -| `just docs-build` | Build the documentation site into `site/`. | +`run` and `server` use built-in defaults unless the platform-specific [default config file](configuration.md) exists. Pass `--config FILE` to choose another file explicitly. diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md new file mode 100644 index 0000000..7039004 --- /dev/null +++ b/docs/guides/configuration.md @@ -0,0 +1,77 @@ +# Configuration + +MiniSky comes with reasonable runtime defaults, so you do not need a config file to start a simulation. To learn more about the shape and defaults, read the [`MiniSkyConfig` API][minisky.MiniSkyConfig] reference. + +Create a config file only when you want to override those values or load plugins automatically. + +## Default location + +The command-line tools look for an optional `config.toml` in MiniSky's platform-specific user config directory: + +| System | Default path | +| --- | --- | +| Linux | `$XDG_CONFIG_HOME/minisky/config.toml`, or `~/.config/minisky/config.toml` when `XDG_CONFIG_HOME` is not set | +| macOS | `$XDG_CONFIG_HOME/minisky/config.toml`, or `~/Library/Application Support/minisky/config.toml` when `XDG_CONFIG_HOME` is not set | +| Windows | `%LOCALAPPDATA%\minisky\config.toml` | + +Ask the installed package for the exact path on your machine: + +```bash +uv run python -c "from minisky import default_user_config_toml_path; print(default_user_config_toml_path())" +``` + +`default_user_config_dir()` and `default_user_config_toml_path()` describe the CLI convention. They return defaults, not mandatory locations. + +## Create your config + +Create the directory and an empty file when you are ready to customise MiniSky: + +```bash +config_path="$(uv run python -c 'from minisky import default_user_config_toml_path; print(default_user_config_toml_path())')" +mkdir -p "$(dirname "$config_path")" +touch "$config_path" +``` + +## Choose another file + +Pass `--config` when a command should use a different TOML file: + +```bash +uv run minisky run --scenario scenarios/kl204.scn --config ./experiment.toml +uv run minisky server --config ./server.toml +``` + +## Use config from Python + +```python +from minisky import MiniSky, MiniSkyConfig + +# try to load from default user path, and if it doesn't exist use defauls +with MiniSky() as runtime: + ... + +# or explicitly pass a config +with MiniSky(config=MiniSkyConfig.from_path("experiment.toml")) as runtime: + ... + +# forcefully use built-in defaults +with MiniSky(config=MiniSkyConfig()) as runtime: + ... +``` + +## Configure plugins + +A table under `[plugins.]` supplies that plugin's config and asks `load_configured()` to load it during startup: + +```toml +[plugins.example] +interval = 2.0 +``` + +Remove the table when you do not want that plugin loaded. An empty table is enough for a plugin that uses only its own defaults: + +```toml +[plugins.example] +``` + +Plugin config is validated by the plugin's `config_class`. diff --git a/docs/guides/plugins.md b/docs/guides/plugins.md index 31dab5c..bd51d5b 100644 --- a/docs/guides/plugins.md +++ b/docs/guides/plugins.md @@ -1,157 +1,161 @@ # Writing plugins -Plugins extend a [`MiniSky`][minisky.MiniSky] runtime without modifying -core code. A plugin can own per-aircraft data, register timed lifecycle hooks, -and add stack commands. The `example_plugins/` directory contains working -examples. +You should use a plugin when you want to add commands, simulation hooks, per-aircraft data, external services, or a replaceable traffic component without changing MiniSky itself. -## Anatomy of a plugin +The example packages under `packages/minisky-example*` and `packages/minisky-tangram` are complete plugins you can use as starting points. -A plugin is a Python file in the directory configured by `plugin_path` that -defines `init_plugin(runtime)`: +## Create a plugin package + +Your package should expose a [`Plugin`][minisky.plugin.Plugin] value through the `minisky.plugins` entry-point group: + +```toml +--8<-- "packages/minisky-example/pyproject.toml:entry-point" +``` + +The entry-point name is the plugin ID. Use lowercase letters, digits, and underscores, such as `example`, `custom_autopilot`, or `tangram`. + +## Build your plugin + +A build function creates fresh components for a MiniSky runtime. Mount the components you want MiniSky to manage, finish the context, and export the resulting plugin declaration: + +```python +--8<-- "packages/minisky-example/src/minisky_example/__init__.py:declaration" +``` + +!!! important + Create fresh component instances in every build. Do not reuse components between runtimes or load attempts. + +By default, the first mounted component is also available through MiniSky's variable explorer. Pass `expose=False` when you mount additional internal components that should not be exposed. + +## Accept configuration + +When your plugin accepts config, define a Pydantic-compatible model and pass it as `config_class`. You can then read the validated config from `context.config`: ```python -"""My example plugin.""" - -from typing import TYPE_CHECKING - -import numpy as np - -from minisky import plugin - -if TYPE_CHECKING: - from minisky import MiniSky - from minisky.traffic import Traffic - - -def init_plugin(runtime: MiniSky): - instance = Example(runtime.traffic) - config = { - "plugin_name": "EXAMPLE", - "update_interval": 5, - "update": instance.update, - "state": instance, - } - commands = { - "PASSENGERS": [ - instance.passengers, - "txt,[int]", - "PASSENGERS callsign, [count]", - "Set or get the number of passengers on an aircraft.", - ] - } - return config, commands +--8<-- "packages/minisky-tangram/src/minisky_tangram/__init__.py:configuration" +``` + +Users place the config under the plugin ID in their [MiniSky config file](configuration.md): + +```toml +[plugins.example] +interval = 2.0 ``` -The runtime is passed explicitly. Plugin code should retain only the specific -runtime components it needs instead of reading package-level aliases. +A table under `[plugins.]` both configures the plugin and loads it during normal startup. An empty table is enough for a plugin that uses only default values. + +## Add per-aircraft data + +Derive from [`Entity`][minisky.plugin.Entity] when your plugin needs an array or list with an entry for every aircraft. Register those values inside `settrafarrays()` so MiniSky keeps them aligned when aircraft are created, deleted, or reset. -The config dictionary supports these lifecycle entries: +The example plugin also shows a command and a periodic hook on the same component: + +```python +--8<-- "packages/minisky-example/src/minisky_example/__init__.py:entity" +``` -| Key | Meaning | -| --- | --- | -| `plugin_name` | Name used by `PLUGINS LOAD` and `enabled_plugins` | -| `update_interval` | Simulation seconds between timed callbacks | -| `preupdate` | Callback before the traffic update | -| `update` | Callback after the traffic update | -| `reset` | Callback when the simulation resets | -| `hold` | Callback when the simulation enters hold | -| `shutdown` | Callback when the owning runtime shuts down | -| `state` | Optional plugin-owned object exposed through the variable explorer | +You can use `self.traffic` after the plugin has loaded. Do not use it in `__init__`; the entity is still detached while the plugin is being built. -Plugin records, timers, hooks, and returned state belong to -[`runtime.plugins`][minisky.plugin.plugin.PluginManager]. Each `init_plugin` call -must create runtime-specific state; imported Python modules and class declarations -are still process-wide. +## Add commands -## Per-aircraft data: `Entity` +Use [`@plugin.command`][minisky.plugin.plugin_decorators.command] on an instance method. The command name defaults to the method name in uppercase, its usage is derived from the signature, and its help text comes from the docstring. -Derive from [`Entity`][minisky.plugin.entity.Entity], pass the owning traffic -object to `super().__init__()`, and register arrays inside a -`settrafarrays()` block. They then grow, shrink, and reset with that traffic -tree. +Use the `arguments` option when MiniSky's stack parser needs more information than the Python annotations provide: ```python -class Example(plugin.Entity): - def __init__(self, traffic: Traffic) -> None: - super().__init__(traffic) - with self.settrafarrays(): - self.npassengers = np.array([]) - - def create(self, n: int = 1) -> None: - super().create(n) - self.npassengers[-n:] = 100 - - def update(self) -> None: - if self.traffic.ntraf: - print(f"{self.traffic.ntraf} aircraft") +@plugin_api.command(arguments="txt,[int]") +def passengers(self, callsign: str, count: int = -1) -> tuple[bool, str]: + """Set or get the passenger count for an aircraft.""" + ... ``` -`Entity` is not a singleton and does not use a proxy. Each plugin load creates -an ordinary object attached to one runtime's traffic-array tree. +A command handler can return `(success, message)`. Returning `None` means the command completed successfully without a message. -## Adding stack commands +## Add simulation hooks -Return a command dictionary as the second value from `init_plugin()`. Binding a -method from the plugin-owned state object keeps the command attached to the -correct runtime: +Use [`@plugin.hook`][minisky.plugin.plugin_decorators.hook] for work tied to the simulation cycle: ```python -class Example(plugin.Entity): - # ... +class Component: + @plugin_api.hook("preupdate") + def before_traffic(self) -> None: + ... + + @plugin_api.hook("update", interval=2.0) + def every_two_seconds(self, dt: float) -> None: + ... +``` + +Available phases are `preupdate`, `update`, `reset`, and `hold`. Intervals use simulated seconds. A hook must be synchronous; if it raises an exception, MiniSky disables that hook and continues running the others. - def passengers(self, callsign: str, count: int = -1) -> tuple[bool, str]: - callsign = callsign.upper() - if callsign not in self.traffic.callsign: - return False, f"Aircraft {callsign} not found" +## Own threads, tasks, and subscriptions - index = self.traffic.callsign.index(callsign) - if count < 0: - return True, f"{callsign} has {int(self.npassengers[index])} passengers" +Use an async lifespan when your plugin opens files, starts tasks or threads, connects to a service, or subscribes to console output. - self.npassengers[index] = count - return True, f"Set {callsign} passengers to {count}" +Tangram uses its lifespan to start and stop the Redis bridge: + +```python +--8<-- "packages/minisky-tangram/src/minisky_tangram/__init__.py:lifespan" ``` -A command entry contains the callback, argument parser specification, brief -usage text, and help text. Command handlers return `(success, message)`; -returning `None` counts as success with no message. +The lifespan receives a [`PluginRuntime`][minisky.plugin.PluginRuntime] with the small set of runtime operations intended for plugins: read status or a snapshot, write to the console, subscribe to console messages, and submit a stack command after startup completes. + +Put cleanup in the lifespan's `finally` block so it runs when the MiniSky runtime closes. You do not need to close console subscriptions separately when they are created through `PluginRuntime`; MiniSky owns them and closes them during teardown. + +!!! note + Lifespan startup happens before your commands, hooks, entities, and replacements are available. Start external resources there, but do not depend on your own registrations or submit stack commands until startup completes. + +## Add a replaceable implementation + +Use [`@plugin.replacement`][minisky.plugin.plugin_decorators.replacement] on a supported traffic component subclass, then include it in `context.finish()`: + +```python +--8<-- "packages/minisky-example-customautopilot/src/minisky_example_customautopilot/__init__.py:replacement" +``` -The [`@plugin.command`][minisky.plugin.plugin_decorators.command] decorator is -also available for stateless module-level declarations. Importing a decorated -function only stores metadata. The command is registered when the owning -runtime loads that plugin module. +After the plugin loads, select the implementation with the stack: -## Discovery and loading +```text +SELECTIMPL AUTOPILOT CUSTOMAUTOPILOT +``` -Discovery parses plugin source without importing it. `MiniSky` performs this -discovery during construction. +You can also select it from Python with `runtime.replaceables.select(...)`. Keep replacement construction synchronous and use the plugin lifespan for external resources. -Load plugins in any of these ways: +## Load and manage plugins -- At startup: list names under `enabled_plugins`, then call `runtime.load_plugins()`. -- From the stack: use `PLUGINS LIST` and `PLUGINS LOAD EXAMPLE`. -- From Python: call [`runtime.plugins.load("EXAMPLE")`][minisky.plugin.plugin.PluginManager.load]. -- REST API: use `GET /plugins` and `GET /plugins/load/EXAMPLE`. +To attempt every plugin configured under `[plugins.]`, call the plugin manager before you start the runner. A failed plugin is reported to the console without preventing later configured plugins from loading: ```python -from minisky import MiniSky, MiniSkySettings +from minisky import MiniSky, MiniSkyConfig + +config = MiniSkyConfig.from_path("experiment.toml") +async with MiniSky(config=config) as runtime: + loaded = await runtime.plugins.load_configured() + print(f"Loaded: {', '.join(loaded)}") + await runtime.run() +``` -settings = MiniSkySettings.from_file("settings.toml") -with MiniSky(settings) as runtime: - runtime.load_plugins() +To load only one installed plugin, call `load()` with its plugin ID: + +```python +ok, message = await runtime.plugins.load("example") +print(message) ``` -## Replaceable implementations +Plugin IDs are case-insensitive. `load()` returns a success flag and a message, so you can decide how your application should handle a missing, invalid, or already loaded plugin. -A plugin can declare a subclass of a replaceable traffic component, such as -[`Autopilot`][minisky.traffic.autopilot.Autopilot]. Python tracks the subclass declaration process-wide, while the selected instance -belongs to each runtime: +To inspect the plugins known to the runtime, use `listing()`: ```python -runtime.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT") +ok, text = runtime.plugins.listing() +print(text) +``` + +While the simulator is running, you can manage plugins through the stack instead: + +```text +PLUGINS LIST +PLUGINS LOAD EXAMPLE ``` -The `SELECTIMPL` stack command calls the same runtime-owned manager. Resetting a -simulation restores base implementations only on that runtime's traffic tree. -See `example_plugins/customautopilot.py` for a complete example. +The REST API provides the same operations through `GET /plugins` and `GET /plugins/load/`. diff --git a/docs/guides/python-api.md b/docs/guides/python-api.md index 7fbbd50..26ecb3d 100644 --- a/docs/guides/python-api.md +++ b/docs/guides/python-api.md @@ -1,15 +1,14 @@ # Python library -MiniSky can be embedded in your own Python code. Construct one explicit runtime, +MiniSky can be embedded in your own Python code. Construct an explicit runtime, step its simulation, and read aircraft state directly from NumPy arrays. ## Minimal example ```python -from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings +from minisky import MiniSky -settings = MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE) -with MiniSky(settings) as runtime: +with MiniSky() as runtime: runtime.simulation.reset() runtime.traffic.cre( "KL315", lat=52.0, lon=4.0, hdg=45, alt=5000, spd=250 @@ -25,6 +24,21 @@ with MiniSky(settings) as runtime: ) ``` + +## Loading a config file + +Python applications choose their own config path rather than relying on the CLI convention: + +```python +from minisky import MiniSkyConfig + +config = MiniSkyConfig.from_path("experiment.toml") +with MiniSky(config=config) as runtime: + runtime.simulation.step() +``` + +See [Configuration](configuration.md) for the default user config location used by `minisky run` and `minisky server`. + ## Runtime ownership A [`MiniSky`][minisky.MiniSky] instance owns all mutable simulator state: @@ -36,20 +50,20 @@ runtime.runner # optional async real-time loop runtime.console # buffered text output runtime.navigation # waypoints, airports, and airways runtime.commands # command registry, queue, and scenario state -runtime.plugins # plugin records, hooks, timers, and state +runtime.plugins # plugin declarations, hooks, and lifespans runtime.streaming # per-runtime snapshot fan-out ``` Pass a scenario to the constructor to queue it immediately: ```python -with MiniSky(settings, scenario="scenarios/kl204.scn") as runtime: +with MiniSky(scenario="scenarios/kl204.scn") as runtime: runtime.simulation.step() ``` -Use `with MiniSky(...)` for manually stepped synchronous work. Use -`async with MiniSky(...)` when running `await runtime.run()` so asynchronous -runner cleanup is awaited. +Use `with MiniSky(...)` for manually stepped synchronous work without active plugin lifespans. Use `async with MiniSky(...)` when you load plugins or run asynchronously so plugin lifespans are closed correctly. + +`await runtime.run()` runs in the current task. If your application creates a background task for it, your application owns that task and must cancel or await it before closing the runtime. ## Creating and commanding aircraft @@ -130,8 +144,8 @@ To run a scenario with scaled wall-clock pacing: import asyncio async def main() -> None: - async with MiniSky(settings, scenario="scenarios/kl204.scn") as runtime: - runtime.load_plugins() + async with MiniSky(scenario="scenarios/kl204.scn") as runtime: + await runtime.plugins.load_configured() runtime.runner.speed = 10 await runtime.run() diff --git a/docs/guides/rest-api.md b/docs/guides/rest-api.md index ca03cf9..d92354d 100644 --- a/docs/guides/rest-api.md +++ b/docs/guides/rest-api.md @@ -12,7 +12,7 @@ uv run minisky server # production-style local server uv run minisky server --reload # development mode with auto-reload ``` -FastAPI serves interactive OpenAPI docs at `http://localhost:8000/docs`. +FastAPI serves interactive OpenAPI docs at `http://localhost:8000/docs`. The server uses built-in defaults unless the [default config file](configuration.md) exists; pass `--config FILE` to choose another file explicitly. ## Endpoints @@ -80,7 +80,7 @@ The `/scn` POST endpoint accepts a multipart file upload and feeds it to the sta it were loaded with `IC`: ```bash -curl -F "file=@scenarios/kl204.scn" http://localhost:8000/scn +curl -F "file=@packages/minisky/scenarios/kl204.scn" http://localhost:8000/scn ``` This lets you run the server remotely and push local scenario files to it — the diff --git a/docs/guides/running-scenarios.md b/docs/guides/running-scenarios.md index 838ba40..696fb56 100644 --- a/docs/guides/running-scenarios.md +++ b/docs/guides/running-scenarios.md @@ -12,9 +12,9 @@ uv run minisky run --scenario scenarios/kl204.scn --speed 10 | --- | --- | --- | | `--scenario` | (required) | Scenario file to load | | `--speed` | `1` | Simulation speed multiplier relative to wall time | +| `--config` | not set | Explicit TOML config override | -The script initialises the simulator with the scenario, loads any plugins enabled in -`settings.toml`, and runs the [`Runner`][minisky.simulation.runner.Runner] loop until the +The script initialises the simulator with the scenario, applies the optional [config](configuration.md), loads configured plugins, and runs the [`Runner`][minisky.simulation.runner.Runner] loop until the scenario ends the simulation. ## Scenario files @@ -50,7 +50,7 @@ From the console or REST API, load a scenario with the `IC` stack command (`IC scenarios/kl204.scn`), or POST a local file to the running server: ```bash -curl -F "file=@scenarios/kl204.scn" http://localhost:8000/scn +curl -F "file=@packages/minisky/scenarios/kl204.scn" http://localhost:8000/scn ``` The console's `/load path/to/file.scn` command does this POST for you — handy because the diff --git a/docs/guides/tangram.md b/docs/guides/tangram.md index 7493922..510ed3d 100644 --- a/docs/guides/tangram.md +++ b/docs/guides/tangram.md @@ -8,7 +8,7 @@ commands) from a tangram sidebar widget. Nothing is added to the tangram source tree. Tangram discovers plugins through Python entry points, so its side of the integration is a package you -`pip install` into whatever environment runs `tangram serve`, plus one line +`pip install` into whatever environment runs `tangram serve`, plus a line of configuration. Both halves of the integration live in this repository: ``` @@ -18,13 +18,13 @@ minisky process (TANGRAM plugin) tangram process listens on from:minisky:command ◀─ Redis ◀────────────────────── ┘ ``` -- **`example_plugins/tangram.py`** (the `TANGRAM` MiniSky plugin) owns all the +- **`packages/minisky-tangram/src/minisky_tangram/__init__.py`** (the `TANGRAM` MiniSky plugin) owns all the logic: it converts each simulation snapshot to aviation units, publishes it to Redis, relays console output, and executes stack commands pushed from the browser. MiniSky talks to tangram *only* through Redis pub/sub — tangram's transport convention (`to::` / `from::`) that has stayed stable across its plugin API changes. -- **`example_plugins/tangram/tangram_minisky/`** is a separately packaged, thin +- **`packages/tangram-minisky/`** is a separately packaged, thin tangram frontend plugin: it registers a `minisky_aircraft` entity type, a deck.gl layer, trail rendering via tangram's shared trajectory store, and the control widget. No business logic lives there, so it is cheap to rewrite @@ -56,16 +56,13 @@ just sync ``` -In `settings.toml`: +Create the optional [MiniSky config file](configuration.md), then add this plugin table: ```toml -enabled_plugins = ["TANGRAM"] - -# [tangram] is optional; uncomment to override the defaults shown here. -# [tangram] -# redis_url = "redis://127.0.0.1:6379" -# channel = "minisky" -# max_hz = 5 +[plugins.tangram] +redis_url = "redis://127.0.0.1:6379" +channel = "minisky" +max_hz = 5 ``` Start MiniSky (any front — the bridge works the same in all of them): @@ -91,7 +88,7 @@ redis-cli publish "from:minisky:command" '{"command": "HOLD"}' redis-cli publish "from:minisky:command" '{"command": "OP"}' ``` -Expect `to:minisky:new-data` snapshots (~`[tangram].max_hz`/s while running, +Expect `to:minisky:new-data` snapshots (~`plugins.tangram.max_hz`/s while running, 1/s heartbeat otherwise) reacting to the commands, plus `to:minisky:console` lines. If this works, the simulator side is done; everything after this point is tangram-side only. @@ -104,7 +101,7 @@ There are two options: ```bash uv tool install tangram_core \ - --with ./example_plugins/tangram/tangram_minisky \ + --with ./packages/tangram-minisky \ --force tangram serve --config /path/to/tangram.toml ``` @@ -134,7 +131,7 @@ requires-python = ">=3.11" dependencies = ["tangram-core", "tangram-minisky"] [tool.uv.sources] -tangram-minisky = { path = "../minisky/example_plugins/tangram/tangram_minisky", editable = true } +tangram-minisky = { path = "../minisky/packages/tangram-minisky", editable = true } ``` Run it with: @@ -159,7 +156,7 @@ dependencies = ["tangram-core", "tangram-minisky"] [tool.uv.sources] tangram-core = { path = "../tangram/packages/tangram_core", editable = true } -tangram-minisky = { path = "../minisky/example_plugins/tangram/tangram_minisky", editable = true } +tangram-minisky = { path = "../minisky/packages/tangram-minisky", editable = true } ``` In MiniSky's root `pnpm-workspace.yaml`, temporarily add: @@ -198,8 +195,8 @@ Work upstream-to-downstream: tangram container) is squatting ports 2346/2347: `lsof -i :2346 -i :2347`. 3. **Channel joins succeed but the widget says "Simulator offline"** — no snapshot or heartbeat arrived for 5 seconds. Almost always a Redis URL - mismatch: `tangram.toml`'s `redis_url` and `settings.toml`'s - `[tangram].redis_url` must point at the *same* Redis instance (mind + mismatch: `tangram.toml`'s `redis_url` and the MiniSky user config file's + `plugins.tangram.redis_url` must point at the *same* Redis instance (mind host-vs-container addressing: a dockerised tangram reaches a compose Redis at `redis://redis:6379`, a host process at `redis://127.0.0.1:6379`). A channel-name mismatch between the two sides has the same symptom. @@ -216,14 +213,14 @@ happens in the MiniSky plugin, keeping `minisky.streaming` consumer-agnostic. groundspeed, tas, ias, vertical_rate, track, inconf, timestamp}], "count": n, "siminfo": {simt, simdt, simutc, speed, ntraf, state, state_name, scenname, nconf_cur, nlos_cur}}`. - Published on every simulation step (wall-clock capped at `[tangram].max_hz`). + Published on every simulation step (wall-clock capped at `plugins.tangram.max_hz`). Whenever the simulation is not advancing — including a freshly started simulator with no scenario — a heartbeat with refreshed `siminfo` (and the last aircraft list) is republished every second, so the frontend always sees the simulator and its state changes. - `to::console`: `{"lines": [...]}` — everything echoed to the MiniSky console (the bridge tees the console, it does not consume it). -- `from::command`: `{"command": "..."}` — one stack command, +- `from::command`: `{"command": "..."}` — a stack command, executed on the next simulation step (works while paused, so `OP` can un-pause). Bare strings are also accepted for redis-cli convenience. diff --git a/docs/index.md b/docs/index.md index 757c4fc..f7ceaba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,10 +29,9 @@ reach a bare-minimum simulator that is easy to read, embed, and extend. 3. Import `minisky` and step the simulation from your own code. ```python - from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings + from minisky import MiniSky - settings = MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE) - with MiniSky(settings) as runtime: + with MiniSky() as runtime: runtime.traffic.cre( "KL315", lat=52.0, lon=4.0, hdg=45, alt=5000, spd=250 ) @@ -50,10 +49,11 @@ reach a bare-minimum simulator that is easy to read, embed, and extend. | Command stack | [`minisky.stack`](api/stack.md) | Text-command interpreter shared by scenario files, the console, and the REST API | | Plugins | [`minisky.plugin`](api/plugin.md) | Discover and load user plugins with per-aircraft data and stack commands | | Tools | [`minisky.tools`](api/tools.md) | Aeronautics conversions (ISA atmosphere, CAS/TAS/Mach) and geodesy | -| Core | [`minisky.core`](api/core.md) | Settings, per-aircraft array bookkeeping (`TrafficArrays`) | +| Core | [`minisky.core`](api/core.md) | Configuration and per-aircraft array bookkeeping (`TrafficArrays`) | ## Where to start 1. [Getting started](getting-started.md) — install and run your first simulation. -2. [Architecture](architecture.md) — how the pieces fit together. -3. [Stack commands](reference/commands.md) — every command the simulator understands. +2. [Configuration](guides/configuration.md) — override defaults or enable plugins when needed. +3. [Architecture](architecture.md) — how the pieces fit together. +4. [Stack commands](reference/commands.md) — every command the simulator understands. diff --git a/docs/macros.py b/docs/macros.py index a998d74..1b69e4b 100644 --- a/docs/macros.py +++ b/docs/macros.py @@ -1,9 +1,9 @@ -from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings +from minisky import MiniSky, MiniSkyConfig def command_docs() -> str: - settings = MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE) - with MiniSky(settings) as runtime: + config = MiniSkyConfig() + with MiniSky(config) as runtime: primary = {} synonyms: dict[str, list[str]] = {} for name, command in sorted(runtime.commands.cmddict.items()): diff --git a/docs/upstream.md b/docs/upstream.md index e1fa024..2741fe4 100644 --- a/docs/upstream.md +++ b/docs/upstream.md @@ -8,7 +8,7 @@ every time someone diffs against upstream. When evaluating a new upstream change, check here first. If a rejected change becomes relevant later (e.g. upstream lands a follow-up with actual new -behaviour), add a new entry rather than editing the old one. +behaviour), add a new entry rather than editing the existing entry. ## Rejected @@ -25,7 +25,7 @@ updated after `cr.update()`. Motivation is research extensibility (a - Zero behavioural change — MiniSky already has the identical past-CPA algorithm in `ConflictResolution.resumenav()` - (`minisky/traffic/asas/resolution.py`), with slightly more robust variable + (`packages/minisky/minisky/traffic/asas/resolution.py`), with slightly more robust variable initialisation than the upstream version. - The seam depends on the replaceable-`Entity` registry (`select()`/`selected()`/`derived()`), machinery MiniSky deliberately diff --git a/example_plugins/customautopilot.py b/example_plugins/customautopilot.py deleted file mode 100644 index 0bdd666..0000000 --- a/example_plugins/customautopilot.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Custom Autopilot Plugin - Example of the Replaceable Pattern. - -This plugin demonstrates how to create a custom autopilot by subclassing -the base Autopilot class. MiniSky's replaceable pattern allows you to -swap implementations at runtime without modifying core code. - -How it works: -1. Subclass a TrafficArrays-derived class (e.g., Autopilot, PerfBase) -2. Your subclass is automatically registered by name (uppercase class name) -3. Select your implementation via scenario command or programmatically: - - Scenario: SELECTIMPL AUTOPILOT CUSTOMAUTOPILOT - - Python: runtime.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT") -4. On simulation reset, implementations revert to defaults - -The SELECTIMPL command replaces the existing instance on traf immediately, -so you can switch implementations mid-simulation if needed. - -Available base classes for replacement: -- Autopilot: Aircraft guidance logic (traf.ap) -- PerfBase: Performance model (traf.perf) -- ConflictDetection: CD algorithm (traf.cd) -- ConflictResolution: CR algorithm (traf.cr) -""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import TYPE_CHECKING - -from minisky.traffic.autopilot import Autopilot - -if TYPE_CHECKING: - from minisky import MiniSky - from minisky.simulation import Simulation - from minisky.traffic import Traffic - - -def init_plugin(_runtime: MiniSky) -> dict[str, str]: - config = {"plugin_name": "CUSTOMAUTOPILOT"} - return config - - -class CustomAutoPilot(Autopilot): - """Custom autopilot implementation. - - Subclassing Autopilot automatically registers this class as 'CUSTOMAUTOPILOT'. - Select it with: SELECTIMPL AUTOPILOT CUSTOMAUTOPILOT - """ - - def __init__(self, traffic: Traffic, get_simulation: Callable[[], Simulation]) -> None: - super().__init__(traffic, get_simulation) - # Add custom instance variables here - self.new_variable = 10 - - def update(self): - # Option 1: Extend base behavior - call super first, then add custom logic - super().update() - self.new_variable += 1 - - # Option 2: Replace base behavior entirely - don't call super().update() - # and implement your own autopilot logic from scratch diff --git a/example_plugins/example.py b/example_plugins/example.py deleted file mode 100644 index a43f249..0000000 --- a/example_plugins/example.py +++ /dev/null @@ -1,125 +0,0 @@ -"""MiniSky example plugin. - -This plugin demonstrates the plugin system capabilities: - -- registering per-aircraft data arrays; -- periodic update functions; -- stack commands bound to runtime-owned plugin state. -""" - -from __future__ import annotations - -from random import Random -from typing import TYPE_CHECKING, Any - -import numpy as np - -from minisky import plugin - -if TYPE_CHECKING: - from minisky import MiniSky - from minisky.traffic import Traffic - - -def init_plugin( - runtime: MiniSky, -) -> tuple[dict[str, Any], dict[str, list[Any]]]: - """Initialize the plugin for one MiniSky runtime. - - This function is required for all plugins. It returns a configuration - dictionary and, optionally, a second dictionary of stack functions. The - runtime argument makes ownership explicit: every plugin load creates a new - entity and command binding for that runtime. - - Args: - runtime: MiniSky runtime loading this plugin. - - Returns: - A `(config, stack_functions)` tuple consumed by the runtime's plugin - manager. - """ - # Instantiate the example entity on this runtime's traffic-array tree. - instance = Example(runtime.traffic, runtime.python_random) - - # Configuration parameters and lifecycle callbacks. - config = { - "plugin_name": "EXAMPLE", - "update_interval": 5, # Update every 5 seconds of simulation time. - "update": instance.update, - "state": instance, - } - - # Bind the PASSENGERS command directly to this runtime's entity instance. - stack_functions = { - "PASSENGERS": [ - instance.passengers, - "txt,[int]", - "PASSENGERS callsign, [count]", - "Set or get the number of passengers on an aircraft.", - ] - } - return config, stack_functions - - -class Example(plugin.Entity): - """Example entity that tracks passenger count per aircraft. - - Each loaded runtime owns a separate `Example` instance. Its passenger - array remains index-aligned with the aircraft arrays of the traffic object - passed to the constructor. - """ - - def __init__(self, traffic: Traffic, random: Random) -> None: - """Attach the entity to `traffic` and register its passenger array.""" - super().__init__(traffic) - self.random = random - - # Register per-aircraft data arrays. These automatically resize when - # aircraft are created or deleted in the owning runtime. - with self.settrafarrays(): - self.npassengers = np.array([]) - - def create(self, n: int = 1) -> None: - """Initialize passenger counts for newly created aircraft. - - Called automatically by the traffic-array tree whenever `n` aircraft - are added to the owning traffic object. - - Args: - n: Number of newly created aircraft. - """ - super().create(n) - self.npassengers[-n:] = [self.random.randint(50, 250) for _ in range(n)] - - def update(self) -> None: - """Periodic update function called every five simulation seconds.""" - if self.traffic.ntraf > 0: - total = int(sum(self.npassengers)) - print(f"Example plugin: {self.traffic.ntraf} aircraft, {total} total passengers") - - def passengers(self, callsign: str, count: int = -1) -> tuple[bool, str]: - """Set or get the number of passengers on an aircraft. - - Args: - callsign: Aircraft callsign. - count: Passenger count to set. Omit it, or pass a negative value, - to query the current count. - - Returns: - A `(success, message)` tuple suitable for the stack command. - """ - callsign = callsign.upper() - - # Find the aircraft index in this runtime's traffic object. - if callsign not in self.traffic.callsign: - return False, f"Aircraft {callsign} not found" - - index = self.traffic.callsign.index(callsign) - if count < 0: - return ( - True, - f"Aircraft {callsign} has {int(self.npassengers[index])} passengers", - ) - - self.npassengers[index] = count - return True, f"Set {callsign} passengers to {count}" diff --git a/justfile b/justfile index 2de7a0e..12dcb08 100644 --- a/justfile +++ b/justfile @@ -1,18 +1,16 @@ -plugin_dir := "example_plugins/tangram/tangram_minisky" - sync: pnpm install pnpm build uv sync --all-packages fmt: - uv run ruff check minisky example_plugins tests --fix - uv run ruff format example_plugins/tangram.py {{plugin_dir}} + uv run ruff check packages tests --fix + uv run ruff format packages tests pnpm lint:fix check: - uv run ruff check minisky example_plugins tests - uv run ruff format example_plugins/tangram.py {{plugin_dir}} --check + uv run ruff check packages tests + uv run ruff format packages tests --check uv run pyright pnpm check diff --git a/minisky/__init__.py b/minisky/__init__.py deleted file mode 100644 index 3c5b207..0000000 --- a/minisky/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -"""MiniSky air traffic simulator. - -[`MiniSky`][minisky.runtime.MiniSky] is the explicit ownership root for one -simulator runtime. Construct it with validated [`MiniSkySettings`][] and access -simulation components through that instance. -""" - -from minisky.core.settings import DEFAULT_SETTINGS_FILE, MiniSkySettings -from minisky.runtime import MiniSky -from minisky.simulation import SimulationState - -BS_OK = 0 -BS_ARGERR = 1 -BS_FUNERR = 2 -BS_CMDERR = 4 - -__all__ = ( - "BS_ARGERR", - "BS_CMDERR", - "BS_FUNERR", - "BS_OK", - "DEFAULT_SETTINGS_FILE", - "MiniSky", - "MiniSkySettings", - "SimulationState", -) diff --git a/minisky/core/__init__.py b/minisky/core/__init__.py deleted file mode 100644 index 0b63a76..0000000 --- a/minisky/core/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Core simulation infrastructure of MiniSky. - -Contains the per-aircraft array bookkeeping (trafficarrays), the settings -loader (settings), and the variable explorer (varexplorer). The classes -TrafficArrays and RegisterElementParameters, which all traffic-related -simulation entities build on, are re-exported here for convenience. -""" - -from __future__ import annotations - -from minisky.core.trafficarrays import RegisterElementParameters, TrafficArrays - -from . import settings, trafficarrays, varexplorer - -__all__ = ( - "RegisterElementParameters", - "TrafficArrays", - "settings", - "trafficarrays", - "varexplorer", -) diff --git a/minisky/core/settings.py b/minisky/core/settings.py deleted file mode 100644 index cd35efe..0000000 --- a/minisky/core/settings.py +++ /dev/null @@ -1,43 +0,0 @@ -"""MiniSky configuration.""" - -from __future__ import annotations - -import tomllib -from pathlib import Path -from typing import Annotated - -import annotated_types -from pydantic import BaseModel, ConfigDict, Field - - -class MiniSkySettings(BaseModel): - """Validated settings for the MiniSky runtime.""" - - # TODO(abraham): when we work on issue #24 we should add a [plugin] - # namespace for plugin-specific config and disallow extras - model_config = ConfigDict(frozen=True, extra="allow") - - asas_dtlookahead: Annotated[float, Field(), annotated_types.Ge(0)] = 300.0 - asas_pzr: Annotated[float, Field(), annotated_types.Gt(0)] = 5.0 - asas_pzh: Annotated[float, Field(), annotated_types.Gt(0)] = 1000.0 - asas_marh: Annotated[float, Field(), annotated_types.Gt(0)] = 1.05 - asas_marv: Annotated[float, Field(), annotated_types.Gt(0)] = 1.05 - # TODO(abraham): delete this. - plugin_path: Annotated[str, Field(), annotated_types.MinLen(1)] = "plugins" - enabled_plugins: tuple[str, ...] = () - - @classmethod - def from_file(cls, path: str | Path) -> MiniSkySettings: - """Load and validate settings from a TOML file.""" - with Path(path).expanduser().open("rb") as file: - return cls.model_validate(tomllib.load(file)) - -# TODO(abraham): delete this, require users to pass in an explicit path and -# use platformdirs for default loading -DEFAULT_SETTINGS_FILE = Path(__file__).parent.parent.parent / "settings.toml" -PACKAGE_DATA_DIR = Path(__file__).parent.parent / "data" - - -def data(path: str) -> Path: - """Return an absolute path inside the package data directory.""" - return PACKAGE_DATA_DIR / path diff --git a/minisky/plugin/__init__.py b/minisky/plugin/__init__.py deleted file mode 100644 index 9ff2452..0000000 --- a/minisky/plugin/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Plugin system for MiniSky. - -Plugins are Python modules in the configured plugin directory that define an -`init_plugin(runtime)` function. They are discovered without being imported -(AST parsing only) and loaded on demand by the runtime-owned -[`PluginManager`][minisky.plugin.plugin.PluginManager]. Loading registers the -plugin's periodic update functions, lifecycle callbacks, variable-explorer -state, and stack commands with that runtime only. - -This module provides the plugin infrastructure including: - -- [`Entity`][minisky.plugin.entity.Entity]: Base class for plugin-owned - per-aircraft data attached to a runtime's traffic tree. -- [`Plugin`][minisky.plugin.plugin.Plugin]: Discovery and loaded-state record - for one plugin in one runtime. -- [`PluginManager`][minisky.plugin.plugin.PluginManager]: Runtime-owned plugin - discovery, loading, and lifecycle management. -- [`TimedFunctionManager`][minisky.plugin.timedfunction.TimedFunctionManager]: - Runtime-owned periodic callbacks and simulation lifecycle hooks. -- [`Timer`][minisky.plugin.timedfunction.Timer]: Simulation-time periodic - trigger used by the timed-function manager. -- [`command`][minisky.plugin.plugin_decorators.command]: Decorator for declaring - plugin stack commands without import-time registry mutation. -""" - -from __future__ import annotations - -from minisky.plugin.entity import Entity -from minisky.plugin.plugin import Plugin, PluginManager -from minisky.plugin.plugin_decorators import command -from minisky.plugin.timedfunction import TimedFunctionManager, Timer - -__all__ = ( - "Entity", - "Plugin", - "PluginManager", - "TimedFunctionManager", - "Timer", - "command", -) diff --git a/minisky/plugin/entity.py b/minisky/plugin/entity.py deleted file mode 100644 index 9c0e00d..0000000 --- a/minisky/plugin/entity.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Entity base class for MiniSky plugin-owned per-aircraft data. - -`Entity` extends [`TrafficArrays`][minisky.core.trafficarrays.TrafficArrays] -so plugin data can participate in the owning runtime's aircraft-array tree. -An entity is attached explicitly to one [`Traffic`][minisky.traffic.Traffic] -object; it is not a process-wide singleton and does not use a proxy. - -Usage: - -```python -class MyPlugin(Entity): - def __init__(self, traffic: Traffic) -> None: - super().__init__(traffic) - with self.settrafarrays(): - self.mydata = np.array([]) -``` - -Arrays and lists registered inside `settrafarrays()` grow, shrink, and reset -with the aircraft in that runtime. Separate runtimes can therefore load -independent instances of the same plugin class. - -For replaceable core traffic components, inherit from `TrafficArrays` -directly and select implementations through the runtime's -`ReplaceableManager`. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from minisky.core.trafficarrays import TrafficArrays - -if TYPE_CHECKING: - from minisky.traffic import Traffic - - -class Entity(TrafficArrays): - """Base class for plugin-owned per-aircraft arrays. - - Combines the automatic create, delete, and reset behavior of - [`TrafficArrays`][minisky.core.trafficarrays.TrafficArrays] with an - explicit reference to the traffic tree that owns the plugin entity. - - Usage: - - ```python - class MyPlugin(Entity): - def __init__(self, traffic: Traffic) -> None: - super().__init__(traffic) - with self.settrafarrays(): - self.mydata = np.array([]) - - def create(self, n: int = 1) -> None: - super().create(n) - self.mydata[-n:] = default_values - ``` - - Args: - traffic: Traffic object whose tree owns this entity. - - Attributes: - traffic: The owning runtime's traffic object. - """ - - def __init__(self, traffic: Traffic) -> None: - """Attach this entity to `traffic` and initialize its array bookkeeping.""" - self.traffic = traffic - super().__init__(traffic) diff --git a/minisky/plugin/plugin.py b/minisky/plugin/plugin.py deleted file mode 100644 index bee0c9f..0000000 --- a/minisky/plugin/plugin.py +++ /dev/null @@ -1,406 +0,0 @@ -"""MiniSky plugin system. - -Provides runtime-owned plugin discovery, loading, and management. - -Discovery ([`PluginManager.discover`][]) scans the plugin directory and parses -each Python file's AST without importing it, looking for an `init_plugin` -function from which the plugin name, docstring, and stack commands are -extracted. Loading ([`PluginManager.load`][]) imports the module, calls -`init_plugin(runtime)`, and registers the returned update hooks and stack -commands with that runtime. [`PluginManager.manage`][] backs the in-simulator -`PLUGINS` stack command. -""" - -from __future__ import annotations - -import ast -import importlib -import sys -import traceback -from dataclasses import dataclass, field -from pathlib import Path -from types import ModuleType -from typing import TYPE_CHECKING, Any - -from minisky.core.trafficarrays import TrafficArrays -from minisky.plugin.plugin_decorators import append_commands, register_declared_commands -from minisky.plugin.timedfunction import TimedFunctionManager - -if TYPE_CHECKING: - from collections.abc import Callable - - from minisky.core.settings import MiniSkySettings - from minisky.core.varexplorer import VariableExplorer - from minisky.runtime import MiniSky - from minisky.simulation import ConsoleIO, Simulation - from minisky.stack import CommandStack - - -# TODO(abraham): split discovered and loaded records so loaded state has no optionals -@dataclass -class Plugin: - """Information about one plugin discovered for one runtime. - - Attributes: - fullname: Importable module name of the plugin (dotted path). - filepath: Path to the plugin's source file. - plugin_doc: Module docstring, extracted during discovery. - plugin_name: Name declared in the config dict, falling back to the - upper-case file stem. - plugin_stack: List of `(command name, help text)` tuples declared by - the plugin. - loaded: True once the plugin has been imported and initialized for the - owning runtime. - module: Imported plugin module, or None until loaded. - config: Config dictionary returned by `init_plugin(runtime)`. - state: Optional runtime-owned state object returned in the config. - command_names: Command names and aliases registered by this plugin. - """ - - fullname: str - filepath: Path - plugin_doc: str = "" - plugin_name: str = "" - plugin_stack: list[tuple[str, str]] = field(default_factory=list) - loaded: bool = False - module: ModuleType | None = None - config: dict[str, Any] = field(default_factory=dict) - state: Any = None - command_names: set[str] = field(default_factory=set) - - -class PluginManager: - """Plugin discovery, loading, hooks, and state for one MiniSky runtime. - - Attributes: - plugins: Dict mapping upper-case plugin names to all discovered plugin - records for this runtime. - loaded_plugins: Dict containing the plugins loaded into this runtime. - timed: Runtime-owned timer and lifecycle-hook manager. - """ - - def __init__( - self, - settings: MiniSkySettings, - console: ConsoleIO, - variables: VariableExplorer, - get_runtime: Callable[[], MiniSky], - get_simulation: Callable[[], Simulation], - get_command_stack: Callable[[], CommandStack], - ) -> None: - self.settings = settings - self.console = console - self.variables = variables - self._get_runtime = get_runtime - self._get_simulation = get_simulation - self._get_command_stack = get_command_stack - self.plugins: dict[str, Plugin] = {} - self.loaded_plugins: dict[str, Plugin] = {} - self.timed = TimedFunctionManager(lambda: self.simulation.simdt) - - @property - def runtime(self) -> MiniSky: - """Return the runtime that owns this manager.""" - return self._get_runtime() - - @property - def simulation(self) -> Simulation: - """Return the owning runtime's simulation.""" - return self._get_simulation() - - @property - def commands(self) -> CommandStack: - """Return the owning runtime's command stack.""" - return self._get_command_stack() - - def discover(self) -> None: - """Discover plugins in the configured directory using AST parsing. - - Resolves `plugin_path` relative to the package root and then the current - working directory, adds its parent to `sys.path`, and scans all `*.py` - files except names starting with an underscore. Modules containing a - top-level `init_plugin` function are registered without being imported. - """ - # Get plugin path from settings. - plugin_path = Path(self.settings.plugin_path) - - # Make the path absolute if it is relative. - if not plugin_path.is_absolute(): - package_path = Path(__file__).parent.parent.parent / plugin_path - working_path = Path.cwd() / plugin_path - if package_path.exists(): - plugin_path = package_path - elif working_path.exists(): - plugin_path = working_path - else: - self.console.echo(f"Plugin directory not found: {plugin_path}") - return - - if not plugin_path.exists(): - self.console.echo(f"Plugin directory not found: {plugin_path}") - return - - # TODO(abraham): replace sys.path mutation with a path-based importer - plugin_parent = str(plugin_path.parent) - if plugin_parent not in sys.path: - sys.path.insert(0, plugin_parent) - - # Scan Python files without importing them. - for filepath in plugin_path.glob("**/*.py"): - if filepath.name.startswith("_"): - continue - - relative_path = filepath.relative_to(plugin_path.parent) - fullname = ".".join(relative_path.with_suffix("").parts) - try: - tree = ast.parse(filepath.read_bytes()) - except Exception: - continue - - # Find the required synchronous init_plugin function. - init_node = next( - ( - item - for item in tree.body - if isinstance(item, ast.FunctionDef) and item.name == "init_plugin" - ), - None, - ) - if init_node is None: - continue - - plugin_info = self._parse_init_plugin(init_node) - if plugin_info is None: - continue - - plugin_name = str( - plugin_info.get("plugin_name", filepath.stem.upper()) - ).upper() - existing = self.plugins.get(plugin_name) - if existing is not None and existing.loaded: - continue - - self.plugins[plugin_name] = Plugin( - fullname=fullname, - filepath=filepath, - plugin_doc=ast.get_docstring(tree) or "", - plugin_name=plugin_name, - plugin_stack=plugin_info.get("stack_functions", []), - ) - - def load(self, name: str) -> tuple[bool, str]: - """Load a previously discovered plugin by name. - - Imports the module, calls `init_plugin(runtime)`, registers returned - `preupdate`, `update`, `reset`, and `hold` hooks as timed functions, - registers plugin data with the variable explorer, and appends declared - stack commands to the owning runtime's command stack. - - Args: - name: Plugin name, case-insensitive, as found during discovery. - - Returns: - Tuple of `(success flag, status message)`. Loading fails when the - plugin is unknown, already loaded, returns an invalid config, or - raises during import or initialization. - """ - plugin = self.plugins.get(name.upper()) - if plugin is None: - return False, f"Error loading plugin: plugin {name} not found." - if plugin.loaded: - return False, f"Plugin {plugin.plugin_name} already loaded" - - # TODO(abraham): make loading transactional before adding unload or reload - try: - # Load and initialize the plugin for this runtime. - # TODO(abraham): isolate module namespaces before supporting unload - module = importlib.import_module(plugin.fullname) - result = module.init_plugin(self.runtime) - # TODO(abraham): replace dict and tuple returns with a typed plugin plan - config = result if isinstance(result, dict) else result[0] - stack_functions = ( - result[1] if isinstance(result, (tuple, list)) and len(result) > 1 else None - ) - if not isinstance(config, dict): - return False, f"Plugin {plugin.plugin_name} returned an invalid config" - - # Get update interval (minimum is simdt) and register hooks. - interval = max(float(config.get("update_interval", 0.0)), self.simulation.simdt) - for hook_name in ("preupdate", "update", "reset", "hold"): - callback = config.get(hook_name) - if callback is not None: - self.timed.register( - callback, - name=f"{plugin.plugin_name}.{callback.__name__}", - dt=interval, - hook=hook_name, - ) - - # Register stack functions only on this runtime. - command_names_before = set(self.commands.cmddict) - register_declared_commands(self.commands, module) - if stack_functions: - append_commands(self.commands, stack_functions) - command_names = set(self.commands.cmddict) - command_names_before - - # Register plugin state, or the module when no state object is returned. - state = config.get("state") - self.variables.register_data_parent( - state if state is not None else module, - plugin.plugin_name.lower(), - ) - - plugin.loaded = True - plugin.module = module - plugin.config = config - plugin.state = state - plugin.command_names = command_names - self.loaded_plugins[plugin.plugin_name] = plugin - return True, f"Successfully loaded plugin {plugin.plugin_name}" - - except ImportError as exc: - return False, f"Failed to load {plugin.plugin_name}: {exc}" - except Exception as exc: - traceback.print_exc() - return False, f"Error loading {plugin.plugin_name}: {exc}" - - def load_enabled(self) -> None: - """Load plugins enabled in this runtime's settings.""" - for plugin_name in self.settings.enabled_plugins: - _, message = self.load(plugin_name) - self.console.echo(message) - - def manage(self, command: str = "LIST", plugin_name: str = "") -> tuple[bool, str]: - """List available plugins or load a plugin. - - Arguments: - - command: `LIST` to show plugins, or `LOAD` / `ENABLE` to load one. - - plugin_name: Name of the plugin to load. - """ - command = command.upper() - - if command == "LIST": - running = set(self.loaded_plugins) - available = set(self.plugins) - running - text = f"\nLoaded plugins: {', '.join(sorted(running)) if running else '(none)'}" - if available: - text += f"\nAvailable plugins: {', '.join(sorted(available))}" - else: - text += "\nNo additional plugins available." - return True, text - - if command in ("LOAD", "ENABLE") or not plugin_name: - # If no command is given, assume loading a plugin. - return self.load(plugin_name or command) - - return False, f"Unknown command: {command}" - - def preupdate(self) -> None: - """Called before traffic update each simulation step.""" - self.timed.preupdate() - - def update(self) -> None: - """Called after traffic update each simulation step.""" - self.timed.update() - - def reset(self) -> None: - """Called on simulation reset.""" - self.timed.reset() - - def hold(self) -> None: - """Called when simulation pauses.""" - self.timed.hold() - - def shutdown(self) -> None: - """Run shutdown callbacks and release all runtime-owned plugin state.""" - errors: list[Exception] = [] - # TODO(abraham): own plugin tasks and handles through one async exit stack. - for plugin in reversed(tuple(self.loaded_plugins.values())): - try: - callback = plugin.config.get("shutdown") - if callback is not None: - callback() - except Exception as exc: # noqa: BLE001 - finish releasing every plugin - errors.append(exc) - finally: - for command_name in plugin.command_names: - self.commands.cmddict.pop(command_name, None) - - self.variables.unregister_data_parent(plugin.plugin_name.lower()) - if isinstance(plugin.state, TrafficArrays): - plugin.state.detach() - - plugin.loaded = False - plugin.module = None - plugin.config.clear() - plugin.state = None - plugin.command_names.clear() - - self.timed.clear() - self.loaded_plugins.clear() - if errors: - raise ExceptionGroup("Plugin shutdown failed", errors) - - @staticmethod - def _parse_init_plugin(func_node: ast.FunctionDef) -> dict[str, Any] | None: - """Parse an `init_plugin` AST node to extract the plugin config. - - Walks the function body backwards from its return statement to find the - returned config dict and optional stack-functions dict, reading literal - keys and values without executing plugin code. - - Args: - func_node: AST node of the `init_plugin` function. - - Returns: - Dict with literal config values plus a `stack_functions` list of - `(command name, help text)` tuples, or None if no return value can - be found. - """ - returned: list[ast.expr] = [] - return_names = ["", ""] - - for item in reversed(func_node.body): - # Find return statement. - if isinstance(item, ast.Return): - if isinstance(item.value, ast.Tuple): - returned = list(item.value.elts) - elif item.value is not None: - returned = [item.value] - if returned: - return_names = [ - value.id if isinstance(value, ast.Name) else "" for value in returned - ] - - # Resolve assignments of returned config dictionaries. - if isinstance(item, ast.Assign) and isinstance(item.value, ast.Dict): - target = item.targets[0] - for index, name in enumerate(return_names): - if name and isinstance(target, ast.Name) and target.id == name: - returned[index] = item.value - - if not returned: - return None - - # Parse the config dict. - config: dict[str, Any] = {} - if isinstance(returned[0], ast.Dict): - for key, value in zip(returned[0].keys, returned[0].values, strict=False): - if isinstance(key, ast.Constant) and isinstance(value, ast.Constant): - config[str(key.value)] = value.value - - # Parse stack functions if present. - if len(returned) > 1 and isinstance(returned[1], ast.Dict): - stack_functions: list[tuple[str, str]] = [] - for key, value in zip(returned[1].keys, returned[1].values, strict=False): - if not isinstance(key, ast.Constant): - continue - help_text = "" - if isinstance(value, (ast.List, ast.Tuple)) and value.elts: - last = value.elts[-1] - if isinstance(last, ast.Constant): - help_text = str(last.value) - stack_functions.append((str(key.value), help_text)) - config["stack_functions"] = stack_functions - - return config diff --git a/minisky/plugin/plugin_decorators.py b/minisky/plugin/plugin_decorators.py deleted file mode 100644 index ffa48d0..0000000 --- a/minisky/plugin/plugin_decorators.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Stack command declarations for MiniSky plugins. - -The `@command` decorator stores command metadata on a function. Importing a -plugin module does not register that command globally. Instead, the -[`PluginManager`][minisky.plugin.plugin.PluginManager] that loads the module -registers its declarations with the owning runtime's -[`CommandStack`][minisky.stack.CommandStack]. -""" - -from __future__ import annotations - -import inspect -from collections.abc import Callable -from types import ModuleType -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from minisky.stack import CommandStack - - -def command( - func: Callable[..., Any] | None = None, - name: str = "", - aliases: tuple[str, ...] = (), - brief: str = "", - help: str = "", - arguments: str = "", -) -> Any: - """Declare a function as a stack command. - - The declaration is stored on the function and registered only when the - runtime-owned plugin manager loads the containing module. This keeps module - import free of command-registry side effects while preserving the familiar - decorator syntax. - - Args: - func: Function to decorate. It may be omitted when using - `@command(...)` syntax. - name: Command name. Defaults to the function name. - aliases: Alternative command names. - brief: Brief usage string. - help: Detailed help text. When omitted, the function docstring is used. - arguments: Argument specification string, for example - `callsign,alt,[spd]`. - - Example: - from minisky import stack - from minisky.stack.argparser import Txt - - @stack.command - def mycommand(arg1: Txt, arg2: int = 5): - '''Help text for mycommand.''' - return True, "Success" - - @stack.command(name="MYCMD", aliases=("MC",)) - def my_command(arg: str): - '''Help text.''' - return True, "Done" - - Returns: - The original function or descriptor, unmodified apart from the stored - declaration metadata. - """ - - def deco(declared: Callable[..., Any]) -> Any: - # Static and class methods store their declaration on the underlying - # function so the plugin loader can inspect them uniformly. - actual_func = ( - declared.__func__ - if isinstance(declared, (staticmethod, classmethod)) - else declared - ) - declaration = { - "name": name or actual_func.__name__, - "aliases": aliases, - "brief": brief, - "help": help or inspect.cleandoc(inspect.getdoc(actual_func) or ""), - "arguments": arguments, - } - actual_func.__stack_command__ = declaration # type: ignore[reportFunctionMemberAccess] - return declared - - # Allow both `@command` and `@command(...)` forms. - return deco(func) if func else deco - - -def register_declared_commands(command_stack: CommandStack, module: ModuleType) -> None: - """Register command declarations from one plugin module. - - Only the supplied module is inspected. This is intentionally narrower than - scanning `sys.modules`: loading a plugin into one runtime must not register - commands imported for another runtime. - - Args: - command_stack: Runtime-owned command registry that receives the - declarations. - module: Imported plugin module whose decorated functions are inspected. - """ - for value in vars(module).values(): - actual_func = ( - value.__func__ if isinstance(value, (staticmethod, classmethod)) else value - ) - declaration = getattr(actual_func, "__stack_command__", None) - if declaration is not None: - command_stack.addcommand(actual_func, **declaration) - - -def append_commands( - command_stack: CommandStack, - newcommands: dict[str, list[Any] | tuple[Any, ...]], - syndict: dict[str, list[str]] | None = None, -) -> None: - """Append a plugin command dictionary to one runtime's command registry. - - This supports the original plugin return format in which `init_plugin` - returns a second dictionary mapping command names to a callback, argument - specification, brief usage text, and full help text. - - Args: - command_stack: Runtime-owned command registry that receives the - commands. - newcommands: Mapping of command name to - `[function, arguments, brief, help]`. Missing trailing values are - treated as empty strings. - syndict: Optional mapping of command name to aliases. - """ - synonyms = syndict or {} - - for name, values in newcommands.items(): - function = values[0] - arguments = values[1] if len(values) > 1 else "" - brief = values[2] if len(values) > 2 else "" - help_text = values[3] if len(values) > 3 else "" - - command_stack.addcommand( - function, - name=name, - arguments=arguments, - brief=brief, - help=help_text, - aliases=synonyms.get(name, []), - ) diff --git a/minisky/plugin/timedfunction.py b/minisky/plugin/timedfunction.py deleted file mode 100644 index 073d34e..0000000 --- a/minisky/plugin/timedfunction.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Timed function infrastructure for MiniSky plugins. - -Provides hooks that are triggered at specific points in the simulation cycle: -- preupdate: Before traffic update each step -- update: After traffic update each step -- reset: On simulation reset -- hold: When simulation pauses - -Each `TimedFunctionManager` owns the hooks and timers for one runtime. -""" - -from __future__ import annotations - -import functools -import inspect -from collections import OrderedDict -from collections.abc import Callable - - -class _Hook(OrderedDict[str, Callable[[], None]]): - """Ordered dictionary of callbacks that can be triggered.""" - - def trigger(self) -> None: - """Call all registered callbacks.""" - for callback in tuple(self.values()): - callback() - - -class Timer: - """Timer class for simulation-time periodic functions. - - A timer fires every `dt` simulation seconds, quantised to whole simulation - timesteps: the requested interval is converted to a step count relative to the - current `sim.simdt`, so the actual interval is never smaller than one timestep. - - Attributes: - name: Unique name of the timer (also the registry key). - dt_default: Interval the timer was created with [s]. - dt_requested: Currently requested interval [s]. - dt_act: Actual interval after quantisation to whole timesteps [s]. - rel_freq: Number of simulation steps between firings. - readynext: True when the timer fires on the current step. - """ - - def __init__(self, name: str, dt: float, get_simdt: Callable[[], float]) -> None: - self.name = name - self.dt_default = dt - self.dt_requested = dt - self.dt_act = dt - self.counter = 0 - self.rel_freq = 1 - self.readynext = True - self._get_simdt = get_simdt - self._update_freq() - - def _update_freq(self) -> None: - """Update the relative frequency based on current simdt.""" - simdt = self._get_simdt() - self.rel_freq = max(1, int(self.dt_requested / simdt)) - self.dt_act = self.rel_freq * simdt - - def reset(self) -> None: - """Reset timer to default state.""" - self.dt_requested = self.dt_default - self.counter = 0 - self._update_freq() - self.readynext = True - - def step(self) -> None: - """Step is called each base timestep to update this timer.""" - self.counter = (self.counter or self.rel_freq) - 1 - self.readynext = self.counter == 0 - - -class TimedFunctionManager: - """Central manager for plugin lifecycle events. - - Provides a clean interface for simulation.py to trigger this runtime's - plugin hooks without knowing about Timer or hook internals. - """ - - _hook_names = ("preupdate", "update", "reset", "hold") - - def __init__(self, get_simdt: Callable[[], float]) -> None: - self._get_simdt = get_simdt - self.timers: dict[str, Timer] = {} - self.preupdate_hooks = _Hook() - self.update_hooks = _Hook() - self.reset_hooks = _Hook() - self.hold_hooks = _Hook() - - def _hook(self, name: str) -> _Hook: - if name not in self._hook_names: - raise KeyError(f"No timing hook found with name {name}") - return getattr(self, f"{name}_hooks") - - def register( - self, - func: Callable[..., None], - *, - name: str = "", - dt: float = 0, - hook: str | tuple[str, ...] = "update", - ) -> Callable[..., None]: - """Turn a function into a periodically timed function. - - Args: - func: The function to register. - name: Name for the timer (auto-generated if not provided). - dt: Update interval in seconds (0 means every step). - hook: Which hook to attach to (`update`, `preupdate`, `reset`, or - `hold`). - - Returns: - The original function. - """ - # Generate a name if none is provided. - timer_name = name or self._callback_name(func) - hook_names = (hook,) if isinstance(hook, str) else hook - - if any(hook_name in ("update", "preupdate") for hook_name in hook_names): - # Create a timer for update/preupdate hooks. - timer = Timer(timer_name, dt, self._get_simdt) - self.timers[timer_name] = timer - else: - timer = None - - # Check if function accepts dt argument. - has_dt_param = "dt" in inspect.signature(func).parameters - - @functools.wraps(func) - def callback() -> None: - if timer is None: - func() - elif timer.readynext: - if has_dt_param: - func(dt=float(timer.dt_act)) - else: - func() - - # Add callback to appropriate hook(s). - for hook_name in hook_names: - target = self._hook(hook_name) - # For reset/hold, store the original function; for - # update/preupdate, store the timed callback. - registered = func if hook_name in ("reset", "hold") else callback - target.setdefault(timer_name, registered) - - return func - - @staticmethod - def _callback_name(func: Callable[..., None]) -> str: - if inspect.ismethod(func): - owner = func.__self__ if inspect.isclass(func.__self__) else type(func.__self__) - return f"{owner.__name__}.{func.__name__}" - return f"{func.__module__}.{func.__name__}" - - def preupdate(self) -> None: - """Called before traffic update each simulation step.""" - for timer in self.timers.values(): - timer.step() - self.preupdate_hooks.trigger() - - def update(self) -> None: - """Called after traffic update each simulation step.""" - self.update_hooks.trigger() - - def reset(self) -> None: - """Called on simulation reset.""" - for timer in self.timers.values(): - timer.reset() - self.reset_hooks.trigger() - - def hold(self) -> None: - """Called when simulation pauses.""" - self.hold_hooks.trigger() - - def clear(self) -> None: - """Remove all callbacks and timers owned by this manager.""" - self.timers.clear() - for name in self._hook_names: - self._hook(name).clear() diff --git a/package.json b/package.json index c777a4d..e4d4781 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,8 @@ "private": true, "packageManager": "pnpm@11.17.0", "scripts": { - "build": "pnpm --filter './example_plugins/tangram/*' run build", - "check": "pnpm --filter './example_plugins/tangram/*' run check", - "lint:fix": "pnpm --filter './example_plugins/tangram/*' run lint:fix" + "build": "pnpm --filter './packages/tangram-minisky' run build", + "check": "pnpm --filter './packages/tangram-minisky' run check", + "lint:fix": "pnpm --filter './packages/tangram-minisky' run lint:fix" } } diff --git a/packages/minisky-example-customautopilot/pyproject.toml b/packages/minisky-example-customautopilot/pyproject.toml new file mode 100644 index 0000000..5c9c870 --- /dev/null +++ b/packages/minisky-example-customautopilot/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "minisky-example-customautopilot" +version = "0.1.0" +description = "Example MiniSky plugin demonstrating replaceable autopilot implementations" +readme = { text = "A private example plugin for MiniSky.", content-type = "text/markdown" } +requires-python = ">=3.11" +dependencies = ["minisky>=0.1.0"] +classifiers = ["Private :: Do Not Upload"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/minisky_example_customautopilot"] + +[tool.uv.sources] +minisky = { workspace = true } + +[project.entry-points."minisky.plugins"] +customautopilot = "minisky_example_customautopilot:plugin" diff --git a/packages/minisky-example-customautopilot/src/minisky_example_customautopilot/__init__.py b/packages/minisky-example-customautopilot/src/minisky_example_customautopilot/__init__.py new file mode 100644 index 0000000..009968c --- /dev/null +++ b/packages/minisky-example-customautopilot/src/minisky_example_customautopilot/__init__.py @@ -0,0 +1,35 @@ +"""Example runtime-local autopilot replacement.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +from minisky import plugin as plugin_api +from minisky.traffic.autopilot import Autopilot + +if TYPE_CHECKING: + from minisky.simulation import Simulation + from minisky.traffic import Traffic + + +# --8<-- [start:replacement] +@plugin_api.replacement +class CustomAutoPilot(Autopilot): + """Extend the base autopilot with an example value.""" + + def __init__(self, traffic: Traffic, get_simulation: Callable[[], Simulation]) -> None: + super().__init__(traffic, get_simulation) + self.new_variable = 10 + + def update(self) -> None: + super().update() + self.new_variable += 1 + + +def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: + return context.finish(replacements=(CustomAutoPilot,)) + + +plugin = plugin_api.Plugin(build=build) +# --8<-- [end:replacement] diff --git a/packages/minisky-example/pyproject.toml b/packages/minisky-example/pyproject.toml new file mode 100644 index 0000000..8909b72 --- /dev/null +++ b/packages/minisky-example/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "minisky-example" +version = "0.1.0" +description = "Example MiniSky plugin demonstrating runtime-owned state and commands" +readme = { text = "A private example plugin for MiniSky.", content-type = "text/markdown" } +requires-python = ">=3.11" +dependencies = ["minisky>=0.1.0", "numpy>=2.2.2"] +classifiers = ["Private :: Do Not Upload"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/minisky_example"] + +[tool.uv.sources] +minisky = { workspace = true } + +# --8<-- [start:entry-point] +[project.entry-points."minisky.plugins"] +example = "minisky_example:plugin" +# --8<-- [end:entry-point] diff --git a/packages/minisky-example/src/minisky_example/__init__.py b/packages/minisky-example/src/minisky_example/__init__.py new file mode 100644 index 0000000..093d803 --- /dev/null +++ b/packages/minisky-example/src/minisky_example/__init__.py @@ -0,0 +1,55 @@ +"""Example runtime-local MiniSky plugin.""" + +from __future__ import annotations + +from random import Random + +import numpy as np + +from minisky import plugin as plugin_api + + +# --8<-- [start:declaration] +def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: + context.mount(Example(context.python_random)) + return context.finish() + + +plugin = plugin_api.Plugin(build=build) +# --8<-- [end:declaration] + + +# --8<-- [start:entity] +class Example(plugin_api.Entity): + """Track passenger count for every aircraft in the owning runtime.""" + + def __init__(self, random: Random) -> None: + super().__init__() + self.random = random + self.updates = 0 + with self.settrafarrays(): + self.npassengers = np.array([]) + + def create(self, n: int = 1) -> None: + super().create(n) + self.npassengers[-n:] = [self.random.randint(50, 250) for _ in range(n)] + + @plugin_api.hook(interval=5.0) + def update(self) -> None: + """Count periodic execution.""" + self.updates += 1 + + @plugin_api.command(arguments="txt,[int]") + def passengers(self, callsign: str, count: int = -1) -> tuple[bool, str]: + """Set or get the number of passengers on an aircraft.""" + callsign = callsign.upper() + if callsign not in self.traffic.callsign: + return False, f"Aircraft {callsign} not found" + index = self.traffic.callsign.index(callsign) + if count < 0: + return True, f"Aircraft {callsign} has {int(self.npassengers[index])} passengers" + self.npassengers[index] = count + return True, f"Set {callsign} passengers to {count}" + + +# --8<-- [end:entity] diff --git a/packages/minisky-tangram/pyproject.toml b/packages/minisky-tangram/pyproject.toml new file mode 100644 index 0000000..945d8d7 --- /dev/null +++ b/packages/minisky-tangram/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "minisky-tangram" +version = "0.1.0" +description = "Example MiniSky plugin bridging simulator state to tangram over Redis" +readme = { text = "A private example plugin for MiniSky.", content-type = "text/markdown" } +requires-python = ">=3.11" +dependencies = ["minisky>=0.1.0", "pydantic>=2.0", "redis>=5.0"] +classifiers = ["Private :: Do Not Upload"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/minisky_tangram"] + +[tool.uv.sources] +minisky = { workspace = true } + +[project.entry-points."minisky.plugins"] +tangram = "minisky_tangram:plugin" diff --git a/example_plugins/tangram.py b/packages/minisky-tangram/src/minisky_tangram/__init__.py similarity index 64% rename from example_plugins/tangram.py rename to packages/minisky-tangram/src/minisky_tangram/__init__.py index 40c3be1..797a51a 100644 --- a/example_plugins/tangram.py +++ b/packages/minisky-tangram/src/minisky_tangram/__init__.py @@ -23,7 +23,7 @@ simulation is paused (plugin update hooks only fire in the OP state; the command stack itself is processed in every state). -Settings (optional, under a `[tangram]` table in `settings.toml`): +Config (optional, under `[plugins.tangram]` in the MiniSky user config file): - `redis_url`: Redis connection URL (default `redis://127.0.0.1:6379`). - `channel`: channel/topic name (default `minisky`). @@ -44,24 +44,22 @@ import threading import time from collections import deque -from collections.abc import Callable +from collections.abc import AsyncGenerator, Callable +from contextlib import asynccontextmanager from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any, TypedDict, cast +from typing import Any, TypedDict, cast -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict +from minisky import plugin as plugin_api from minisky.simulation import SimulationState -from minisky.streaming import Snapshot, build_snapshot +from minisky.streaming import Snapshot from minisky.tools.aero import fpm, ft, kts -if TYPE_CHECKING: - from minisky import MiniSky - from minisky.simulation import ConsoleIO, Runner, Simulation - from minisky.traffic import Traffic - -class TangramSettings(BaseModel): - """Validated `[tangram]` config from `settings.toml`.""" +# --8<-- [start:configuration] +class TangramConfig(BaseModel): + """Validated `[plugins.tangram]` configuration.""" model_config = ConfigDict(extra="forbid", frozen=True) @@ -70,8 +68,7 @@ class TangramSettings(BaseModel): max_hz: float = 5.0 -class TangramPluginSettings(BaseModel): - tangram: TangramSettings = Field(default_factory=TangramSettings) +# --8<-- [end:configuration] # How often the background thread republishes state while the simulation is @@ -210,65 +207,42 @@ def extract_command(payload: str | bytes) -> str | None: class TangramBridge: - """Owns the Redis connection and shuttles data between it and the sim. - - The simulation thread only ever touches thread-safe queues/deques: the - `update` hook enqueues converted snapshots, and a tee on `scr.echo` - enqueues console lines. A daemon thread does all Redis I/O: draining - those queues, republishing a heartbeat while the sim is not advancing, - and listening for browser commands on `from::*`. - """ + """Own Redis I/O and bridge it to a plugin runtime.""" def __init__( self, + # we assume the redis url has no password and is safe to log redis_url: str, channel: str, max_hz: float, - snapshot_builder: Callable[[], Snapshot], - console: ConsoleIO, - simulation: Simulation, - runner: Runner, - traffic: Traffic, - get_scenname: Callable[[], str], - stack_command: Callable[[str], None], redis_factory: Callable[[str], Any] | None = None, ) -> None: self.redis_url = redis_url self.channel = channel self.min_interval = 1.0 / max_hz if max_hz > 0 else 0.0 - self.snapshot_builder = snapshot_builder - self.console = console - self.simulation = simulation - self.runner = runner - self.traffic = traffic - self.get_scenname = get_scenname - self.stack_command = stack_command self.redis_factory = redis_factory self.connected = False self.published = 0 self.last_error = "" + self._snapshot_builder: Callable[[], Snapshot] | None = None + self._status_builder: Callable[[], plugin_api.PluginStatus] | None = None + self._stack_command: Callable[[str], None] | None = None self._last_build = 0.0 self._last_payload: TangramPayload | None = None self._snapshots: queue.Queue[TangramPayload] = queue.Queue(maxsize=4) self._console: deque[str] = deque(maxlen=200) self._stop = threading.Event() self._thread: threading.Thread | None = None - self._original_echo: Callable[[str, int], None] | None = None self.ready = threading.Event() - """Set once the command subscription is live (commands published - before this are lost -- Redis pub/sub has no replay).""" - # -- simulation-thread side ------------------------------------------- - - def start(self) -> tuple[bool, str]: - """Install the console tee and start the Redis I/O thread.""" + def start(self, runtime: plugin_api.PluginRuntime) -> tuple[bool, str]: + """Bind runtime capabilities and start the Redis thread.""" try: if self.redis_factory is None: import redis - # cast: from_url's untyped **kwargs would leak Unknown under strict mode. self.redis_factory = cast( "Callable[[str], Any]", redis.Redis.from_url, # pyright: ignore[reportUnknownMemberType] @@ -279,28 +253,32 @@ def start(self) -> tuple[bool, str]: "MiniSky repository root" ) - self._tee_console() + self._snapshot_builder = runtime.snapshot + self._status_builder = runtime.status + self._stack_command = runtime.stack_command + self._stop.clear() + self.ready.clear() self._thread = threading.Thread(target=self._run, name="tangram-bridge", daemon=True) self._thread.start() return True, f"Tangram bridge publishing to to:{self.channel}:* at {self.redis_url}" def stop(self) -> None: - """Stop Redis I/O and restore the runtime console.""" + """Stop Redis I/O and release runtime callbacks.""" self._stop.set() + # TODO(abraham): use finite redis timeouts, close client/pubsub resources, + # and retain ownership when the thread does not stop if self._thread is not None: self._thread.join(timeout=2.0) self._thread = None - if self._original_echo is not None: - self.console.echo = self._original_echo # type: ignore[method-assign] - self._original_echo = None + self.connected = False + self.ready.clear() + self._snapshot_builder = None + self._status_builder = None + self._stack_command = None + @plugin_api.command(name="TANGRAM") def status(self) -> tuple[bool, str]: - """Return the current Redis bridge status for the TANGRAM command. - - Returns: - A `(True, message)` tuple reporting connection state, Redis URL, - channel, publication count, and the most recent transport error. - """ + """Show the status of the tangram Redis bridge.""" status = "connected" if self.connected else "disconnected" text = ( f"Tangram bridge: {status} to {self.redis_url}\n" @@ -310,66 +288,57 @@ def status(self) -> tuple[bool, str]: text += f"\nLast error: {self.last_error}" return True, text + @plugin_api.hook("update") def tick(self) -> None: - """Update hook: build and enqueue a snapshot (rate-capped). Runs in OP.""" + """Build and enqueue a rate-capped snapshot while operating.""" + snapshot_builder = self._snapshot_builder + if snapshot_builder is None: + return now = time.monotonic() if now - self._last_build < self.min_interval: return self._last_build = now - self._enqueue(convert_snapshot(self.snapshot_builder())) + self._enqueue(convert_snapshot(snapshot_builder())) + @plugin_api.hook("reset") def reset(self) -> None: - """Reset hook: push an empty payload so the frontend clears the map.""" + """Push an empty payload so the frontend clears the map.""" + snapshot_builder = self._snapshot_builder + if snapshot_builder is None: + return self._last_payload = None - self._enqueue(convert_snapshot(self.snapshot_builder())) + self._enqueue(convert_snapshot(snapshot_builder())) + + def capture_console(self, text: str) -> None: + if text: + self._console.extend(text.splitlines()) def _enqueue(self, payload: TangramPayload) -> None: self._last_payload = payload try: self._snapshots.put_nowait(payload) except queue.Full: - # Drop the oldest snapshot; each payload is a full state anyway. try: self._snapshots.get_nowait() self._snapshots.put_nowait(payload) except (queue.Empty, queue.Full): pass - def _tee_console(self) -> None: - """Also capture everything echoed to the console, without consuming it.""" - if self._original_echo is not None: - return - self._original_echo = self.console.echo - - def echo(text: str = "", flag: int = 0) -> None: - assert self._original_echo is not None - self._original_echo(text, flag) - if text: - self._console.extend(text.splitlines()) - - self.console.echo = echo # type: ignore[method-assign] - - # -- Redis-thread side ------------------------------------------------- - def _siminfo_heartbeat(self) -> TangramPayload: - """Refresh the cheap scalar fields of the last payload. - - Only reads scalar attributes of the injected runtime components (safe - enough from a second thread); the aircraft list and conflict counters are reused - from the last snapshot built on the simulation thread. - """ + status_builder = self._status_builder + if status_builder is None: + raise RuntimeError("Tangram bridge is stopped") + status = status_builder() last = self._last_payload - sim = self.simulation - state = int(sim.state) siminfo: TangramSimInfo = { - "simt": float(sim.simt), - "simdt": float(sim.simdt), - "simutc": sim.utc.isoformat(), - "speed": float(self.runner.speed), - "ntraf": int(self.traffic.ntraf), - "state": state, - "state_name": _state_name(state), - "scenname": self.get_scenname(), + "simt": status.simt, + "simdt": status.simdt, + "simutc": status.simutc.isoformat(), + "speed": status.speed, + "ntraf": status.ntraf, + "state": status.state, + "state_name": _state_name(status.state), + "scenname": status.scenname, "nconf_cur": last["siminfo"]["nconf_cur"] if last is not None else 0, "nlos_cur": last["siminfo"]["nlos_cur"] if last is not None else 0, } @@ -401,9 +370,10 @@ def _run(self) -> None: if isinstance(topic, bytes): topic = topic.decode("utf-8", errors="replace") if topic == command_topic: - cmd = extract_command(message.get("data", "")) - if cmd: - self.stack_command(cmd) + command = extract_command(message.get("data", "")) + stack_command = self._stack_command + if command and stack_command is not None: + stack_command(command) published = False while True: @@ -418,8 +388,6 @@ def _run(self) -> None: if published: last_publish = time.monotonic() elif time.monotonic() - last_publish > HEARTBEAT_SECS: - # Publish even before the first snapshot (INIT state, no - # traffic yet) so the frontend sees the simulator at all. client.publish(data_topic, json.dumps(self._siminfo_heartbeat())) self.published += 1 last_publish = time.monotonic() @@ -429,65 +397,38 @@ def _run(self) -> None: while self._console: lines.append(self._console.popleft()) client.publish(console_topic, json.dumps({"lines": lines})) - except Exception as e: # noqa: BLE001 - reconnect on any Redis failure + except Exception as exc: # noqa: BLE001 - reconnect after transport failure self.connected = False self.ready.clear() - self.last_error = str(e) + self.last_error = str(exc) if self._stop.wait(timeout=2.0): return -def init_plugin( - runtime: MiniSky, -) -> tuple[dict[str, Any], dict[str, list[Any]]]: - """Create the bridge and register its simulation hooks for one runtime. +# --8<-- [start:lifespan] +def build(context: plugin_api.PluginContext[TangramConfig]) -> plugin_api.PluginSpec: + bridge = context.mount( + TangramBridge( + redis_url=context.config.redis_url, + channel=context.config.channel, + max_hz=context.config.max_hz, + ) + ) - The returned state, lifecycle callbacks, shutdown callback, and TANGRAM - command are all bound to the supplied runtime. Loading the plugin in a - second runtime therefore creates an independent Redis bridge. + @asynccontextmanager + async def lifespan(runtime: plugin_api.PluginRuntime) -> AsyncGenerator[None]: + runtime.subscribe_console(bridge.capture_console) + success, message = bridge.start(runtime) + runtime.echo(message) + if not success: + raise RuntimeError(message) + try: + yield + finally: + bridge.stop() - Args: - runtime: MiniSky runtime loading the plugin. + return context.finish(lifespan=lifespan) - Returns: - A `(config, stack_functions)` tuple consumed by the runtime-owned - plugin manager. - """ - extras = runtime.settings.model_extra or {} - cfg = TangramPluginSettings.model_validate({"tangram": extras.get("tangram", {})}).tangram - bridge = TangramBridge( - redis_url=cfg.redis_url, - channel=cfg.channel, - max_hz=cfg.max_hz, - snapshot_builder=lambda: build_snapshot( - runtime.simulation, runtime.traffic, runtime.runner, runtime.commands - ), - console=runtime.console, - simulation=runtime.simulation, - runner=runtime.runner, - traffic=runtime.traffic, - get_scenname=runtime.commands.get_scenname, - stack_command=runtime.commands.stack, - ) - success, message = bridge.start() - runtime.console.echo(message) - if not success: - raise RuntimeError(message) - - config = { - "plugin_name": "TANGRAM", - "update_interval": 0.0, - "update": bridge.tick, - "reset": bridge.reset, - "shutdown": bridge.stop, - "state": bridge, - } - stack_functions = { - "TANGRAM": [ - bridge.status, - "", - "TANGRAM", - "Show the status of the tangram Redis bridge.", - ] - } - return config, stack_functions + +plugin = plugin_api.Plugin(build=build, config_class=TangramConfig) +# --8<-- [end:lifespan] diff --git a/packages/minisky/minisky/__init__.py b/packages/minisky/minisky/__init__.py new file mode 100644 index 0000000..004f1c2 --- /dev/null +++ b/packages/minisky/minisky/__init__.py @@ -0,0 +1,31 @@ +"""MiniSky air traffic simulator. + +[`MiniSky`][minisky.runtime.MiniSky] is the explicit ownership root for a +simulator runtime. Construct it without arguments to use the optional default +user config, or pass a validated [`MiniSkyConfig`][] explicitly. +""" + +from minisky.core.config import ( + MiniSkyConfig, + default_user_config_dir, + default_user_config_toml_path, +) +from minisky.runtime import MiniSky +from minisky.simulation import SimulationState + +BS_OK = 0 +BS_ARGERR = 1 +BS_FUNERR = 2 +BS_CMDERR = 4 + +__all__ = ( + "BS_ARGERR", + "BS_CMDERR", + "BS_FUNERR", + "BS_OK", + "default_user_config_dir", + "default_user_config_toml_path", + "MiniSky", + "MiniSkyConfig", + "SimulationState", +) diff --git a/minisky/cli.py b/packages/minisky/minisky/cli.py similarity index 68% rename from minisky/cli.py rename to packages/minisky/minisky/cli.py index f8c7b53..e19bc23 100644 --- a/minisky/cli.py +++ b/packages/minisky/minisky/cli.py @@ -5,8 +5,10 @@ import asyncio import json import os +import tomllib +from pathlib import Path from pprint import pprint -from typing import TYPE_CHECKING, Annotated +from typing import TYPE_CHECKING, Annotated, TypeAlias import requests import typer @@ -15,30 +17,55 @@ from prompt_toolkit import prompt from prompt_toolkit.completion import NestedCompleter, PathCompleter from prompt_toolkit.history import FileHistory +from pydantic import ValidationError + +from minisky.core.config import MiniSkyConfig if TYPE_CHECKING: from minisky.runtime import MiniSky app = typer.Typer(help="MiniSky command-line tools.", no_args_is_help=True) +_ConfigOption: TypeAlias = Annotated[ + Path | None, + typer.Option(help="Config TOML file. Overrides the default user config path."), +] + history_file = os.path.expanduser("/tmp/hacksky_console_history") path_completer = PathCompleter() completer = NestedCompleter.from_nested_dict({"load": path_completer, "/load": path_completer}) -def _new_runtime(scenario: str | None = None) -> MiniSky: - """Construct a runtime from the default settings.""" - from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings +def _load_config(path: Path | None) -> MiniSkyConfig | None: + if path is None: + return None + + selected = path.expanduser() + try: + return MiniSkyConfig.from_path(selected) + except FileNotFoundError as exc: + raise typer.BadParameter( + f"config file not found: {selected}", + param_hint="--config", + ) from exc + except (tomllib.TOMLDecodeError, ValidationError) as exc: + raise typer.BadParameter( + f"invalid config file {selected}: {exc}", + param_hint="--config", + ) from exc - settings = MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE) - runtime = MiniSky(settings, scenario) - return runtime +def _new_runtime(config_path: Path | None, scenario: str | None = None) -> MiniSky: + """Construct a runtime from explicit or default configuration.""" + from minisky import MiniSky -async def _run_scenario(scenario: str, speed: int) -> None: + return MiniSky(config=_load_config(config_path), scenario=scenario) + + +async def _run_scenario(scenario: str, speed: int, config_path: Path | None) -> None: """Initialise the simulator with a scenario and run it to completion.""" - async with _new_runtime(scenario) as runtime: - runtime.load_plugins() + async with _new_runtime(config_path, scenario) as runtime: + await runtime.plugins.load_configured() runtime.runner.speed = speed await runtime.run() @@ -47,9 +74,10 @@ async def _run_scenario(scenario: str, speed: int) -> None: def run_cmd( scenario: Annotated[str, typer.Option(help="Scenario (.scn) file to run.")], speed: Annotated[int, typer.Option(help="Simulation speed multiplier.")] = 1, + config: _ConfigOption = None, ) -> None: """Run a scenario file without interaction.""" - asyncio.run(_run_scenario(scenario, speed)) + asyncio.run(_run_scenario(scenario, speed, config)) @app.command("server") @@ -61,16 +89,34 @@ def server_cmd( os.environ.get("MINISKY_PORT", "8000") ), reload: Annotated[bool, typer.Option(help="Enable uvicorn auto-reload.")] = False, + config: _ConfigOption = None, ) -> None: """Start the REST and WebSocket API server.""" import uvicorn + # NOTE(abraham): we want config to be explicit. + if reload and config is not None: + raise typer.BadParameter( + "--config cannot be combined with --reload yet", + param_hint="--config", + ) + + if reload: + uvicorn.run( + "minisky.server:create_app", + factory=True, + host=host, + port=port, + reload=True, + ) + return + + from minisky.server import create_app + uvicorn.run( - "minisky.server:create_app", - factory=True, + create_app(_new_runtime(config)), host=host, port=port, - reload=reload, ) diff --git a/packages/minisky/minisky/core/__init__.py b/packages/minisky/minisky/core/__init__.py new file mode 100644 index 0000000..e49dca4 --- /dev/null +++ b/packages/minisky/minisky/core/__init__.py @@ -0,0 +1,21 @@ +"""Core simulation infrastructure of MiniSky. + +Contains configuration loading, per-aircraft array bookkeeping, and the +variable explorer. The classes TrafficArrays and RegisterElementParameters, +which all traffic-related simulation entities build on, are re-exported here +for convenience. +""" + +from __future__ import annotations + +from minisky.core.trafficarrays import RegisterElementParameters, TrafficArrays + +from . import config, trafficarrays, varexplorer + +__all__ = ( + "RegisterElementParameters", + "TrafficArrays", + "config", + "trafficarrays", + "varexplorer", +) diff --git a/packages/minisky/minisky/core/config.py b/packages/minisky/minisky/core/config.py new file mode 100644 index 0000000..d59728d --- /dev/null +++ b/packages/minisky/minisky/core/config.py @@ -0,0 +1,55 @@ +"""MiniSky configuration.""" + +from __future__ import annotations + +import tomllib +from os import PathLike +from pathlib import Path +from typing import Annotated, Any, TypeAlias + +import annotated_types +from pydantic import BaseModel, ConfigDict, Field +from pydantic.functional_validators import BeforeValidator + +from minisky.identifiers import validate_plugin_id + +PluginId: TypeAlias = Annotated[str, BeforeValidator(validate_plugin_id)] + + +class MiniSkyConfig(BaseModel): + """Validated configuration for a MiniSky runtime.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + asas_dtlookahead: Annotated[float, Field(), annotated_types.Ge(0)] = 300.0 + asas_pzr: Annotated[float, Field(), annotated_types.Gt(0)] = 5.0 + asas_pzh: Annotated[float, Field(), annotated_types.Gt(0)] = 1000.0 + asas_marh: Annotated[float, Field(), annotated_types.Gt(0)] = 1.05 + asas_marv: Annotated[float, Field(), annotated_types.Gt(0)] = 1.05 + plugins: dict[PluginId, dict[str, Any]] = Field(default_factory=dict) + + @classmethod + def from_path(cls, path: str | PathLike[str]) -> MiniSkyConfig: + """Load and validate configuration from an explicit TOML path.""" + with Path(path).expanduser().open("rb") as file: + return cls.model_validate(tomllib.load(file)) + + +PACKAGE_DATA_DIR = Path(__file__).parent.parent / "data" + + +def default_user_config_dir() -> Path: + """Return the platform-specific default MiniSky config directory.""" + from platformdirs import user_config_path + + return user_config_path("minisky", appauthor=False) + + +def default_user_config_toml_path() -> Path: + """Return the optional default MiniSky TOML config path.""" + return default_user_config_dir() / "config.toml" + + +def data(path: str) -> Path: + """Return an absolute path inside the package data directory.""" + return PACKAGE_DATA_DIR / path diff --git a/minisky/core/trafficarrays.py b/packages/minisky/minisky/core/trafficarrays.py similarity index 58% rename from minisky/core/trafficarrays.py rename to packages/minisky/minisky/core/trafficarrays.py index 8077c74..92dc998 100644 --- a/minisky/core/trafficarrays.py +++ b/packages/minisky/minisky/core/trafficarrays.py @@ -1,8 +1,8 @@ """TrafficArrays: Base class for per-aircraft data arrays. Classes that derive from TrafficArrays get automated create, delete, and reset -functionality for all registered child arrays. All subclasses are automatically -replaceable via SELECTIMPL - see minisky/plugin/ for usage examples. +functionality for all registered child arrays. Replaceable implementations are +registered explicitly in a runtime before `SELECTIMPL` can use them. MiniSky stores aircraft state as parallel numpy arrays and lists, where index i in every array belongs to the same aircraft. Per-aircraft @@ -22,176 +22,190 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +import inspect +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from functools import wraps from types import MappingProxyType -from typing import TYPE_CHECKING +from typing import Any import numpy as np -if TYPE_CHECKING: - from minisky.stack import Command +from minisky.identifiers import normalize_public_name +defaults = MappingProxyType({"float": 0.0, "int": 0, "uint": 0, "bool": False, "S": "", "str": ""}) -defaults = MappingProxyType( - {"float": 0.0, "int": 0, "uint": 0, "bool": False, "S": "", "str": ""} -) +@dataclass(frozen=True, slots=True) +class PreparedReplacement: + """A validated runtime-local replacement entry.""" -class ReplaceableManager: - """Own replaceable implementation choices for one traffic tree. + base: type[TrafficArrays] + name: str + implementation: type[TrafficArrays] - Replaceable base classes are discovered from the actual objects attached - to this manager's traffic tree. Alternative implementations are discovered - from each base class's Python subclass hierarchy. No process-wide registry - or selected implementation is maintained. - Attributes: - traffic: Root traffic object whose replaceable child instances are - inspected and replaced. - _get_command_registry: Lazy callback returning the owning runtime's - command registry so bound callbacks can be rebound after a - replacement. - """ +@dataclass(slots=True) +class _ComponentSlot: + """Stable access to a replaceable component attached to the traffic tree.""" + + traffic: TrafficArrays + attribute: str + base: type[TrafficArrays] + + @property + def current(self) -> TrafficArrays: + component = getattr(self.traffic, self.attribute) + if not isinstance(component, self.base): + raise RuntimeError(f"replaceable slot {self.attribute} has an invalid component") + return component + + def bind(self, callback: Callable[..., Any]) -> Callable[..., Any]: + method_name = callback.__name__ + + @wraps(callback) + def dispatch(*args: Any, **kwargs: Any) -> Any: + method = getattr(self.current, method_name) + return method(*args, **kwargs) + + return dispatch + + def replace(self, implementation: type[TrafficArrays]) -> None: + previous = self.current + replacement = previous.new_implementation(implementation) + if not isinstance(replacement, self.base): + raise TypeError( + f"replacement {type(replacement).__name__} must inherit {self.base.__name__}" + ) + + ntraf = int(getattr(self.traffic, "ntraf", 0)) + if ntraf: + replacement.create(ntraf) + replacement.create_children(ntraf) + if previous._parent is not None: + replacement.reparent(previous._parent) + + for name in previous._ArrVars: + if hasattr(replacement, name): + setattr(replacement, name, getattr(previous, name)) + for name in previous._LstVars: + if hasattr(replacement, name): + setattr(replacement, name, getattr(previous, name)) + + setattr(self.traffic, self.attribute, replacement) + previous.detach() + + +class ReplaceableManager: + """Own replacement implementations visible to a runtime.""" def __init__( self, traffic: TrafficArrays, - get_command_registry: Callable[[], Mapping[str, Command]], + *, + bases: Iterable[type[TrafficArrays]], + core: Iterable[type[TrafficArrays]] = (), ) -> None: self.traffic = traffic - self._get_command_registry = get_command_registry - - def _instance(self, base: type[TrafficArrays]) -> TrafficArrays | None: - """Return the instance of `base` attached directly to this traffic object.""" - return next( - (value for value in self.traffic.__dict__.values() if isinstance(value, base)), - None, - ) - - def _available(self) -> dict[str, type[TrafficArrays]]: - """Return replaceable base classes represented on this traffic tree. - - The runtime's actual component instances are the source of truth. This - avoids a mutable import-time catalog while retaining load-order - independence for plugin subclasses. - """ - available: dict[str, type[TrafficArrays]] = {} - for value in self.traffic.__dict__.values(): - if isinstance(value, TrafficArrays): - base = type(value).replaceable_base() - available[base.__name__.upper()] = base - return available + self._bases = {base.__name__.upper(): base for base in bases} + self._slots = {base: self._find_slot(base) for base in self._bases.values()} + self._implementations: dict[type[TrafficArrays], dict[str, type[TrafficArrays]]] = { + base: {base.__name__.upper(): base} for base in self._bases.values() + } + for implementation in core: + prepared = self.prepare(implementation) + self._implementations[prepared.base][prepared.name] = implementation + + # TODO(abraham): declare replaceable slots during Traffic construction + # instead of scanning attributes. + def _find_slot(self, base: type[TrafficArrays]) -> _ComponentSlot: + matches = [name for name, value in self.traffic.__dict__.items() if isinstance(value, base)] + if len(matches) != 1: + raise ValueError( + f"expected exactly one {base.__name__} component, found {len(matches)}" + ) + return _ComponentSlot(self.traffic, matches[0], base) + + def bind_callback(self, callback: Callable[..., Any]) -> Callable[..., Any]: + """Return a callback that follows replacement selection when needed.""" + if not inspect.ismethod(callback): + return callback + for slot in self._slots.values(): + if callback.__self__ is slot.current: + return slot.bind(callback) + return callback + + def prepare( + self, + implementation: type[TrafficArrays], + *, + base: type[TrafficArrays] | None = None, + name: str = "", + ) -> PreparedReplacement: + if not isinstance(implementation, type) or not issubclass(implementation, TrafficArrays): + raise TypeError("replacement implementation must inherit TrafficArrays") + root = base or implementation.replaceable_base() + if self._bases.get(root.__name__.upper()) is not root: + raise ValueError(f"unsupported replacement base: {root.__name__}") + if not issubclass(implementation, root): + raise TypeError(f"replacement {implementation.__name__} must inherit {root.__name__}") + public_name = normalize_public_name(name or implementation.__name__) + if public_name in ("BASE", root.__name__.upper()): + raise ValueError(f"replacement name is reserved: {public_name}") + return PreparedReplacement(root, public_name, implementation) + + def validate(self, replacements: tuple[PreparedReplacement, ...]) -> None: + seen: set[tuple[type[TrafficArrays], str]] = set() + for replacement in replacements: + key = (replacement.base, replacement.name) + if key in seen: + raise ValueError(f"replacement repeated: {replacement.name}") + if replacement.name in self._implementations[replacement.base]: + raise ValueError(f"replacement already registered: {replacement.name}") + seen.add(key) + + def install(self, replacements: tuple[PreparedReplacement, ...]) -> None: + for replacement in replacements: + self._implementations[replacement.base][replacement.name] = replacement.implementation + + def remove(self, replacements: tuple[PreparedReplacement, ...]) -> None: + for replacement in reversed(replacements): + slot = self._slots[replacement.base] + if type(slot.current) is replacement.implementation: + # NOTE(abraham): replacements are synchronous strategies. + slot.replace(replacement.base) + implementations = self._implementations[replacement.base] + if implementations.get(replacement.name) is replacement.implementation: + del implementations[replacement.name] def select(self, basename: str = "", implname: str = "") -> tuple[bool, str]: - """Select an implementation for a replaceable class. - - Arguments: - - basename: Name of the replaceable base class, for example - `AUTOPILOT`. - - implname: Name of the implementation to select, for example - `CUSTOMAUTOPILOT`. - - Returns: - A `(success, message)` tuple. With no arguments, the message lists - the replaceable classes available on this runtime. With only a - base name, it reports the current and available implementations. - """ - available = self._available() if not basename: - return True, "Replaceable classes in MiniSky:\n" + ", ".join(sorted(available)) + return True, "Replaceable classes in MiniSky:\n" + ", ".join(sorted(self._bases)) - base = available.get(basename.upper()) + base = self._bases.get(basename.upper()) if base is None: return False, f"Replaceable {basename} not found." - - impls = base.derived() - current_instance = self._instance(base) - current = type(current_instance) if current_instance is not None else base + implementations = self._implementations[base] + slot = self._slots[base] + current = type(slot.current) if not implname: return True, ( f"Current implementation for {basename}: {current.__name__}\n" - f"Available implementations: {', '.join(sorted(impls))}" + f"Available implementations: {', '.join(sorted(implementations))}" ) - impl = impls.get(base.__name__.upper() if implname.upper() == "BASE" else implname.upper()) - if impl is None: + requested = base.__name__.upper() if implname.upper() == "BASE" else implname.upper() + implementation = implementations.get(requested) + if implementation is None: return False, f"Implementation {implname} not found for {basename}." - - if current is not impl: - replaced = _replace_instance_on_traf( - base, impl, self.traffic, self._get_command_registry() - ) - if not replaced: - return False, f"No {basename} instance exists on this traffic tree." - + if current is not implementation: + slot.replace(implementation) return True, f"Selected {implname} for {basename}" def reset(self) -> None: - """Reset all replaceables to their base implementation. - - Every replaceable component currently attached to this runtime's - traffic object is reinstantiated with its base implementation. Existing - per-aircraft arrays are preserved and stack commands bound to the old - object are rebound to the replacement. - """ - registry = self._get_command_registry() - for base in self._available().values(): - current = self._instance(base) - if current is not None and type(current) is not base: - _replace_instance_on_traf(base, base, self.traffic, registry) - - -def _replace_instance_on_traf( - base: type[TrafficArrays], - impl: type[TrafficArrays], - traffic: TrafficArrays, - cmddict: Mapping[str, Command], -) -> bool: - """Replace an existing instance of `base` on traffic with `impl`. - - This ensures `SELECTIMPL` takes effect immediately, not just for future - instantiations. It returns `True` when a matching component was found and - replaced, and `False` otherwise. - """ - # Find the attribute on traffic that contains an instance of the base class. - for attr_name, attr_value in traffic.__dict__.items(): - if isinstance(attr_value, base): - # Create a new instance with the old component's runtime dependencies. - new_instance = attr_value.new_implementation(impl) - if attr_value._parent is not None: - new_instance.reparent(attr_value._parent) - - # Copy any existing per-aircraft array and list data to the replacement. - for arr_var in getattr(attr_value, "_ArrVars", []): - if hasattr(new_instance, arr_var): - setattr(new_instance, arr_var, getattr(attr_value, arr_var)) - for lst_var in getattr(attr_value, "_LstVars", []): - if hasattr(new_instance, lst_var): - setattr(new_instance, lst_var, getattr(attr_value, lst_var)) - - # Replace the traffic attribute and detach the old tree node. - setattr(traffic, attr_name, new_instance) - if attr_value._parent is not None: - attr_value._parent._children.remove(attr_value) - - # Commands bound to the old instance would otherwise mutate an orphan. - _rebind_stack_commands(attr_value, new_instance, cmddict) - return True - return False - - -def _rebind_stack_commands( - old_instance: TrafficArrays, - new_instance: TrafficArrays, - cmddict: Mapping[str, Command], -) -> None: - """Rebind stack command callbacks from `old_instance` to `new_instance`.""" - import inspect - - for cmdobj in set(cmddict.values()): - callback = cmdobj.callback - if inspect.ismethod(callback) and callback.__self__ is old_instance: - cmdobj.callback = getattr(new_instance, callback.__func__.__name__, callback) + for base, slot in self._slots.items(): + if type(slot.current) is not base: + slot.replace(base) class RegisterElementParameters: @@ -228,9 +242,9 @@ class TrafficArrays: that all registered per-aircraft arrays in the simulation keep the same length as the number of aircraft. - Replaceable implementations are discovered from the Python subclass - hierarchy by `ReplaceableManager`; selection state belongs to an - individual runtime rather than to this class. + Replaceable implementations are registered explicitly with + `ReplaceableManager`; selection state belongs to an individual runtime + rather than to this class. Attributes: _parent: Parent node of this object in the tree. @@ -253,16 +267,6 @@ def replaceable_base(cls) -> type[TrafficArrays]: return candidate return cls - # TODO(abraham): replace process-wide subclass discovery with plugin declarations - # or ideally remove implementation inheritance altogether - @classmethod - def derived(cls): - """Recursively find all derived classes.""" - ret = {cls.__name__.upper(): cls} - for sub in cls.__subclasses__(): - ret.update(sub.derived()) - return ret - def __init__(self, parent: TrafficArrays | None = None) -> None: """Create a TrafficArrays node, optionally attached to `parent`. diff --git a/minisky/core/varexplorer.py b/packages/minisky/minisky/core/varexplorer.py similarity index 91% rename from minisky/core/varexplorer.py rename to packages/minisky/minisky/core/varexplorer.py index 550f48a..d965a37 100644 --- a/minisky/core/varexplorer.py +++ b/packages/minisky/minisky/core/varexplorer.py @@ -23,7 +23,7 @@ class VariableExplorer: - """Searchable simulation data sources owned by one MiniSky runtime.""" + """Searchable simulation data sources owned by a MiniSky runtime.""" def __init__(self) -> None: # The variable lists and their corresponding sources @@ -42,6 +42,11 @@ def init(self, simulation: Any, traffic: Any) -> None: ] ) + def validate_data_parent(self, name: str) -> None: + """Reject a top-level name already owned by another data source.""" + if name in self.varlist: + raise ValueError(f"variable parent already registered: {name}") + def register_data_parent(self, obj: Any, name: str) -> None: """Register an object as a searchable data source of the variable explorer. @@ -51,9 +56,11 @@ def register_data_parent(self, obj: Any, name: str) -> None: """ self.varlist[name] = (obj, getvarsfromobj(obj)) - def unregister_data_parent(self, name: str) -> None: - """Remove a previously registered top-level data source.""" - self.varlist.pop(name, None) + def unregister_data_parent(self, name: str, *, expected: object | None = None) -> None: + """Remove a parent only while it still refers to the expected object.""" + current = self.varlist.get(name) + if current is not None and (expected is None or current[0] is expected): + del self.varlist[name] def lsvar(self, varname: str = "") -> tuple[bool, str]: """Stack function to list information on simulation variables in the diff --git a/minisky/data/navigation/airport.parquet b/packages/minisky/minisky/data/navigation/airport.parquet similarity index 100% rename from minisky/data/navigation/airport.parquet rename to packages/minisky/minisky/data/navigation/airport.parquet diff --git a/minisky/data/navigation/airway.parquet b/packages/minisky/minisky/data/navigation/airway.parquet similarity index 100% rename from minisky/data/navigation/airway.parquet rename to packages/minisky/minisky/data/navigation/airway.parquet diff --git a/minisky/data/navigation/country.parquet b/packages/minisky/minisky/data/navigation/country.parquet similarity index 100% rename from minisky/data/navigation/country.parquet rename to packages/minisky/minisky/data/navigation/country.parquet diff --git a/minisky/data/navigation/fir.json b/packages/minisky/minisky/data/navigation/fir.json similarity index 100% rename from minisky/data/navigation/fir.json rename to packages/minisky/minisky/data/navigation/fir.json diff --git a/minisky/data/navigation/geo_declination_data.csv b/packages/minisky/minisky/data/navigation/geo_declination_data.csv similarity index 100% rename from minisky/data/navigation/geo_declination_data.csv rename to packages/minisky/minisky/data/navigation/geo_declination_data.csv diff --git a/minisky/data/navigation/runway_thresholds.json b/packages/minisky/minisky/data/navigation/runway_thresholds.json similarity index 100% rename from minisky/data/navigation/runway_thresholds.json rename to packages/minisky/minisky/data/navigation/runway_thresholds.json diff --git a/minisky/data/navigation/waypoint.parquet b/packages/minisky/minisky/data/navigation/waypoint.parquet similarity index 100% rename from minisky/data/navigation/waypoint.parquet rename to packages/minisky/minisky/data/navigation/waypoint.parquet diff --git a/minisky/data/performance/openap/rotor/aircraft.json b/packages/minisky/minisky/data/performance/openap/rotor/aircraft.json similarity index 100% rename from minisky/data/performance/openap/rotor/aircraft.json rename to packages/minisky/minisky/data/performance/openap/rotor/aircraft.json diff --git a/packages/minisky/minisky/identifiers.py b/packages/minisky/minisky/identifiers.py new file mode 100644 index 0000000..b307f1f --- /dev/null +++ b/packages/minisky/minisky/identifiers.py @@ -0,0 +1,28 @@ +"""Validation for public MiniSky identifiers.""" + +from __future__ import annotations + +import re + +_PLUGIN_ID = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)*$") +_RESERVED_PLUGIN_IDS = frozenset({"sim", "traf"}) + + +def validate_plugin_id(value: object) -> str: + """Validate a canonical lowercase plugin ID.""" + if not isinstance(value, str) or not _PLUGIN_ID.fullmatch(value): + raise ValueError(f"invalid plugin id: {value!r}") + if value in _RESERVED_PLUGIN_IDS: + raise ValueError(f"reserved plugin id: {value!r}") + return value + + +_PUBLIC_NAME = re.compile(r"^[A-Z][A-Z0-9_]*$") + + +def normalize_public_name(value: str) -> str: + """Normalize a command or replacement name.""" + name = value.strip().upper() + if not _PUBLIC_NAME.fullmatch(name): + raise ValueError(f"invalid public name: {value!r}") + return name diff --git a/packages/minisky/minisky/plugin/__init__.py b/packages/minisky/minisky/plugin/__init__.py new file mode 100644 index 0000000..1d5138a --- /dev/null +++ b/packages/minisky/minisky/plugin/__init__.py @@ -0,0 +1,34 @@ +"""Public contracts for runtime-local MiniSky plugins. + +Plugin packages can expose a [Plugin][minisky.plugin.Plugin] value through +the `minisky.plugins` entry-point group. +""" + +from __future__ import annotations + +from minisky.plugin.entity import Entity +from minisky.plugin.plugin import ( + Plugin, + PluginContext, + PluginError, + PluginManager, + PluginRuntime, + PluginSpec, + PluginStatus, +) +from minisky.plugin.plugin_decorators import HookName, command, hook, replacement + +__all__ = ( + "Entity", + "HookName", + "Plugin", + "PluginContext", + "PluginError", + "PluginManager", + "PluginRuntime", + "PluginSpec", + "PluginStatus", + "command", + "hook", + "replacement", +) diff --git a/packages/minisky/minisky/plugin/entity.py b/packages/minisky/minisky/plugin/entity.py new file mode 100644 index 0000000..b9d5f5c --- /dev/null +++ b/packages/minisky/minisky/plugin/entity.py @@ -0,0 +1,80 @@ +"""Per-aircraft state for MiniSky plugins.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from minisky.core.trafficarrays import TrafficArrays + +if TYPE_CHECKING: + from minisky.traffic import Traffic + + +class Entity(TrafficArrays): + """Base class for plugin-owned per-aircraft arrays. + + Create a fresh entity in every plugin build and declare arrays or lists + inside `with self.settrafarrays():`. MiniSky sizes and attaches the entity + when the plugin loads, then retires it during shutdown. + + `traffic` is available during initial backfill and while the plugin is active. + Do not use it in `__init__`. + """ + + def __init__(self) -> None: + super().__init__() + self._traffic: Traffic | None = None + self._prepared_traffic: Traffic | None = None + self._retired = False + + @property + def traffic(self) -> Traffic: + """Return traffic during initial backfill and while active.""" + traffic = self._traffic if self._traffic is not None else self._prepared_traffic + if traffic is None: + raise RuntimeError("plugin entity is detached") + return traffic + + @property + def ownerless(self) -> bool: + return ( + not self._retired + and self._parent is None + and self._traffic is None + and self._prepared_traffic is None + ) + + def _prepare(self, traffic: Traffic) -> None: + """Size arrays for existing traffic without exposing live traffic.""" + if not self.ownerless: + raise RuntimeError("plugin entity must be fresh and detached") + self._prepared_traffic = traffic + try: + if traffic.ntraf: + self.create(traffic.ntraf) + except BaseException: + self._prepared_traffic = None + TrafficArrays.reset(self) + raise + + def _publish(self) -> None: + traffic = self._prepared_traffic + if traffic is None or self._parent is not None: + raise RuntimeError("plugin entity is not prepared") + self.reparent(traffic) + self._traffic = traffic + self._prepared_traffic = None + + def _abort(self) -> None: + """Undo preparation after a failed load.""" + self.detach() + self._traffic = None + self._prepared_traffic = None + TrafficArrays.reset(self) + + def _retire(self) -> None: + """Detach permanently when the owning plugin stops.""" + self.detach() + self._traffic = None + self._prepared_traffic = None + self._retired = True diff --git a/packages/minisky/minisky/plugin/plugin.py b/packages/minisky/minisky/plugin/plugin.py new file mode 100644 index 0000000..b30e756 --- /dev/null +++ b/packages/minisky/minisky/plugin/plugin.py @@ -0,0 +1,679 @@ +"""Runtime-owned declarations and loading for installed MiniSky plugins.""" + +from __future__ import annotations + +import asyncio +import inspect +import math +import traceback +from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from copy import deepcopy +from dataclasses import dataclass +from datetime import datetime +from enum import Enum, auto +from importlib import metadata +from random import Random +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, Generic, TypeVar + +from pydantic import TypeAdapter + +from minisky.core.trafficarrays import PreparedReplacement, TrafficArrays +from minisky.identifiers import validate_plugin_id +from minisky.plugin.entity import Entity +from minisky.plugin.plugin_decorators import ( + HookName, + declared_commands, + declared_hooks, + declared_replacement, +) +from minisky.streaming import Snapshot, build_snapshot + +if TYPE_CHECKING: + from minisky.core.config import MiniSkyConfig + from minisky.core.varexplorer import VariableExplorer + from minisky.runtime import MiniSky + from minisky.simulation import ConsoleIO, Simulation + from minisky.simulation.console import ConsoleSubscription + from minisky.stack import CommandStack, PreparedCommand + +ConfigT = TypeVar("ConfigT") +ComponentT = TypeVar("ComponentT") +CommandReply = tuple[bool, str] + + +class PluginError(RuntimeError): + """A plugin declaration or lifecycle operation failed.""" + + +@dataclass(frozen=True, slots=True) +class PluginStatus: + """Read-only scalar runtime status.""" + + simt: float + simdt: float + simutc: datetime + speed: float + ntraf: int + state: int + scenname: str + + +class _PluginRuntimeState(Enum): + STARTING = auto() + PUBLISHED = auto() + REVOKED = auto() + + +class PluginRuntime: + """Restricted runtime capabilities available during a plugin lifespan.""" + + def __init__( + self, + *, + status: Callable[[], PluginStatus], + snapshot: Callable[[], Snapshot], + echo: Callable[[str], None], + subscribe_console: Callable[[Callable[[str], None]], ConsoleSubscription], + stack_command: Callable[[str], None], + ) -> None: + self._status = status + self._snapshot = snapshot + self._echo = echo + self._subscribe_console = subscribe_console + self._stack_command = stack_command + self._subscriptions: list[ConsoleSubscription] = [] + self._state = _PluginRuntimeState.STARTING + + def status(self) -> PluginStatus: + self._raise_if_revoked() + return self._status() + + def snapshot(self) -> Snapshot: + self._raise_if_revoked() + return self._snapshot() + + def echo(self, text: str) -> None: + self._raise_if_revoked() + self._echo(text) + + def stack_command(self, command: str) -> None: + self._raise_if_revoked() + if self._state is not _PluginRuntimeState.PUBLISHED: + raise RuntimeError("plugin runtime is not published") + self._stack_command(command) + + def subscribe_console(self, callback: Callable[[str], None]) -> ConsoleSubscription: + self._raise_if_revoked() + subscription = self._subscribe_console(callback) + self._subscriptions.append(subscription) + return subscription + + def _activate(self) -> None: + self._raise_if_revoked() + self._state = _PluginRuntimeState.PUBLISHED + + def _revoke(self) -> None: + if self._state is _PluginRuntimeState.REVOKED: + return + self._state = _PluginRuntimeState.REVOKED + for subscription in reversed(self._subscriptions): + subscription.close() + self._subscriptions.clear() + + def _raise_if_revoked(self) -> None: + if self._state is _PluginRuntimeState.REVOKED: + raise RuntimeError("plugin runtime is revoked") + + +PluginLifespan = Callable[[PluginRuntime], AbstractAsyncContextManager[None]] + + +@asynccontextmanager +async def _noop_lifespan(_runtime: PluginRuntime) -> AsyncGenerator[None]: + yield + + +@dataclass(frozen=True, slots=True) +class PluginSpec: + """Components and resources built for a runtime.""" + + components: tuple[object, ...] + state: object | None = None + replacements: tuple[type[TrafficArrays], ...] = () + lifespan: PluginLifespan = _noop_lifespan + + +class PluginContext(Generic[ConfigT]): + """Build fresh plugin components for a runtime.""" + + def __init__(self, config: ConfigT, python_random: Random) -> None: + self.config = config + self.python_random = python_random + self._components: list[object] = [] + self._state: object | None = None + self._finished = False + + def mount(self, component: ComponentT, *, expose: bool = True) -> ComponentT: + """Add a component and optionally expose it through variable lookup.""" + if self._finished: + raise RuntimeError("plugin context has already been finished") + if component is None: + raise TypeError("plugin component must not be None") + if any(existing is component for existing in self._components): + raise PluginError("plugin component mounted more than once") + if expose and self._state is not None: + raise PluginError("a plugin may expose only a state component") + self._components.append(component) + if expose: + self._state = component + return component + + def finish( + self, + *, + replacements: Iterable[type[TrafficArrays]] = (), + lifespan: PluginLifespan = _noop_lifespan, + ) -> PluginSpec: + """Finish this context and return its immutable specification.""" + if self._finished: + raise RuntimeError("plugin context has already been finished") + self._finished = True + implementations = tuple(replacements) + if not all(isinstance(implementation, type) for implementation in implementations): + raise TypeError("plugin replacements must be classes") + return PluginSpec(tuple(self._components), self._state, implementations, lifespan) + + +PluginBuild = Callable[[PluginContext[Any]], PluginSpec] + + +def _empty_build(context: PluginContext[Any]) -> PluginSpec: + return context.finish() + + +# TODO(abraham): preserve the config type relation through entry-point metadata. +@dataclass(frozen=True, slots=True) +class Plugin: + """Declare a plugin build function and its optional configuration type.""" + + build: PluginBuild = _empty_build + config_class: type | None = None + + +@dataclass(slots=True) +class _Hook: + callback: Callable[..., Any] + phase: HookName + interval: float + name: str + accepts_dt: bool + elapsed: float = 0.0 + enabled: bool = True + + def due(self, simdt: float) -> tuple[bool, float]: + if self.interval <= 0: + return True, simdt + self.elapsed += simdt + if self.elapsed + 1e-12 < self.interval: + return False, 0.0 + elapsed, self.elapsed = self.elapsed, 0.0 + return True, elapsed + + +@dataclass(frozen=True, slots=True) +class _PreparedPlugin: + spec: PluginSpec + commands: tuple[PreparedCommand, ...] + hooks: tuple[_Hook, ...] + entities: tuple[Entity, ...] + replacements: tuple[PreparedReplacement, ...] + + def abort(self) -> None: + for entity in reversed(self.entities): + entity._abort() + + +@dataclass +class _PluginRecord: + """Entry-point metadata and active state for a runtime.""" + + entry_point: metadata.EntryPoint + plugin_name: str + loaded: bool = False + spec: PluginSpec | None = None + commands: tuple[PreparedCommand, ...] = () + hooks: tuple[_Hook, ...] = () + entities: tuple[Entity, ...] = () + replacements: tuple[PreparedReplacement, ...] = () + lifespan: AbstractAsyncContextManager[None] | None = None + runtime: PluginRuntime | None = None + + +class _ManagerState(Enum): + OPEN = auto() + CLOSING = auto() + CLOSED = auto() + + +class PluginManager: + """Discover, load, run, and close plugins for a runtime.""" + + def __init__( + self, + config: MiniSkyConfig, + console: ConsoleIO, + variables: VariableExplorer, + get_runtime: Callable[[], MiniSky], + get_simulation: Callable[[], Simulation], + get_command_stack: Callable[[], CommandStack], + ) -> None: + self.config = config + self.console = console + self.variables = variables + self._get_runtime = get_runtime + self._get_simulation = get_simulation + self._get_command_stack = get_command_stack + self.plugins: dict[str, _PluginRecord] = {} + self.loaded_plugins: dict[str, _PluginRecord] = {} + self._lock = asyncio.Lock() + self._state = _ManagerState.OPEN + + @property + def runtime(self) -> MiniSky: + return self._get_runtime() + + @property + def simulation(self) -> Simulation: + return self._get_simulation() + + @property + def commands(self) -> CommandStack: + return self._get_command_stack() + + @property + def requires_async_close(self) -> bool: + return bool(self.loaded_plugins) + + def discover(self) -> None: + """Discover installed plugin declarations without importing modules.""" + entries: dict[str, metadata.EntryPoint] = {} + duplicates: set[str] = set() + for entry_point in metadata.entry_points(group="minisky.plugins"): + try: + plugin_id = validate_plugin_id(entry_point.name) + except ValueError as exc: + self.console.echo(f"Ignoring plugin entry point {entry_point.name!r}: {exc}") + continue + + plugin_name = plugin_id.upper() + if plugin_name in duplicates: + continue + if plugin_name in entries: + entries.pop(plugin_name) + duplicates.add(plugin_name) + self.console.echo(f"Ignoring duplicate plugin entry point: {plugin_id}") + continue + entries[plugin_name] = entry_point + + for plugin_name, entry_point in entries.items(): + existing = self.plugins.get(plugin_name) + if existing is not None and existing.loaded: + continue + self.plugins[plugin_name] = _PluginRecord(entry_point, plugin_name) + + for plugin_name in duplicates: + existing = self.plugins.get(plugin_name) + if existing is None or not existing.loaded: + self.plugins.pop(plugin_name, None) + + async def load(self, name: str) -> CommandReply: + """Load a discovered plugin by name.""" + async with self._lock: + if self._state is not _ManagerState.OPEN: + return False, "Plugin manager is closed" + plugin = self.plugins.get(name.upper()) + if plugin is None: + return False, f"Error loading plugin: plugin {name} not found." + if plugin.loaded: + return False, f"Plugin {plugin.plugin_name} already loaded" + return await self._load(plugin) + + async def _load(self, plugin: _PluginRecord) -> CommandReply: + prepared: _PreparedPlugin | None = None + plugin_runtime: PluginRuntime | None = None + lifespan: AbstractAsyncContextManager[None] | None = None + entered = False + try: + declaration = plugin.entry_point.load() + if not isinstance(declaration, Plugin): + raise PluginError( + f"plugin {plugin.plugin_name.lower()} entry point must export Plugin" + ) + key = plugin.plugin_name.lower() + spec = self._build(key, declaration) + prepared = self._prepare(key, spec) + if spec.state is not None: + self.variables.validate_data_parent(key) + + plugin_runtime = self._plugin_runtime() + lifespan = spec.lifespan(plugin_runtime) + await lifespan.__aenter__() + entered = True + for entity in prepared.entities: + entity._prepare(self.runtime.traffic) + plugin_runtime._activate() + self._publish(key, prepared) + + plugin.loaded = True + plugin.spec = spec + plugin.commands = prepared.commands + plugin.hooks = prepared.hooks + plugin.entities = prepared.entities + plugin.replacements = prepared.replacements + plugin.lifespan = lifespan + plugin.runtime = plugin_runtime + self.loaded_plugins[plugin.plugin_name] = plugin + return True, f"Successfully loaded plugin {plugin.plugin_name}" + except BaseException as exc: + if prepared is not None: + prepared.abort() + if plugin_runtime is not None: + plugin_runtime._revoke() + if entered and lifespan is not None: + try: + await lifespan.__aexit__(type(exc), exc, exc.__traceback__) + except BaseException as cleanup_error: + traceback.print_exception(cleanup_error) + if not isinstance(exc, Exception): + raise + traceback.print_exception(exc) + return False, f"Error loading {plugin.plugin_name}: {exc}" + + def _build(self, key: str, declaration: Plugin) -> PluginSpec: + raw = deepcopy(self.config.plugins.get(key, {})) + if declaration.config_class is None: + if raw: + raise PluginError(f"plugin {key} does not accept configuration") + config: object = MappingProxyType({}) + else: + try: + config = TypeAdapter(declaration.config_class).validate_python(raw) + except Exception as exc: + raise PluginError(f"plugin {key} configuration is invalid: {exc}") from exc + + context = PluginContext(config, self.runtime.python_random) + spec = declaration.build(context) + if not isinstance(spec, PluginSpec): + raise PluginError(f"plugin {key} build did not return PluginSpec") + return spec + + def _prepare(self, key: str, spec: PluginSpec) -> _PreparedPlugin: + commands: list[PreparedCommand] = [] + command_names: set[str] = set() + for component in spec.components: + try: + for bound in declared_commands(component): + prepared = self.commands.prepare_command( + bound.callback, + name=bound.name, + aliases=bound.aliases, + arguments=bound.declaration.arguments, + brief=bound.brief, + help=bound.help, + ) + overlap = command_names.intersection(prepared.names) + if overlap: + raise PluginError(f"plugin {key} repeats command name: {min(overlap)}") + command_names.update(prepared.names) + commands.append(prepared) + except (TypeError, ValueError) as exc: + raise PluginError(str(exc)) from exc + command_tuple = tuple(commands) + self.commands.validate_commands(command_tuple) + + hooks: list[_Hook] = [] + for component in spec.components: + try: + for bound in declared_hooks(component): + hooks.append( + self._prepare_hook( + key, + bound.callback, + bound.hook, + bound.declaration.interval, + bound.name, + ) + ) + except (TypeError, ValueError) as exc: + raise PluginError(str(exc)) from exc + + replacements: list[PreparedReplacement] = [] + for implementation in spec.replacements: + declaration = declared_replacement(implementation) + try: + replacements.append( + self.runtime.replaceables.prepare( + implementation, + base=declaration.base, + name=declaration.name, + ) + ) + except (TypeError, ValueError) as exc: + raise PluginError(str(exc)) from exc + replacement_tuple = tuple(replacements) + self.runtime.replaceables.validate(replacement_tuple) + + entities = tuple( + component for component in spec.components if isinstance(component, Entity) + ) + + return _PreparedPlugin( + spec, + command_tuple, + tuple(hooks), + entities, + replacement_tuple, + ) + + @staticmethod + def _prepare_hook( + key: str, + callback: Callable[..., Any], + phase: HookName, + interval: float, + name: str, + ) -> _Hook: + if inspect.iscoroutinefunction(callback): + raise PluginError(f"plugin {key} hook {name} must be synchronous") + if not math.isfinite(interval) or interval < 0: + raise PluginError(f"plugin {key} hook {name} has invalid interval") + if phase in ("reset", "hold") and interval: + raise PluginError(f"plugin {key} gives interval to non-periodic {phase} hook") + + signature = inspect.signature(callback) + accepts_dt = phase in ("preupdate", "update") and "dt" in signature.parameters + try: + signature.bind(dt=0.0) if accepts_dt else signature.bind() + except TypeError as exc: + raise PluginError(f"plugin {key} hook {name} has incompatible signature") from exc + return _Hook(callback, phase, interval, name, accepts_dt) + + def _plugin_runtime(self) -> PluginRuntime: + runtime = self.runtime + return PluginRuntime( + status=lambda: PluginStatus( + float(runtime.simulation.simt), + float(runtime.simulation.simdt), + runtime.simulation.utc, + float(runtime.runner.speed), + int(runtime.traffic.ntraf), + int(runtime.simulation.state), + runtime.commands.get_scenname(), + ), + snapshot=lambda: build_snapshot( + runtime.simulation, + runtime.traffic, + runtime.runner, + runtime.commands, + ), + echo=self.console.echo, + subscribe_console=self.console.subscribe, + stack_command=self.commands.stack, + ) + + def _publish(self, key: str, prepared: _PreparedPlugin) -> None: + try: + for entity in prepared.entities: + entity._publish() + self.commands.install_commands(prepared.commands) + if prepared.spec.state is not None: + self.variables.register_data_parent(prepared.spec.state, key) + self.runtime.replaceables.install(prepared.replacements) + except BaseException: + self.commands.remove_commands(prepared.commands) + if prepared.spec.state is not None: + self.variables.unregister_data_parent(key, expected=prepared.spec.state) + self.runtime.replaceables.remove(prepared.replacements) + raise + + async def load_configured(self) -> tuple[str, ...]: + """Attempt every configured plugin and return those loaded successfully.""" + loaded: list[str] = [] + for plugin_name in self.config.plugins: + ok, message = await self.load(plugin_name) + self.console.echo(message) + if ok: + loaded.append(plugin_name.upper()) + return tuple(loaded) + + def listing(self) -> CommandReply: + running = set(self.loaded_plugins) + available = set(self.plugins) - running + text = f"\nLoaded plugins: {', '.join(sorted(running)) if running else '(none)'}" + if available: + text += f"\nAvailable plugins: {', '.join(sorted(available))}" + else: + text += "\nNo additional plugins available." + return True, text + + def manage( + self, command: str = "LIST", plugin_name: str = "" + ) -> CommandReply | Awaitable[CommandReply]: + """List available plugins or load a plugin through the command stack.""" + operation = command.strip().upper() + if operation in ("", "LIST"): + return self.listing() + if operation == "LOAD": + if not plugin_name.strip(): + return False, "plugin name is required" + return self.load(plugin_name) + if not plugin_name: + return self.load(command) + return False, f"Unknown command: {command}" + + def preupdate(self) -> None: + self._run_hooks("preupdate") + + def update(self) -> None: + self._run_hooks("update") + + def reset(self) -> None: + for plugin in self.loaded_plugins.values(): + for hook in plugin.hooks: + hook.elapsed = 0.0 + self._run_hooks("reset") + + def hold(self) -> None: + self._run_hooks("hold") + + def _run_hooks(self, phase: HookName) -> None: + if self._state is not _ManagerState.OPEN: + return + simdt = self.simulation.simdt + for plugin in tuple(self.loaded_plugins.values()): + for hook in plugin.hooks: + if not hook.enabled or hook.phase != phase: + continue + due, elapsed = hook.due(simdt) + if not due: + continue + try: + if hook.accepts_dt: + hook.callback(dt=elapsed) + else: + hook.callback() + except Exception as exc: + hook.enabled = False + traceback.print_exception(exc) + self.console.echo( + f"Plugin {plugin.plugin_name} disabled failing {phase} hook " + f"{hook.name}: {exc}" + ) + + async def aclose(self) -> None: + """Remove registrations and exit active lifespans in reverse order.""" + async with self._lock: + if self._state is _ManagerState.CLOSED: + return + self._state = _ManagerState.CLOSING + errors: list[Exception] = [] + for plugin in reversed(tuple(self.loaded_plugins.values())): + if plugin.runtime is not None: + plugin.runtime._revoke() + try: + self._remove(plugin) + except Exception as exc: + errors.append(exc) + + if plugin.lifespan is not None: + try: + await plugin.lifespan.__aexit__(None, None, None) + except Exception as exc: + errors.append(exc) + self._clear(plugin) + + self.loaded_plugins.clear() + self._state = _ManagerState.CLOSED + if errors: + raise ExceptionGroup("Plugin shutdown failed", errors) + + def _remove(self, plugin: _PluginRecord) -> None: + errors: list[Exception] = [] + cleanups: list[Callable[[], None]] = [ + lambda: self.commands.remove_commands(plugin.commands), + ] + state = plugin.spec.state if plugin.spec is not None else None + if state is not None: + cleanups.append( + lambda: self.variables.unregister_data_parent( + plugin.plugin_name.lower(), expected=state + ) + ) + cleanups.append(lambda: self.runtime.replaceables.remove(plugin.replacements)) + cleanups.extend(entity._retire for entity in reversed(plugin.entities)) + for cleanup in cleanups: + try: + cleanup() + except Exception as exc: + errors.append(exc) + if errors: + raise ExceptionGroup(f"Plugin {plugin.plugin_name} removal failed", errors) + + @staticmethod + def _clear(plugin: _PluginRecord) -> None: + plugin.loaded = False + plugin.spec = None + plugin.commands = () + plugin.hooks = () + plugin.entities = () + plugin.replacements = () + plugin.lifespan = None + plugin.runtime = None + + def close(self) -> None: + """Close a manager with no active plugin lifespans.""" + if self.loaded_plugins: + raise RuntimeError("active plugins require async close") + self._state = _ManagerState.CLOSED diff --git a/packages/minisky/minisky/plugin/plugin_decorators.py b/packages/minisky/minisky/plugin/plugin_decorators.py new file mode 100644 index 0000000..622261b --- /dev/null +++ b/packages/minisky/minisky/plugin/plugin_decorators.py @@ -0,0 +1,290 @@ +"""Decorators for plugin commands, hooks, and replacements. + +The decorators store metadata only. Typed plugins mount an instance with +[PluginContext][minisky.plugin.plugin.PluginContext] before MiniSky binds its +declarations to that runtime. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload + +from minisky.identifiers import normalize_public_name + +if TYPE_CHECKING: + from minisky.core.trafficarrays import TrafficArrays + +# +# commands +# + +CommandCallback = Callable[..., Any] +CommandTarget = TypeVar("CommandTarget", bound=CommandCallback) +_COMMAND = "__minisky_command__" + + +@dataclass(frozen=True, slots=True) +class CommandDeclaration: + """Command metadata stored on a decorated method.""" + + arguments: str = "" + name: str = "" + aliases: tuple[str, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "name", normalize_public_name(self.name) if self.name else "") + object.__setattr__( + self, + "aliases", + tuple(normalize_public_name(alias) for alias in self.aliases), + ) + + +@dataclass(frozen=True, slots=True) +class BoundCommand: + """A command declaration bound to a component instance.""" + + callback: CommandCallback + declaration: CommandDeclaration + + @property + def name(self) -> str: + return self.declaration.name or normalize_public_name(self.callback.__name__) + + @property + def aliases(self) -> tuple[str, ...]: + return self.declaration.aliases + + @property + def brief(self) -> str: + parameters: list[str] = [] + for parameter in inspect.signature(self.callback).parameters.values(): + name = parameter.name + if parameter.kind is inspect.Parameter.VAR_POSITIONAL: + name = f"{name},..." + if parameter.default is not inspect.Parameter.empty: + name = f"[{name}]" + parameters.append(name) + suffix = f" {','.join(parameters)}" if parameters else "" + return f"{self.name}{suffix}" + + @property + def help(self) -> str: + return inspect.cleandoc(inspect.getdoc(self.callback) or "") + + +@overload +def command(func: CommandTarget, /) -> CommandTarget: ... + + +@overload +def command( + *, + arguments: str = "", + name: str = "", + aliases: tuple[str, ...] = (), +) -> Callable[[CommandTarget], CommandTarget]: ... + + +def command( + func: CommandTarget | None = None, + /, + *, + arguments: str = "", + name: str = "", + aliases: tuple[str, ...] = (), +) -> CommandTarget | Callable[[CommandTarget], CommandTarget]: + """Declare an instance method as a stack command. + + The method name becomes the command name unless `name` is provided. Its + docstring becomes command help and its signature becomes the brief usage + text. + """ + + def decorate(target: CommandTarget) -> CommandTarget: + actual = _underlying_function(target) + if _COMMAND in vars(actual): + raise TypeError("a plugin command may be declared only once") + setattr(actual, _COMMAND, CommandDeclaration(arguments, name, aliases)) + return target + + return decorate(func) if func is not None else decorate + + +def declared_commands(component: object) -> Iterator[BoundCommand]: + """Bind command declarations to this exact component instance.""" + for attribute_name, value in _declaration_namespace(component).items(): + declaration = getattr(_underlying_function(value), _COMMAND, None) + if isinstance(declaration, CommandDeclaration): + yield BoundCommand(_bound_method(component, attribute_name, "command"), declaration) + + +# +# hooks +# + +HookCallback = Callable[..., Any] +HookTarget = TypeVar("HookTarget", bound=HookCallback) +HookName = Literal["preupdate", "update", "reset", "hold"] +_HOOKS = "__minisky_hooks__" +_HOOK_NAMES = frozenset({"preupdate", "update", "reset", "hold"}) + + +@dataclass(frozen=True, slots=True) +class HookDeclaration: + """Simulation-hook metadata stored on a decorated method.""" + + hook: HookName | None = None + interval: float = 0.0 + name: str = "" + + +@dataclass(frozen=True, slots=True) +class BoundHook: + """A hook declaration bound to a component instance.""" + + callback: HookCallback + declaration: HookDeclaration + + @property + def hook(self) -> HookName: + value = self.declaration.hook or self.callback.__name__.lower() + if value not in _HOOK_NAMES: + raise ValueError( + f"cannot infer plugin hook from {self.callback.__name__!r}; " + "specify preupdate, update, reset, or hold" + ) + return value # type: ignore[return-value] + + @property + def name(self) -> str: + return self.declaration.name or self.callback.__name__ + + +@overload +def hook(func: HookTarget, /) -> HookTarget: ... + + +@overload +def hook( + hook_name: HookName | None = None, + /, + *, + interval: float = 0.0, + name: str = "", +) -> Callable[[HookTarget], HookTarget]: ... + + +def hook( + func_or_name: HookTarget | HookName | None = None, + /, + *, + interval: float = 0.0, + name: str = "", +) -> HookTarget | Callable[[HookTarget], HookTarget]: + """Declare a synchronous simulation hook on an instance method.""" + + def decorate(target: HookTarget, hook_name: HookName | None) -> HookTarget: + actual = _underlying_function(target) + declarations = tuple(getattr(actual, _HOOKS, ())) + setattr(actual, _HOOKS, (*declarations, HookDeclaration(hook_name, interval, name))) + return target + + if callable(func_or_name): + return decorate(func_or_name, None) + return lambda target: decorate(target, func_or_name) + + +def declared_hooks(component: object) -> Iterator[BoundHook]: + """Bind hook declarations to this exact component instance.""" + for attribute_name, value in _declaration_namespace(component).items(): + declarations = getattr(_underlying_function(value), _HOOKS, ()) + if not declarations: + continue + callback = _bound_method(component, attribute_name, "hook") + for declaration in declarations: + if not isinstance(declaration, HookDeclaration): + raise TypeError(f"invalid hook declaration on {attribute_name!r}") + yield BoundHook(callback, declaration) + + +# +# replacements +# + +ReplacementTarget = TypeVar("ReplacementTarget", bound=type[Any]) +_REPLACEMENT = "__minisky_replacement__" + + +@dataclass(frozen=True, slots=True) +class ReplacementDeclaration: + """Replacement metadata stored on a decorated class.""" + + base: type[TrafficArrays] | None = None + name: str = "" + + +@overload +def replacement(target: ReplacementTarget, /) -> ReplacementTarget: ... + + +@overload +def replacement( + *, + base: type[TrafficArrays] | None = None, + name: str = "", +) -> Callable[[ReplacementTarget], ReplacementTarget]: ... + + +def replacement( + target: ReplacementTarget | None = None, + /, + *, + base: type[TrafficArrays] | None = None, + name: str = "", +) -> ReplacementTarget | Callable[[ReplacementTarget], ReplacementTarget]: + """Declare a runtime-local traffic implementation.""" + + def decorate(implementation: ReplacementTarget) -> ReplacementTarget: + if _REPLACEMENT in vars(implementation): + raise TypeError(f"replacement already declared: {implementation.__name__}") + setattr(implementation, _REPLACEMENT, ReplacementDeclaration(base, name)) + return implementation + + return decorate(target) if target is not None else decorate + + +def declared_replacement(implementation: type[Any]) -> ReplacementDeclaration: + """Return replacement metadata for an explicitly declared class.""" + declaration = vars(implementation).get(_REPLACEMENT) + if not isinstance(declaration, ReplacementDeclaration): + raise TypeError(f"replacement {implementation.__name__!r} must use @plugin.replacement") + return declaration + + +# +# internals +# + + +def _bound_method(component: object, name: str, kind: str) -> Callable[..., Any]: + callback = getattr(component, name) + if not inspect.ismethod(callback) or callback.__self__ is not component: + raise TypeError(f"decorated {kind} {name!r} must be an instance method") + return callback + + +def _declaration_namespace(component: object) -> dict[str, Any]: + namespace: dict[str, Any] = {} + for cls in reversed(type(component).__mro__): + namespace.update(vars(cls)) + return namespace + + +def _underlying_function(value: Any) -> Any: + if isinstance(value, (staticmethod, classmethod)): + value = value.__func__ + return inspect.unwrap(value) if callable(value) else value diff --git a/minisky/runtime.py b/packages/minisky/minisky/runtime.py similarity index 63% rename from minisky/runtime.py rename to packages/minisky/minisky/runtime.py index 072782e..a351acf 100644 --- a/minisky/runtime.py +++ b/packages/minisky/minisky/runtime.py @@ -1,13 +1,12 @@ -"""Explicit ownership root for one MiniSky runtime.""" +"""Explicit ownership root for a MiniSky runtime.""" from __future__ import annotations -import asyncio from random import Random import numpy as np -from minisky.core.settings import MiniSkySettings, data +from minisky.core.config import MiniSkyConfig, data, default_user_config_toml_path from minisky.core.trafficarrays import ReplaceableManager from minisky.core.varexplorer import VariableExplorer from minisky.plugin import PluginManager @@ -17,14 +16,30 @@ from minisky.tools.areafilter import AreaFilter from minisky.tools.navdata import Navdatabase from minisky.traffic import Traffic +from minisky.traffic.asas import MVP, ConflictDetection, ConflictResolution +from minisky.traffic.autopilot import Autopilot +from minisky.traffic.performance.perfoap import OpenAP class MiniSky: - """Own the primary objects that make up one simulator runtime.""" - - def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> None: - self.settings = settings - self._run_task: asyncio.Task[None] | None = None + """Own the primary objects that make up a simulator runtime. + + When `config` is omitted, MiniSky loads the optional default user + config and otherwise falls back to [`MiniSkyConfig`][minisky.MiniSkyConfig] + defaults. + """ + + def __init__( + self, + config: MiniSkyConfig | None = None, + scenario: str | None = None, + ) -> None: + if config is None: + try: + config = MiniSkyConfig.from_path(default_user_config_toml_path()) + except FileNotFoundError: + config = MiniSkyConfig() + self.config = config self._closed = False self.python_random = Random() self.numpy_random = np.random.RandomState() @@ -33,7 +48,7 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No self.areas = AreaFilter() self.variables = VariableExplorer() self.traffic = Traffic( - settings=settings, + config=config, python_random=self.python_random, numpy_random=self.numpy_random, areas=self.areas, @@ -44,9 +59,13 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No get_command_registry=lambda: self.commands.cmddict, select_implementation=lambda base, impl: self.replaceables.select(base, impl), ) - self.replaceables = ReplaceableManager(self.traffic, lambda: self.commands.cmddict) + self.replaceables = ReplaceableManager( + self.traffic, + bases=(Autopilot, ConflictDetection, ConflictResolution, OpenAP), + core=(MVP,), + ) self.plugins = PluginManager( - settings=settings, + config=config, console=self.console, variables=self.variables, get_runtime=lambda: self, @@ -94,45 +113,12 @@ def __init__(self, settings: MiniSkySettings, scenario: str | None = None) -> No def _stop_runner(self) -> None: self.runner.stop() - # TODO(abraham): load configured plugins during async entry and make this private - def load_plugins(self) -> None: - """Load plugins enabled in this runtime's settings.""" - self.plugins.load_enabled() - async def run(self) -> None: """Run the simulation until its runner stops.""" if self._closed: raise RuntimeError("MiniSky runtime is closed") await self.runner.run() - # TODO(abraham): move background task ownership to callers and remove start() - def start(self) -> asyncio.Task[None]: - """Start the simulation runner in an owned asyncio task.""" - if self._closed: - raise RuntimeError("MiniSky runtime is closed") - task = self._run_task - if task is not None: - if not task.done(): - return task - self._run_task = None - task.result() - - self._run_task = asyncio.create_task(self.run()) - return self._run_task - - def _close_resources(self) -> list[Exception]: - if self._closed: - return [] - - errors: list[Exception] = [] - for cleanup in (self.runner.shutdown, self.streaming.close, self.plugins.shutdown): - try: - cleanup() - except Exception as exc: - errors.append(exc) - self._closed = True - return errors - @staticmethod def _raise_errors(message: str, errors: list[Exception]) -> None: if len(errors) == 1: @@ -140,47 +126,44 @@ def _raise_errors(message: str, errors: list[Exception]) -> None: if errors: raise ExceptionGroup(message, errors) - # TODO(abraham): remove sync close when teardown has one async ownership path def close(self) -> None: - """Release synchronous resources and request runner-task cancellation.""" - errors = self._close_resources() - task = self._run_task - try: - current = asyncio.current_task() - except RuntimeError: - current = None - - if task is not None and task is not current: - if task.done(): - self._run_task = None - try: - task.result() - except asyncio.CancelledError: - pass - except Exception as exc: - errors.append(exc) - else: - # only aclose() can await cancellation of an asyncio task - task.cancel() - - self._raise_errors("MiniSky cleanup failed", errors) + """Close synchronous resources when no plugin lifespan is active.""" + if self._closed: + return + if self.commands.command_pending or self.plugins.requires_async_close: + raise RuntimeError("active asynchronous work requires await runtime.aclose()") - async def aclose(self) -> None: - """Stop the runner task and release all runtime-owned resources.""" - errors = self._close_resources() - task = self._run_task - if task is not None and task is not asyncio.current_task(): - if not task.done(): - task.cancel() + errors: list[Exception] = [] + for cleanup in (self.runner.shutdown, self.streaming.close, self.plugins.close): try: - await task - except asyncio.CancelledError: - pass + cleanup() except Exception as exc: errors.append(exc) - finally: - self._run_task = None + self._closed = True + self._raise_errors("MiniSky cleanup failed", errors) + + async def aclose(self) -> None: + """Stop the runner and close runtime-owned asynchronous resources.""" + if self._closed: + return + errors: list[Exception] = [] + self.runner.shutdown() + + try: + await self.commands.aclose() + except Exception as exc: + errors.append(exc) + try: + await self.plugins.aclose() + except Exception as exc: + errors.append(exc) + try: + self.streaming.close() + except Exception as exc: + errors.append(exc) + + self._closed = True self._raise_errors("MiniSky shutdown failed", errors) def __enter__(self) -> MiniSky: diff --git a/minisky/server.py b/packages/minisky/minisky/server.py similarity index 89% rename from minisky/server.py rename to packages/minisky/minisky/server.py index acbc8de..71e5def 100644 --- a/minisky/server.py +++ b/packages/minisky/minisky/server.py @@ -21,8 +21,9 @@ from __future__ import annotations +import asyncio import os -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from io import StringIO from typing import Annotated, Any, cast @@ -41,7 +42,7 @@ from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles -from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings +from minisky import MiniSky from minisky.tools import aero @@ -57,27 +58,39 @@ def _get_runtime(request: Request) -> MiniSky: async def lifespan(app: FastAPI): """Run the app-owned simulator for the lifetime of the API server.""" runtime = cast(MiniSky, app.state.runtime) - runtime.load_plugins() - runtime.start() + await runtime.plugins.load_configured() + runner_task = asyncio.create_task(runtime.run()) try: yield finally: - await runtime.aclose() + errors: list[Exception] = [] + runner_task.cancel() + with suppress(asyncio.CancelledError): + try: + await runner_task + except Exception as exc: + errors.append(exc) + try: + await runtime.aclose() + except Exception as exc: + errors.append(exc) + if len(errors) == 1: + raise errors[0] + if errors: + raise ExceptionGroup("MiniSky server shutdown failed", errors) def create_app(runtime: MiniSky | None = None) -> FastAPI: """Create a FastAPI application owning a simulator runtime.""" if runtime is None: - settings = MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE) - runtime = MiniSky(settings) + runtime = MiniSky() app = FastAPI(lifespan=lifespan) app.state.runtime = runtime app.include_router(create_router()) - # Static files live at the repository root (../static relative to this - # package), which resolves correctly for both a source checkout and an - # editable install. + # TODO(abraham): package static assets inside minisky and resolve them + # with importlib.resources for wheel installs static_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static") os.makedirs(static_dir, exist_ok=True) app.mount("/static", StaticFiles(directory=static_dir), name="static") @@ -195,7 +208,7 @@ def commands(runtime: Runtime) -> dict[str, str]: async def stream(websocket: WebSocket) -> None: """Push a full simulation snapshot once per simulation step in SI units. - Emits one JSON message per published tick, rate-capped by + Emits a JSON message per published tick, rate-capped by [`STREAM_MAX_HZ`][minisky.streaming.STREAM_MAX_HZ], containing `siminfo` and `acdata` as built by [`build_snapshot`][minisky.streaming.build_snapshot]. The most recent snapshot is sent immediately on connect so a new client is @@ -252,13 +265,13 @@ def list_plugins(runtime: Runtime) -> Any: return runtime.plugins.manage("LIST") -def load_plugin(name: str, runtime: Runtime) -> Any: +async def load_plugin(name: str, runtime: Runtime) -> Any: """Load a plugin by name.""" - return runtime.plugins.manage("LOAD", name) + return await runtime.plugins.load(name) def create_router() -> APIRouter: - """Create the API router for one FastAPI application.""" + """Create the API router for a FastAPI application.""" router = APIRouter() router.add_api_route("/", root, methods=["GET"]) router.add_api_route("/all", all, methods=["GET"]) diff --git a/minisky/simulation/__init__.py b/packages/minisky/minisky/simulation/__init__.py similarity index 100% rename from minisky/simulation/__init__.py rename to packages/minisky/minisky/simulation/__init__.py diff --git a/minisky/simulation/console.py b/packages/minisky/minisky/simulation/console.py similarity index 50% rename from minisky/simulation/console.py rename to packages/minisky/minisky/simulation/console.py index 196efad..0a97f4e 100644 --- a/minisky/simulation/console.py +++ b/packages/minisky/minisky/simulation/console.py @@ -1,40 +1,41 @@ -"""Console I/O for the MiniSky simulation. - -Defines `ConsoleIO`, the text output channel of the simulator. Stack -commands and simulation state changes report back through its `echo` -method, which prints to stdout and stores the message in a buffer that remote -clients (such as the HTTP API served by `minisky server`) can read asynchronously. -Each `MiniSky` runtime owns one instance as [`runtime.console`][minisky.simulation.console.ConsoleIO]. -""" +"""Console I/O for a MiniSky runtime.""" from __future__ import annotations import asyncio import io import sys +import traceback from collections.abc import Callable from colorama import Fore, Style +ConsoleCallback = Callable[[str], None] + + +class ConsoleSubscription: + """Owned console callback registration.""" + + def __init__(self, console: ConsoleIO, token: int) -> None: + self._console = console + self._token = token + self._closed = False + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._console._unsubscribe(self._token) + + def __enter__(self) -> ConsoleSubscription: + return self + + def __exit__(self, *_args: object) -> None: + self.close() + class ConsoleIO: - """Class within sim task which sends/receives data to/from GUI task. - - Acts as the runtime's screen/console object ([`runtime.console`][minisky.simulation.console.ConsoleIO]). Output - produced with `echo` is printed to stdout and kept in an in-memory - buffer; an `asyncio.Event` is set on every echo so that awaiting - consumers (e.g. the HTTP API's `/stack` endpoint) know new output is - available and can collect it with `read_output_buffer`. - - Attributes: - siminfo_rate: Update rate of simulation info messages [Hz]. - acupdate_rate: Update rate of aircraft update messages [Hz]. - prevtime: Simulation time of the previous info update [s]. - samplecount: Number of simulation samples counted while operating. - prevcount: Sample count at the previous info update. - output_buffer: `StringIO` buffer holding the latest echoed text. - event: `asyncio.Event` set whenever new output has been echoed. - """ + """Text output and subscriptions owned by a runtime.""" # Prefix for the stdout copy of echoed text, aligned with uvicorn's # "INFO: " column. Only the terminal print gets it; the output @@ -54,9 +55,10 @@ def __init__(self, is_operating: Callable[[], bool]) -> None: self.prevtime: float = 0.0 self.samplecount: int = 0 self.prevcount: int = 0 - - self.output_buffer: io.StringIO = io.StringIO() - self.event: asyncio.Event = asyncio.Event() + self.output_buffer = io.StringIO() + self.event = asyncio.Event() + self._subscribers: dict[int, ConsoleCallback] = {} + self._next_subscription = 0 def update(self) -> None: """Count one simulation sample while the simulation is operating. @@ -75,17 +77,8 @@ def reset(self) -> None: self.prevtime = 0.0 def echo(self, text: str = "", flag: int = 0) -> None: - """Print a message and store it in the output buffer. - - The previous buffer contents are discarded, the text is written both - to stdout (each line prefixed with `prefix`) and to the buffer - (verbatim), and the output event is set to wake up any consumer - awaiting new output. - - Args: - text: Message text to output. - flag: Message flag (accepted for interface compatibility, unused). - """ + """Print, buffer, and publish a console message.""" + del flag self.output_buffer.truncate(0) self.output_buffer.seek(0) prefix = self.prefix @@ -99,24 +92,37 @@ def echo(self, text: str = "", flag: int = 0) -> None: print(text, file=self.output_buffer, end="") self.event.set() + for token, callback in tuple(self._subscribers.items()): + try: + callback(text) + except Exception as exc: + self._subscribers.pop(token, None) + traceback.print_exception(exc) + + def subscribe(self, callback: ConsoleCallback) -> ConsoleSubscription: + """Subscribe to future console messages.""" + token = self._next_subscription + self._next_subscription += 1 + self._subscribers[token] = callback + return ConsoleSubscription(self, token) + + def _unsubscribe(self, token: int) -> None: + self._subscribers.pop(token, None) + def getviewctr(self) -> tuple[float, float]: - """Return the current view center (lat, lon). Stub for non-GUI mode.""" + """Return the current view center. Stub for non-GUI mode.""" return 0.0, 0.0 def addnavwpt(self, name: str, lat: float, lon: float) -> None: - """Add a nav waypoint marker to the display. Stub for non-GUI mode.""" + """Add a waypoint marker. Stub for non-GUI mode.""" pass def removenavwpt(self, name: str) -> None: - """Remove a nav waypoint marker from the display. Stub for non-GUI mode.""" + """Remove a waypoint marker. Stub for non-GUI mode.""" pass def read_output_buffer(self) -> str: - """Return the buffered console output and clear the buffer. - - Returns: - str: All text echoed since the last read (empty string if none). - """ + """Return and clear buffered console output.""" text = self.output_buffer.getvalue() self.output_buffer.truncate(0) self.output_buffer.seek(0) diff --git a/minisky/simulation/runner.py b/packages/minisky/minisky/simulation/runner.py similarity index 95% rename from minisky/simulation/runner.py rename to packages/minisky/minisky/simulation/runner.py index 7555390..98b2409 100644 --- a/minisky/simulation/runner.py +++ b/packages/minisky/minisky/simulation/runner.py @@ -23,8 +23,8 @@ class Runner: """Asyncio loop that drives the simulation at a configurable speed. - Each loop iteration performs one call to `self.simulation.step()` (which - advances simulation time by one `simdt`) and then sleeps so that steps + Each loop iteration performs a call to `self.simulation.step()` (which + advances simulation time by `simdt`) and then sleeps so that steps occur every `1 / speed` wall-clock seconds. During a fast-forward jump (see `forward`) the sleep is shortened to the minimum interval so the target simulation time is reached as fast as possible. @@ -36,7 +36,7 @@ class Runner: allow_shutdown: If False, `stop` is ignored and the loop keeps running (used when the simulator should idle without a scenario). speed: Simulation speed factor relative to real time; the loop targets - one simulation step every `1 / speed` wall-clock seconds. + a simulation step every `1 / speed` wall-clock seconds. jump: Remaining fast-forward request [s of simulation time]; 0 when no jump is active. jump_to: Target simulation time of the active fast-forward jump [s]. @@ -76,7 +76,7 @@ def forward(self, seconds: float) -> None: def setspeed(self, mult: float) -> tuple[bool, str]: """Set the simulation speed multiplier (stack DTMULT command). - The loop targets one simulation step every `1 / speed` wall-clock + The loop targets a simulation step every `1 / speed` wall-clock seconds, so a larger multiplier makes simulated time advance faster relative to the wall clock. This is the wall-clock-pacing equivalent of BlueSky's `DTMULT`. diff --git a/minisky/simulation/simulation.py b/packages/minisky/minisky/simulation/simulation.py similarity index 97% rename from minisky/simulation/simulation.py rename to packages/minisky/minisky/simulation/simulation.py index 6438d4d..846d399 100644 --- a/minisky/simulation/simulation.py +++ b/packages/minisky/minisky/simulation/simulation.py @@ -3,7 +3,7 @@ Defines the `Simulation` class, the central clock and state machine of the simulator. It advances simulation time, processes the command stack, triggers plugin pre-/post-update hooks, and updates all aircraft in the -traffic object once per timestep. Each `MiniSky` runtime owns one instance. +traffic object once per timestep. Each `MiniSky` runtime owns an instance. """ from __future__ import annotations @@ -111,7 +111,7 @@ def __init__( # Keep track of known clients self.clients: set[Any] = set() - def step(self) -> None: + def step(self) -> bool: """Perform one simulation timestep. Call this function instead of update if you don't want to run with a fixed @@ -133,8 +133,10 @@ def step(self) -> None: ): self.op() - # Always update stack - self.commands.process() + # An awaitable stack command owns this boundary until it completes. + if not self.commands.process(): + self.publish_tick() + return False if self.state == SimulationState.OP: self.simt += self.simdt @@ -153,6 +155,7 @@ def step(self) -> None: # Publish after command and state processing in every simulation state. # This is a no-op when the runtime has no stream subscribers. self.publish_tick() + return True def stop(self) -> None: """Stop the simulation (stack STOP/QUIT command). diff --git a/minisky/stack/__init__.py b/packages/minisky/minisky/stack/__init__.py similarity index 70% rename from minisky/stack/__init__.py rename to packages/minisky/minisky/stack/__init__.py index 74c7515..a22074a 100644 --- a/minisky/stack/__init__.py +++ b/packages/minisky/minisky/stack/__init__.py @@ -12,7 +12,7 @@ command set is defined in `minisky.stack.commands` and registered by [`CommandStack.init`][minisky.stack.CommandStack.init]. -Each `CommandStack` owns one runtime's command registry, pending command +Each `CommandStack` owns a runtime's command registry, pending command queue, scenario buffer, and sender state. This module also implements scenario handling: [`CommandStack.ic`][minisky.stack.CommandStack.ic] loads a scenario file, @@ -23,13 +23,17 @@ from __future__ import annotations +import asyncio import inspect import os import traceback -from collections.abc import Callable, Iterator +from collections.abc import Awaitable, Callable, Iterator +from contextlib import suppress +from dataclasses import dataclass from functools import partial from io import StringIO from pathlib import Path +from threading import Lock from typing import TYPE_CHECKING, Any, NamedTuple import numpy as np @@ -76,12 +80,11 @@ class Command: def __init__( self, - func, - parent: Command | None = None, + func: Callable[..., Any], name: str = "", *, argument_parser: argparser.ArgumentParser, - **kwargs, + **kwargs: Any, ) -> None: self.argument_parser = argument_parser self.name = name @@ -92,23 +95,11 @@ def __init__( self.valid = True self.arguments = self._get_arguments(kwargs.get("arguments", "")) self.params = [] - self.parent = parent self.callback = func - def __call__(self, argstring: str) -> CommandResult: - """Parse an argument string and execute this command. - - The command's Parameter objects convert the argument text into - typed values, which are passed to the callback function. - - Args: - argstring: The command-line text following the command name. - - Raises: - ArgumentError: When argument parsing fails, or when more - arguments are given than the command accepts. - """ - args = [] + def __call__(self, argstring: str) -> CommandResult | Awaitable[CommandResult]: + """Parse arguments and execute the callback.""" + args: list[Any] = [] param = None # Use callback-specified parameter parsers to generate param list from strings for param in self.params: @@ -126,33 +117,36 @@ def __call__(self, argstring: str) -> CommandResult: while argstring: _, argstring = getnextarg(argstring) count += 1 - msg += f", but {count} were given" - raise ArgumentError(msg) + raise ArgumentError(f"{msg}, but {count} were given") result = param(argstring) argstring = result[-1] args.extend(result[:-1]) - # Call callback function with parsed parameters - ret = self.callback(*args) - # Always return a tuple with a success value and a message string - if ret is None: - return CommandResult(success=True, echotext="") - if isinstance(ret, (tuple, list)): - if len(ret) > 1: - return CommandResult(success=bool(ret[0]), echotext=str(ret[1])) - if len(ret) == 1: - ret = bool(ret[0]) - return CommandResult(success=bool(ret), echotext="") + result = self.callback(*args) + if inspect.isawaitable(result): + return self._await_result(result) + return self._result(result) + + @staticmethod + async def _await_result(result: Awaitable[Any]) -> CommandResult: + return Command._result(await result) + + @staticmethod + def _result(result: Any) -> CommandResult: + if result is None: + return CommandResult(True, "") + if isinstance(result, (tuple, list)): + if len(result) > 1: + return CommandResult(bool(result[0]), str(result[1])) + if len(result) == 1: + result = result[0] + return CommandResult(bool(result), "") def __repr__(self) -> str: if self.valid: return f"" return f" None: - """Placeholder callback for commands without an implementation.""" - pass - @property def callback(self): """Callback pointing to the actual function that implements this @@ -163,7 +157,8 @@ def callback(self): @callback.setter def callback(self, function): self._callback = function - self._callback_source = function.func if isinstance(function, partial) else function + source = function.func if isinstance(function, partial) else function + self._callback_source = inspect.unwrap(source) try: # eval_str resolves stringified hints (from __future__ import annotations) # to the actual objects, so Annotated aliases are recognised either way @@ -267,12 +262,28 @@ def _get_arguments(self, arguments) -> tuple: return tuple(argtypes) +@dataclass(frozen=True, slots=True) +class PreparedCommand: + """A parsed command ready for registry installation.""" + + command: Command + names: tuple[str, ...] + + +@dataclass(slots=True) +class _PendingCommand: + task: asyncio.Future[CommandResult] + name: str + argstring: str + command: Command + + class CommandStack: - """Command registry, queue, and scenario state for one runtime. + """Command registry, queue, and scenario state for a runtime. Holds the available command objects, the queue of pending command lines, and the commands and timestamps loaded from a scenario file. Each - `MiniSky` runtime owns one instance, so command and + `MiniSky` runtime owns an instance, so command and scenario state is not shared between runtimes. Attributes: @@ -309,8 +320,12 @@ def __init__( self.argument_parser = argparser.ArgumentParser(traffic, navigation, console) self._get_simulation = get_simulation self._get_runner = get_runner + # TODO(abraham): package bundled scenarios inside minisky and resolve + # them with importlib.resources for wheel installs. self.scenario_root = scenario_root or Path(__file__).parent.parent.parent self.cmddict: dict[str, Command] = {} + self._queue_lock = Lock() + self._pending_command: _PendingCommand | None = None self._reset_state() @property @@ -321,61 +336,71 @@ def simulation(self) -> Simulation: def runner(self) -> Runner: return self._get_runner() - def addcommand( + # TODO(abraham): derive stack parsers from Annotated[...] metadata and remove + # the arguments DSL. + def prepare_command( self, - func: Callable, - parent: Command | None = None, + func: Callable[..., Any], + *, name: str = "", - command_type: type[Command] = Command, - **kwargs: Any, - ) -> None: - """Add `func` as a stack command in this runtime. - - Creates a command object for the given function and registers it and - its aliases in this command stack's `cmddict`. When a command with the - same name already exists, the existing command object is kept. - - Args: - func: Function, static method, or class method implementing the - command. - parent: Optional parent command when this is a subcommand. - name: Command name. Defaults to the function name in upper case. - command_type: Command class used to wrap the callback. - **kwargs: Command options: `arguments` (an argument type - specification such as `callsign,alt,[vspd]`), `brief`, `help`, - and `aliases`. - """ - func = func.__func__ if isinstance(func, (staticmethod, classmethod)) else func - name = (name or func.__name__).upper() - - cmdobj = self.cmddict.get(name) - if not cmdobj: - cmdobj = command_type( - func, - parent, - name, - argument_parser=self.argument_parser, - **kwargs, - ) - self.cmddict[name] = cmdobj - for alias in cmdobj.aliases: - self.cmddict[alias] = cmdobj - else: - if cmdobj.callback is func: - return - print(f"Attempt to reimplement {name} from {cmdobj.callback} to {func}") - if not isinstance(cmdobj, command_type): - raise TypeError( - f"Error reimplementing {name}: " - f"A {type(cmdobj).__name__} cannot be " - f"reimplemented as a {command_type.__name__}" - ) + aliases: tuple[str, ...] = (), + arguments: str = "", + brief: str = "", + help: str = "", + ) -> PreparedCommand: + """Construct and parse a command without registering it.""" + callback = func.__func__ if isinstance(func, (staticmethod, classmethod)) else func + callback = self.replaceables.bind_callback(callback) + command_name = (name or callback.__name__).upper() + alias_names = tuple(alias.upper() for alias in aliases) + names = (command_name, *alias_names) + if len(names) != len(set(names)): + raise ValueError(f"command {command_name} repeats an alias") + command_obj = Command( + callback, + name=command_name, + argument_parser=self.argument_parser, + aliases=alias_names, + arguments=arguments, + brief=brief, + help=help, + ) + return PreparedCommand(command_obj, names) + + def validate_commands(self, commands: tuple[PreparedCommand, ...]) -> None: + """Reject command names already used by this stack or the same batch.""" + seen: set[str] = set() + for prepared in commands: + for name in prepared.names: + if name in seen: + raise ValueError(f"command name repeated in batch: {name}") + if name in self.cmddict: + raise ValueError(f"command already registered: {name}") + seen.add(name) + + def install_commands(self, commands: tuple[PreparedCommand, ...]) -> None: + """Install commands that were already constructed and validated.""" + for prepared in commands: + for name in prepared.names: + self.cmddict[name] = prepared.command + + def remove_commands(self, commands: tuple[PreparedCommand, ...]) -> None: + """Remove command names only while they still refer to the same object.""" + for prepared in commands: + for name in prepared.names: + if self.cmddict.get(name) is prepared.command: + del self.cmddict[name] def _reset_state(self) -> None: """Reset the runtime-owned command queue and scenario state.""" # Stack data self.current = "" - self.cmdstack: list[tuple[str, bytes | None]] = [] + with self._queue_lock: + self.cmdstack: list[tuple[str, bytes | None]] = [] + pending, self._pending_command = self._pending_command, None + if pending is not None and not pending.task.done(): + pending.task.cancel() + pending.task.add_done_callback(_consume_task_result) # Scenario details self.scenname = "" @@ -385,36 +410,35 @@ def _reset_state(self) -> None: # Current command details self.sender_rte: bytes | None = None - def commands(self) -> Iterator[str]: - """Iterate over the command lines pending for this simulation step. + def _take_commands(self) -> list[tuple[str, bytes | None]]: + """Detach the current queue while preserving each command's sender.""" + with self._queue_lock: + pending, self.cmdstack = self.cmdstack, [] + return pending - Detaches the pending command list before iterating so that a - [`CommandStack.stack`][minisky.stack.CommandStack.stack] call from another thread, such as a plugin I/O thread, - cannot race with processing: a command lands either on the detached - list processed in this step or on the fresh list processed next step. - """ - pending, self.cmdstack = self.cmdstack, [] - # Assign to instance attributes so current and sender_rte track the loop. - for self.current, self.sender_rte in pending: - yield self.current + def commands(self) -> Iterator[str]: + """Iterate over the command lines pending for this simulation step.""" + for current, sender in self._take_commands(): + self.current = current + self.sender_rte = sender + yield current def init(self) -> None: - """Initialise BlueSky base stack commands.""" - - cmddict, synonyms = commands.get_commands(self) - - # register command - for name, values in cmddict.items(): - function, arguments, brief, help_text = values - - self.addcommand( - function, + """Prepare, validate, and install the base stack commands.""" + catalog = commands.get_commands(self) + prepared = tuple( + self.prepare_command( + definition.callback, name=name, - arguments=arguments, - brief=brief, - help=help_text, - aliases=synonyms.get(name, []), + aliases=catalog.aliases.get(name, ()), + arguments=definition.arguments, + brief=definition.brief, + help=definition.help, ) + for name, definition in catalog.definitions.items() + ) + self.validate_commands(prepared) + self.install_commands(prepared) def delete_element(self, *arg: Any) -> Any: """DEL: Delete an element (aircraft, wind field, area shape, or group). @@ -449,27 +473,19 @@ def reset(self) -> None: self._reset_state() self.argument_parser.reset() - def process(self) -> None: - """Sim-side stack processing; called once per simulation step. - - First moves due scenario commands onto the stack (see checkscen), then - parses and executes every queued command line: the first word is looked - up in self.cmddict (an aircraft callsign may also be used as prefix, - in which case the second word is the command, defaulting to POS), the - remaining text is passed to the Command object for argument parsing and - execution, and any resulting message is echoed to the screen. The - pending commands are detached from the stack up front (see - CommandStack.commands), so commands stacked while processing runs — including - from other threads — are kept for the next step instead of being lost. - """ + def process(self) -> bool: + """Process commands until an awaitable callback owns the stack.""" + if not self._finish_pending_command(): + return False + # First check for commands in scenario file self.checkscen() # Process stack of commands - for cmdline in self.commands(): - success = True - echotext = "" - + pending = self._take_commands() + for index, (cmdline, sender_id) in enumerate(pending): + self.current = cmdline + self.sender_rte = sender_id # Get first argument from command line and check if it's a command cmd, argstring = argparser.getnextarg(cmdline) cmdu = cmd.upper() @@ -483,39 +499,106 @@ def process(self) -> None: cmdu = cmd.upper() if cmd else "POS" cmdobj = self.cmddict.get(cmdu) - # Proceed if a command object was found - if cmdobj: + if cmdobj is None: + message = ( + f"error: unknown command or aircraft: {cmd}" + if not argstring + else f"error: unknown command: {cmd}" + ) + self.console.echo(message) + continue + + try: + result = cmdobj(argstring) + except argparser.ArgumentError as exc: + header = "" if not argstring else exc.args[0] if exc.args else "Argument error." + self.console.echo(f"{header}\nUsage:\n{cmdobj.brieftext()}") + continue + except Exception as exc: + self._echo_command_exception(cmdu, argstring, exc) + continue + + if inspect.isawaitable(result): try: - # Call the command, passing the argument string - success, echotext = cmdobj(argstring) - if not success: - if not argstring: - echotext = echotext or cmdobj.brieftext() - else: - echotext = f"Error: {echotext or cmdobj.brieftext()}" - - except argparser.ArgumentError as e: - success = False - header = "" if not argstring else e.args[0] if e.args else "Argument error." - echotext = f"{header}\nUsage:\n{cmdobj.brieftext()}" - except Exception as e: - header = "" if not argstring else e.args[0] if e.args else "Function error." - echotext = ( - f"Error calling function implementation of {cmdu}: {header}\n" - + "Traceback printed to terminal." - ) - traceback.print_exc() + asyncio.get_running_loop() + except RuntimeError: + if inspect.iscoroutine(result): + result.close() + self.console.echo("asynchronous stack commands require a running event loop") + continue + # NOTE(abraham): one awaitable owns the stack. later commands stay + # at the same simulation timestamp until it finishes. + # TODO(abraham): add per-caller completion handles if callers need + # responses independent of console output. + task = asyncio.ensure_future(result) + self._pending_command = _PendingCommand(task, cmdu, argstring, cmdobj) + self._prepend_commands(pending[index + 1 :]) + return False + + self._echo_command_result(cmdobj, argstring, result) + return True + + def _finish_pending_command(self) -> bool: + pending = self._pending_command + if pending is None: + return True + if not pending.task.done(): + return False + self._pending_command = None + try: + result = pending.task.result() + except asyncio.CancelledError: + return True + except Exception as exc: + self._echo_command_exception(pending.name, pending.argstring, exc) + else: + self._echo_command_result(pending.command, pending.argstring, result) + return True - # Command not found + def _prepend_commands(self, commands: list[tuple[str, bytes | None]]) -> None: + if not commands: + return + with self._queue_lock: + self.cmdstack[0:0] = commands + + def _echo_command_result( + self, command_obj: Command, argstring: str, result: CommandResult + ) -> None: + success, text = result + if not success: + if not argstring: + text = text or command_obj.brieftext() else: - success = False - if not argstring: - echotext = f"error: unknown command or aircraft: {cmd}" - else: - echotext = f"error: unknown command: {cmd}" + text = f"Error: {text or command_obj.brieftext()}" + if text: + self.console.echo(text) + + def _echo_command_exception(self, name: str, argstring: str, error: Exception) -> None: + header = "" if not argstring else error.args[0] if error.args else "Function error." + self.console.echo( + f"Error calling function implementation of {name}: {header}\n" + "Traceback printed to terminal." + ) + traceback.print_exception(error) - if echotext: - self.console.echo(echotext) + @property + def command_pending(self) -> bool: + return self._pending_command is not None + + async def wait_for_pending(self) -> None: + pending = self._pending_command + if pending is not None and not pending.task.done(): + await asyncio.wait((pending.task,)) + + async def aclose(self) -> None: + """Cancel and await the stack-owned asynchronous command.""" + pending, self._pending_command = self._pending_command, None + if pending is None: + return + if not pending.task.done(): + pending.task.cancel() + with suppress(asyncio.CancelledError, Exception): + await pending.task def readscn(self, scn: str | Path | StringIO) -> Iterator[tuple[float, str]]: """Read a scenario file and yield its timestamped commands. @@ -753,12 +836,13 @@ def stack(self, *cmdlines: str, sender_id: bytes | None = None) -> None: commands separated by ";". sender_id: Optional network route/id of the command sender. """ - # TODO(abraham): replace this list with an owned mailbox? + queued: list[tuple[str, bytes | None]] = [] for cmdline in cmdlines: - cmdline = cmdline.strip() - if cmdline: - for line in cmdline.split(";"): - self.cmdstack.append((line, sender_id)) + text = cmdline.strip() + if text: + queued.extend((line, sender_id) for line in text.split(";") if line) + with self._queue_lock: + self.cmdstack.extend(queued) def sender(self): """Return the sender of the currently executed stack command. @@ -791,3 +875,8 @@ def set_scendata(self, newtime, newcmd) -> None: """Set the scenario data. This is used by the batch logic.""" self.scentime = newtime self.scencmd = newcmd + + +def _consume_task_result(task: asyncio.Future[Any]) -> None: + with suppress(asyncio.CancelledError, Exception): + task.result() diff --git a/minisky/stack/argparser.py b/packages/minisky/minisky/stack/argparser.py similarity index 99% rename from minisky/stack/argparser.py rename to packages/minisky/minisky/stack/argparser.py index d48a595..0a1af71 100644 --- a/minisky/stack/argparser.py +++ b/packages/minisky/minisky/stack/argparser.py @@ -412,7 +412,7 @@ def parse(self, argstring: str) -> tuple: class ArgumentParser: - """Own argument parser instances and reference data for one command stack. + """Own argument parser instances and reference data for a command stack. The traffic, navigation database, and console references are explicit, while the parser registry and reference data are isolated from other diff --git a/minisky/stack/commands.py b/packages/minisky/minisky/stack/commands.py similarity index 96% rename from minisky/stack/commands.py rename to packages/minisky/minisky/stack/commands.py index 584d800..594b3d6 100644 --- a/minisky/stack/commands.py +++ b/packages/minisky/minisky/stack/commands.py @@ -53,27 +53,40 @@ from __future__ import annotations +from collections.abc import Callable from functools import partial -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, NamedTuple, TypeAlias if TYPE_CHECKING: from minisky.stack import CommandStack -def get_commands(command_stack: CommandStack) -> tuple: +class CommandDefinition(NamedTuple): + callback: Callable[..., Any] + arguments: str + brief: str + help: str + + +CommandDefinitions: TypeAlias = dict[str, CommandDefinition] +CommandAliases: TypeAlias = dict[str, tuple[str, ...]] + + +class CommandCatalog(NamedTuple): + definitions: CommandDefinitions + aliases: CommandAliases + + +def get_commands(command_stack: CommandStack) -> CommandCatalog: """Assemble the base command and synonym dictionaries of the simulator. Binds callbacks to the objects owned by the provided runtime command stack. - - Returns: - tuple: (cmddict, synonyms). cmddict maps a command name to a list - of [function, argument type string, brief usage text, help text]; - synonyms maps a command name to a list of alias names. """ from minisky import tools from minisky.traffic import route - cmddict = { + # TODO(abraham): migrate core commands from this legacy table to typed declarations. + cmddict: dict[str, list[Any]] = { "ADDWPT": [ partial(route.addwpt, command_stack.traffic), "callsign,wpt,[alt,spd,wpt,wpt]", @@ -574,7 +587,7 @@ def get_commands(command_stack: CommandStack) -> tuple: } # Command synonym dictionary - synonyms = { + synonyms: dict[str, list[str]] = { "ASAS": ["CD", "CDMETHOD"], "POS": ["AWY", "AIRPORT", "RUNWAYS", "AIRWAY", "AIRWAYS"], "BANK": ["BANKLIM"], @@ -594,4 +607,6 @@ def get_commands(command_stack: CommandStack) -> tuple: "PLUGINS": ["PLUGIN"], } - return cmddict, synonyms + definitions = {name: CommandDefinition(*values) for name, values in cmddict.items()} + aliases = {name: tuple(names) for name, names in synonyms.items()} + return CommandCatalog(definitions, aliases) diff --git a/minisky/streaming.py b/packages/minisky/minisky/streaming.py similarity index 99% rename from minisky/streaming.py rename to packages/minisky/minisky/streaming.py index ed60c25..cf3f192 100644 --- a/minisky/streaming.py +++ b/packages/minisky/minisky/streaming.py @@ -1,6 +1,6 @@ """Per-tick streaming of simulation state. -Provides a small, transport-agnostic mechanism to push a full snapshot of one +Provides a small, transport-agnostic mechanism to push a full snapshot of a simulation runtime once per timestep. [`build_snapshot`][] receives the runtime explicitly and returns a plain, JSON-serialisable dict in **SI units**; [`StreamHub`][] fans that snapshot out to any number of awaiting consumers diff --git a/minisky/tools/__init__.py b/packages/minisky/minisky/tools/__init__.py similarity index 100% rename from minisky/tools/__init__.py rename to packages/minisky/minisky/tools/__init__.py diff --git a/minisky/tools/aero.py b/packages/minisky/minisky/tools/aero.py similarity index 100% rename from minisky/tools/aero.py rename to packages/minisky/minisky/tools/aero.py diff --git a/minisky/tools/areafilter.py b/packages/minisky/minisky/tools/areafilter.py similarity index 99% rename from minisky/tools/areafilter.py rename to packages/minisky/minisky/tools/areafilter.py index f0eee9f..3c71cd4 100644 --- a/minisky/tools/areafilter.py +++ b/packages/minisky/minisky/tools/areafilter.py @@ -50,7 +50,7 @@ def delete(*args, **kwargs): class AreaFilter: - """Named geometric shapes and spatial index for one MiniSky runtime.""" + """Named geometric shapes and spatial index for a MiniSky runtime.""" def __init__(self) -> None: # Dictionary of all basic shapes (The shape classes defined in this file) by name @@ -267,7 +267,6 @@ def get_knearest( return [self.areas_by_id[area_id] for area_id in ids if area_id in self.areas_by_id] - class Shape: """ Base class of BlueSky shapes diff --git a/minisky/tools/convert.py b/packages/minisky/minisky/tools/convert.py similarity index 100% rename from minisky/tools/convert.py rename to packages/minisky/minisky/tools/convert.py diff --git a/minisky/tools/geo.py b/packages/minisky/minisky/tools/geo.py similarity index 99% rename from minisky/tools/geo.py rename to packages/minisky/minisky/tools/geo.py index 2b7ea8f..2f56b8d 100644 --- a/minisky/tools/geo.py +++ b/packages/minisky/minisky/tools/geo.py @@ -19,7 +19,7 @@ import numpy as np import pandas as pd -from minisky.core.settings import data +from minisky.core.config import data # Type alias for values that may be a scalar or a numpy array FloatOrArray = float | np.ndarray diff --git a/minisky/tools/navdata.py b/packages/minisky/minisky/tools/navdata.py similarity index 100% rename from minisky/tools/navdata.py rename to packages/minisky/minisky/tools/navdata.py diff --git a/minisky/tools/position.py b/packages/minisky/minisky/tools/position.py similarity index 100% rename from minisky/tools/position.py rename to packages/minisky/minisky/tools/position.py diff --git a/minisky/traffic/__init__.py b/packages/minisky/minisky/traffic/__init__.py similarity index 100% rename from minisky/traffic/__init__.py rename to packages/minisky/minisky/traffic/__init__.py diff --git a/minisky/traffic/activewpdata.py b/packages/minisky/minisky/traffic/activewpdata.py similarity index 100% rename from minisky/traffic/activewpdata.py rename to packages/minisky/minisky/traffic/activewpdata.py diff --git a/minisky/traffic/aporasas.py b/packages/minisky/minisky/traffic/aporasas.py similarity index 100% rename from minisky/traffic/aporasas.py rename to packages/minisky/minisky/traffic/aporasas.py diff --git a/minisky/traffic/asas/__init__.py b/packages/minisky/minisky/traffic/asas/__init__.py similarity index 100% rename from minisky/traffic/asas/__init__.py rename to packages/minisky/minisky/traffic/asas/__init__.py diff --git a/minisky/traffic/asas/detection.py b/packages/minisky/minisky/traffic/asas/detection.py similarity index 97% rename from minisky/traffic/asas/detection.py rename to packages/minisky/minisky/traffic/asas/detection.py index 79935dd..6c22120 100644 --- a/minisky/traffic/asas/detection.py +++ b/packages/minisky/minisky/traffic/asas/detection.py @@ -25,7 +25,7 @@ import numpy as np from scipy.spatial import KDTree -from minisky.core.settings import MiniSkySettings +from minisky.core.config import MiniSkyConfig from minisky.core.trafficarrays import TrafficArrays from minisky.stack.argparser import Time, Txt from minisky.tools.aero import ft, nm @@ -97,21 +97,21 @@ class ConflictDetection(TrafficArrays): """ def __init__( - self, settings: MiniSkySettings, traffic: Traffic, stack_command: Callable[..., None] + self, config: MiniSkyConfig, traffic: Traffic, stack_command: Callable[..., None] ) -> None: super().__init__() - self.settings = settings + self.config = config self.traffic = traffic self.stack_command = stack_command ## Default values # [m] Horizontal separation minimum for detection - self.rpz_def = self.settings.asas_pzr * nm + self.rpz_def = self.config.asas_pzr * nm self.global_rpz = True # [m] Vertical separation minimum for detection - self.hpz_def = self.settings.asas_pzh * ft + self.hpz_def = self.config.asas_pzh * ft self.global_hpz = True # [s] lookahead time - self.dtlookahead_def = self.settings.asas_dtlookahead + self.dtlookahead_def = self.config.asas_dtlookahead self.global_dtlook = True self.dtnolook_def = 0.0 self.global_dtnolook = True @@ -148,7 +148,7 @@ def __init__( def new_implementation(self, implementation: Callable[..., TrafficArrays]) -> TrafficArrays: """Construct a replacement with this runtime's traffic and command stack.""" - return implementation(self.settings, self.traffic, self.stack_command) + return implementation(self.config, self.traffic, self.stack_command) def clearconfdb(self) -> None: """Clear the conflict database. @@ -192,15 +192,15 @@ def reset(self) -> None: Called on simulation reset: clears the conflict database and the historic conflict/LoS lists, and restores the default separation - minima and lookahead times from the simulation settings. + minima and lookahead times from the simulation config. """ super().reset() self.clearconfdb() self.confpairs_all.clear() self.lospairs_all.clear() - self.rpz_def = self.settings.asas_pzr * nm - self.hpz_def = self.settings.asas_pzh * ft - self.dtlookahead_def = self.settings.asas_dtlookahead + self.rpz_def = self.config.asas_pzr * nm + self.hpz_def = self.config.asas_pzh * ft + self.dtlookahead_def = self.config.asas_dtlookahead self.dtnolook_def = 0.0 self.global_rpz = self.global_hpz = True self.global_dtlook = self.global_dtnolook = True diff --git a/minisky/traffic/asas/mvp.py b/packages/minisky/minisky/traffic/asas/mvp.py similarity index 99% rename from minisky/traffic/asas/mvp.py rename to packages/minisky/minisky/traffic/asas/mvp.py index e9b4342..e82d0ed 100644 --- a/minisky/traffic/asas/mvp.py +++ b/packages/minisky/minisky/traffic/asas/mvp.py @@ -21,7 +21,7 @@ import numpy as np -from minisky.core.settings import MiniSkySettings +from minisky.core.config import MiniSkyConfig from minisky.stack.argparser import Txt from minisky.traffic.asas import ConflictResolution @@ -53,11 +53,11 @@ class MVP(ConflictResolution): def __init__( self, - settings: MiniSkySettings, + config: MiniSkyConfig, traffic: Traffic, select_implementation: Callable[[str, str], tuple[bool, str]], ) -> None: - super().__init__(settings, traffic, select_implementation) + super().__init__(config, traffic, select_implementation) # [-] switch to limit resolution to the horizontal direction self.swresohoriz = True # [-] switch to use only speed resolutions (works with swresohoriz = True) @@ -434,7 +434,7 @@ def MVP( """Modified Voltage Potential (MVP) resolution method. Computes the velocity change that displaces the predicted closest - point of approach (CPA) of one conflict pair to the edge of the + point of approach (CPA) of a conflict pair to the edge of the resolution zone (protected zone scaled by `resofach`/`resofacv`). Horizontally, the intrusion at CPA is divided by the time to CPA to obtain the required speed change along the CPA displacement diff --git a/minisky/traffic/asas/resolution.py b/packages/minisky/minisky/traffic/asas/resolution.py similarity index 98% rename from minisky/traffic/asas/resolution.py rename to packages/minisky/minisky/traffic/asas/resolution.py index 9b5e3e5..e6e2b8e 100644 --- a/minisky/traffic/asas/resolution.py +++ b/packages/minisky/minisky/traffic/asas/resolution.py @@ -20,7 +20,7 @@ import numpy as np -from minisky.core.settings import MiniSkySettings +from minisky.core.config import MiniSkyConfig from minisky.core.trafficarrays import TrafficArrays from minisky.stack.argparser import Txt from minisky.tools.aero import ft, nm @@ -69,12 +69,12 @@ class ConflictResolution(TrafficArrays): def __init__( self, - settings: MiniSkySettings, + config: MiniSkyConfig, traffic: Traffic, select_implementation: Callable[[str, str], tuple[bool, str]], ) -> None: super().__init__() - self.settings = settings + self.config = config self.traffic = traffic self.select_implementation = select_implementation self.activate = False @@ -87,8 +87,8 @@ def __init__( # Resolution factors: # set < 1 to maneuver only a fraction of the resolution # set > 1 to add a margin to separation values - self.resofach = self.settings.asas_marh - self.resofacv = self.settings.asas_marv + self.resofach = self.config.asas_marh + self.resofacv = self.config.asas_marv # Switches to guarantee last reso zone commands keep valid if cd zone changes self.resodhrelative = ( @@ -108,7 +108,7 @@ def __init__( def new_implementation(self, implementation: Callable[..., TrafficArrays]) -> TrafficArrays: """Construct a replacement with this runtime's traffic and selector.""" - return implementation(self.settings, self.traffic, self.select_implementation) + return implementation(self.config, self.traffic, self.select_implementation) def switch(self, flag: bool | None = None) -> None: """Turn conflict resolution on or off. @@ -123,14 +123,14 @@ def reset(self) -> None: Called on simulation reset: clears priority settings and pending resolution pairs, and restores the resolution zone factors from the - simulation settings. + simulation config. """ super().reset() self.swprio = False self.priocode = "" self.resopairs.clear() - self.resofach = self.settings.asas_marh - self.resofacv = self.settings.asas_marv + self.resofach = self.config.asas_marh + self.resofacv = self.config.asas_marv self.resodhrelative = True self.resorrelative = True diff --git a/minisky/traffic/autopilot.py b/packages/minisky/minisky/traffic/autopilot.py similarity index 100% rename from minisky/traffic/autopilot.py rename to packages/minisky/minisky/traffic/autopilot.py diff --git a/minisky/traffic/conditional.py b/packages/minisky/minisky/traffic/conditional.py similarity index 100% rename from minisky/traffic/conditional.py rename to packages/minisky/minisky/traffic/conditional.py diff --git a/minisky/traffic/performance/__init__.py b/packages/minisky/minisky/traffic/performance/__init__.py similarity index 100% rename from minisky/traffic/performance/__init__.py rename to packages/minisky/minisky/traffic/performance/__init__.py diff --git a/minisky/traffic/performance/coeff.py b/packages/minisky/minisky/traffic/performance/coeff.py similarity index 99% rename from minisky/traffic/performance/coeff.py rename to packages/minisky/minisky/traffic/performance/coeff.py index 9e5a911..5207a5d 100644 --- a/minisky/traffic/performance/coeff.py +++ b/packages/minisky/minisky/traffic/performance/coeff.py @@ -14,7 +14,7 @@ import numpy as np from openap import WRAP, drag, prop -from minisky.core.settings import data +from minisky.core.config import data LIFT_FIXWING = 1 # fixwing aircraft LIFT_ROTOR = 2 # rotor aircraft diff --git a/minisky/traffic/performance/perfoap.py b/packages/minisky/minisky/traffic/performance/perfoap.py similarity index 100% rename from minisky/traffic/performance/perfoap.py rename to packages/minisky/minisky/traffic/performance/perfoap.py diff --git a/minisky/traffic/performance/phase.py b/packages/minisky/minisky/traffic/performance/phase.py similarity index 100% rename from minisky/traffic/performance/phase.py rename to packages/minisky/minisky/traffic/performance/phase.py diff --git a/minisky/traffic/performance/thrust.py b/packages/minisky/minisky/traffic/performance/thrust.py similarity index 100% rename from minisky/traffic/performance/thrust.py rename to packages/minisky/minisky/traffic/performance/thrust.py diff --git a/minisky/traffic/route.py b/packages/minisky/minisky/traffic/route.py similarity index 100% rename from minisky/traffic/route.py rename to packages/minisky/minisky/traffic/route.py diff --git a/minisky/traffic/traffic.py b/packages/minisky/minisky/traffic/traffic.py similarity index 99% rename from minisky/traffic/traffic.py rename to packages/minisky/minisky/traffic/traffic.py index c12a295..f18fead 100644 --- a/minisky/traffic/traffic.py +++ b/packages/minisky/minisky/traffic/traffic.py @@ -20,7 +20,7 @@ import numpy as np -from minisky.core.settings import MiniSkySettings +from minisky.core.config import MiniSkyConfig from minisky.core.trafficarrays import TrafficArrays from minisky.tools import geo from minisky.tools.aero import ( @@ -133,7 +133,7 @@ class Traffic(TrafficArrays): def __init__( self, - settings: MiniSkySettings, + config: MiniSkyConfig, python_random: Random, numpy_random: np.random.RandomState, areas: AreaFilter, @@ -145,7 +145,7 @@ def __init__( select_implementation: Callable[[str, str], tuple[bool, str]], ) -> None: super().__init__() - self.settings = settings + self.config = config self.python_random = python_random self.numpy_random = numpy_random self.areas = areas @@ -215,8 +215,8 @@ def __init__( self.swvnavspd = np.array([], dtype=bool) # Flight Models - self.cd = ConflictDetection(settings, self, stack_command) - self.cr = ConflictResolution(settings, self, select_implementation) + self.cd = ConflictDetection(config, self, stack_command) + self.cr = ConflictResolution(config, self, select_implementation) self.ap = Autopilot(self, get_simulation) self.aporasas = APorASAS(self) self.noise = SurveillanceUncertainty(self, get_simulation) @@ -535,7 +535,7 @@ def creconfs( and speed are computed such that, relative to the target aircraft, separation is lost after the given time with the given distance at the closest point of approach. The protected-zone radius and height - from the settings (asas_pzr, asas_pzh) are taken into account. + from the config (asas_pzr, asas_pzh) are taken into account. Args: callsign: Callsign of the new (intruder) aircraft. @@ -560,8 +560,8 @@ def creconfs( tasref = self.tas[targetidx] # m/s vsref = self.vs[targetidx] # m/s cpa = dcpa * nm - pzr = self.settings.asas_pzr * nm - pzh = self.settings.asas_pzh * ft + pzr = self.config.asas_pzr * nm + pzh = self.config.asas_pzh * ft trk = trkref + np.radians(dpsi) if dH is None: diff --git a/minisky/traffic/trafficgroups.py b/packages/minisky/minisky/traffic/trafficgroups.py similarity index 100% rename from minisky/traffic/trafficgroups.py rename to packages/minisky/minisky/traffic/trafficgroups.py diff --git a/minisky/traffic/trails.py b/packages/minisky/minisky/traffic/trails.py similarity index 100% rename from minisky/traffic/trails.py rename to packages/minisky/minisky/traffic/trails.py diff --git a/minisky/traffic/turbulence.py b/packages/minisky/minisky/traffic/turbulence.py similarity index 100% rename from minisky/traffic/turbulence.py rename to packages/minisky/minisky/traffic/turbulence.py diff --git a/minisky/traffic/uncertainty.py b/packages/minisky/minisky/traffic/uncertainty.py similarity index 100% rename from minisky/traffic/uncertainty.py rename to packages/minisky/minisky/traffic/uncertainty.py diff --git a/minisky/traffic/wind.py b/packages/minisky/minisky/traffic/wind.py similarity index 100% rename from minisky/traffic/wind.py rename to packages/minisky/minisky/traffic/wind.py diff --git a/packages/minisky/pyproject.toml b/packages/minisky/pyproject.toml new file mode 100644 index 0000000..9b39842 --- /dev/null +++ b/packages/minisky/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "minisky" +version = "0.1.0" +description = "A minimal command line air traffic simulator with REST API (a BlueSky fork)" +readme = { text = "MiniSky is a minimal command-line air traffic simulator with a REST API.", content-type = "text/markdown" } +requires-python = ">=3.11" +dependencies = [ + "typer>=0.15.0", + "colorama>=0.4.6", + "fastapi[standard]>=0.115.7", + "matplotlib>=3.10.0", + "numpy>=2.2.2", + "openap>=2.4", + "pandas>=2.2.3", + "platformdirs>=4.3.6", + "prompt-toolkit>=3.0.50", + "pyarrow>=19.0.1", + "requests>=2.32.3", + "rtree>=1.3.0", + "scipy>=1.15.1", +] + +[project.scripts] +minisky = "minisky.cli:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["minisky"] diff --git a/scenarios/2ac_converging.scn b/packages/minisky/scenarios/2ac_converging.scn similarity index 100% rename from scenarios/2ac_converging.scn rename to packages/minisky/scenarios/2ac_converging.scn diff --git a/scenarios/customap.scn b/packages/minisky/scenarios/customap.scn similarity index 100% rename from scenarios/customap.scn rename to packages/minisky/scenarios/customap.scn diff --git a/scenarios/kl204.scn b/packages/minisky/scenarios/kl204.scn similarity index 100% rename from scenarios/kl204.scn rename to packages/minisky/scenarios/kl204.scn diff --git a/static/display.html b/packages/minisky/static/display.html similarity index 100% rename from static/display.html rename to packages/minisky/static/display.html diff --git a/example_plugins/tangram/tangram_minisky/eslint.config.js b/packages/tangram-minisky/eslint.config.js similarity index 100% rename from example_plugins/tangram/tangram_minisky/eslint.config.js rename to packages/tangram-minisky/eslint.config.js diff --git a/example_plugins/tangram/tangram_minisky/package.json b/packages/tangram-minisky/package.json similarity index 100% rename from example_plugins/tangram/tangram_minisky/package.json rename to packages/tangram-minisky/package.json diff --git a/example_plugins/tangram/tangram_minisky/pyproject.toml b/packages/tangram-minisky/pyproject.toml similarity index 100% rename from example_plugins/tangram/tangram_minisky/pyproject.toml rename to packages/tangram-minisky/pyproject.toml diff --git a/example_plugins/tangram/tangram_minisky/readme.md b/packages/tangram-minisky/readme.md similarity index 81% rename from example_plugins/tangram/tangram_minisky/readme.md rename to packages/tangram-minisky/readme.md index 76a60bc..9cbb621 100644 --- a/example_plugins/tangram/tangram_minisky/readme.md +++ b/packages/tangram-minisky/readme.md @@ -6,7 +6,7 @@ renders live traffic from a MiniSky simulator. This package is **frontend-only** and deliberately disposable: it contains no business logic and no simulation state. Everything simulator-side (unit conversion, snapshot publishing, command handling) lives in MiniSky's own -`TANGRAM` plugin (`example_plugins/tangram.py`), which talks to tangram +`TANGRAM` plugin (`packages/minisky-tangram/src/minisky_tangram/__init__.py`), which talks to tangram exclusively over Redis pub/sub — tangram's one transport layer that has been stable across its recent plugin API churn. If tangram's frontend API breaks again, only this package needs touching. @@ -32,7 +32,7 @@ again, only this package needs touching. - `from::command` — `{command: "OP"}` stack commands from the browser `` defaults to `minisky` and is configurable on both sides -(`[tangram].channel` in MiniSky's `settings.toml`, `channel` in tangram's +(`plugins.tangram.channel` in MiniSky's user config file, `channel` in tangram's `tangram.toml` under `[plugins.tangram_minisky]`). ## Build and run @@ -48,7 +48,7 @@ Run with published tangram using either route: ```bash uv tool install tangram_core \ - --with ./example_plugins/tangram/tangram_minisky \ + --with ./packages/tangram-minisky \ --force tangram serve --config ../tangram_minisky_exe/tangram.toml ``` @@ -62,7 +62,7 @@ uv run tangram serve --config tangram.toml ``` The complete setup and temporary local-checkout overrides are documented in -[Streaming to a tangram map](../../../docs/guides/tangram.md). +[Streaming to a tangram map](../../docs/guides/tangram.md). MiniSky config locations and optional file creation are covered in [Configuration](../../docs/guides/configuration.md). ## Run the simulator side @@ -70,8 +70,8 @@ In the MiniSky repo: ```bash just sync -# settings.toml: enabled_plugins = ["TANGRAM"], and (optionally) a [tangram] table -# with redis_url pointing at the same Redis instance tangram uses +# Add [plugins.tangram] to the MiniSky user config file with redis_url +# pointing at the same Redis instance tangram uses. minisky server # or: minisky run --scenario scenarios/kl204.scn ``` diff --git a/example_plugins/tangram/tangram_minisky/src/tangram_minisky/AircraftCountWidget.vue b/packages/tangram-minisky/src/tangram_minisky/AircraftCountWidget.vue similarity index 100% rename from example_plugins/tangram/tangram_minisky/src/tangram_minisky/AircraftCountWidget.vue rename to packages/tangram-minisky/src/tangram_minisky/AircraftCountWidget.vue diff --git a/example_plugins/tangram/tangram_minisky/src/tangram_minisky/AircraftLayer.vue b/packages/tangram-minisky/src/tangram_minisky/AircraftLayer.vue similarity index 100% rename from example_plugins/tangram/tangram_minisky/src/tangram_minisky/AircraftLayer.vue rename to packages/tangram-minisky/src/tangram_minisky/AircraftLayer.vue diff --git a/example_plugins/tangram/tangram_minisky/src/tangram_minisky/AircraftTrailLayer.vue b/packages/tangram-minisky/src/tangram_minisky/AircraftTrailLayer.vue similarity index 100% rename from example_plugins/tangram/tangram_minisky/src/tangram_minisky/AircraftTrailLayer.vue rename to packages/tangram-minisky/src/tangram_minisky/AircraftTrailLayer.vue diff --git a/example_plugins/tangram/tangram_minisky/src/tangram_minisky/SimControlWidget.vue b/packages/tangram-minisky/src/tangram_minisky/SimControlWidget.vue similarity index 100% rename from example_plugins/tangram/tangram_minisky/src/tangram_minisky/SimControlWidget.vue rename to packages/tangram-minisky/src/tangram_minisky/SimControlWidget.vue diff --git a/example_plugins/tangram/tangram_minisky/src/tangram_minisky/__init__.py b/packages/tangram-minisky/src/tangram_minisky/__init__.py similarity index 92% rename from example_plugins/tangram/tangram_minisky/src/tangram_minisky/__init__.py rename to packages/tangram-minisky/src/tangram_minisky/__init__.py index cfd31f0..a286fc0 100644 --- a/example_plugins/tangram/tangram_minisky/src/tangram_minisky/__init__.py +++ b/packages/tangram-minisky/src/tangram_minisky/__init__.py @@ -3,7 +3,7 @@ This package is deliberately frontend-only: it registers no API routes and runs no background services inside tangram. All simulator-side logic (unit conversion, snapshot publishing, command handling) lives in MiniSky's own -`TANGRAM` plugin (`example_plugins/tangram.py` in the MiniSky repo), +`TANGRAM` plugin (`packages/minisky-tangram/src/minisky_tangram/__init__.py` in the MiniSky repo), which talks to tangram exclusively over Redis pub/sub: - `to::new-data` full state snapshots (aviation units) @@ -24,7 +24,7 @@ @dataclass(frozen=True) class MiniskyConfig: channel: str = "minisky" - """Redis channel name; must match `[tangram].channel` in MiniSky's settings.toml.""" + """Redis channel name; must match `plugins.tangram.channel` in MiniSky config.""" topbar_order: int = 45 """Ordering hint for the sim-control widget in tangram's topbar (lower is earlier).""" sidebar_order: int = 45 diff --git a/example_plugins/tangram/tangram_minisky/src/tangram_minisky/index.ts b/packages/tangram-minisky/src/tangram_minisky/index.ts similarity index 100% rename from example_plugins/tangram/tangram_minisky/src/tangram_minisky/index.ts rename to packages/tangram-minisky/src/tangram_minisky/index.ts diff --git a/example_plugins/tangram/tangram_minisky/src/tangram_minisky/store.ts b/packages/tangram-minisky/src/tangram_minisky/store.ts similarity index 100% rename from example_plugins/tangram/tangram_minisky/src/tangram_minisky/store.ts rename to packages/tangram-minisky/src/tangram_minisky/store.ts diff --git a/example_plugins/tangram/tangram_minisky/tsconfig.json b/packages/tangram-minisky/tsconfig.json similarity index 100% rename from example_plugins/tangram/tangram_minisky/tsconfig.json rename to packages/tangram-minisky/tsconfig.json diff --git a/example_plugins/tangram/tangram_minisky/tsconfig.vite.json b/packages/tangram-minisky/tsconfig.vite.json similarity index 100% rename from example_plugins/tangram/tangram_minisky/tsconfig.vite.json rename to packages/tangram-minisky/tsconfig.vite.json diff --git a/example_plugins/tangram/tangram_minisky/vite.config.ts b/packages/tangram-minisky/vite.config.ts similarity index 100% rename from example_plugins/tangram/tangram_minisky/vite.config.ts rename to packages/tangram-minisky/vite.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51e6dc8..46c22cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ importers: .: {} - example_plugins/tangram/tangram_minisky: + packages/tangram-minisky: dependencies: '@open-aviation/tangram-core': specifier: ^0.5.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fc89cec..b8de774 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,9 +1,9 @@ packages: - - example_plugins/tangram/* + - packages/* # to use a local version of tangram-core instead, uncomment this. # link paths are relative to this workspace root; the deck.gl links point at the # checkout's own copies so typechecking sees a single deck.gl installation. # before committing, remember to re-comment and run `pnpm i`! # overrides: -# "@open-aviation/tangram-core": "link:../tangram/packages/tangram_core" \ No newline at end of file +# "@open-aviation/tangram-core": "link:../tangram/packages/tangram_core" diff --git a/pyproject.toml b/pyproject.toml index a97f9ce..98c35d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,36 +1,5 @@ -[project] -name = "minisky" -version = "0.1.0" -description = "A minimal command line air traffic simulator with REST API (a BlueSky fork)" -readme = "readme.md" -requires-python = ">=3.11" -dependencies = [ - "typer>=0.15.0", - "colorama>=0.4.6", - "fastapi[standard]>=0.115.7", - "matplotlib>=3.10.0", - "numpy>=2.2.2", - "openap>=2.4", - "pandas>=2.2.3", - "prompt-toolkit>=3.0.50", - "pyarrow>=19.0.1", - "requests>=2.32.3", - "rtree>=1.3.0", - "scipy>=1.15.1", -] - -[project.scripts] -minisky = "minisky.cli:app" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["minisky"] - [tool.uv.workspace] -members = ["example_plugins/tangram/tangram_minisky"] +members = ["packages/*"] [dependency-groups] dev = [ @@ -47,7 +16,7 @@ docs = [ [tool.pytest.ini_options] testpaths = ["tests"] -pythonpath = ["."] +pythonpath = [".", "packages/minisky"] addopts = "-ra -m 'not api'" markers = [ "api: FastAPI endpoint tests, run separately with just test-api", @@ -72,19 +41,45 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] # re-exports -"minisky/plugin/*.py" = ["F401"] # plugin API surface re-exports +"packages/minisky/minisky/plugin/*.py" = ["F401"] # plugin API surface re-exports + +[tool.ruff.lint.isort] +known-first-party = [ + "minisky", + "minisky_example", + "minisky_example_customautopilot", + "minisky_tangram", + "example_plugins", + "tests", +] [tool.pyright] -include = ["minisky", "example_plugins", "tests"] +include = [ + "packages/minisky/minisky", + "packages/minisky-example*/src", + "packages/minisky-tangram/src", + "packages/tangram-minisky/src", + "tests", +] exclude = [ "**/.*", "**/__pycache__", "**/node_modules", ".venv", "build", "site", # TODO(abraham): these files will need extensive typing, deferring for now "tests/unit/test_aero.py", "tests/unit/test_detection.py" ] +extraPaths = [ + "packages/minisky", + "packages/minisky-example/src", + "packages/minisky-example-customautopilot/src", + "packages/minisky-tangram/src", + "packages/tangram-minisky/src", +] pythonVersion = "3.11" # "standard" catches useful bugs while staying practical for this numpy/scipy-heavy fork. typeCheckingMode = "standard" # Fully-typed files where any leftover implicit Any should be an error. -strict = ["example_plugins/*.py"] +strict = [ + "packages/minisky-example*/src/**/*.py", + "packages/minisky-tangram/src/**/*.py", +] reportMissingTypeStubs = false reportWildcardImportFromLibrary = false diff --git a/readme.md b/readme.md index 38552f5..26d9747 100644 --- a/readme.md +++ b/readme.md @@ -99,10 +99,9 @@ Note that commands are case-insensitive. Use the simulator in your Python code: ```python -from minisky import DEFAULT_SETTINGS_FILE, MiniSky, MiniSkySettings +from minisky import MiniSky -settings = MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE) -with MiniSky(settings) as runtime: +with MiniSky() as runtime: runtime.traffic.cre( "KL315", lat=52.0, lon=4.0, hdg=45, alt=5000, spd=250 ) @@ -116,6 +115,8 @@ with MiniSky(settings) as runtime: ## Documentation +MiniSky runs with built-in defaults. The [configuration guide](docs/guides/configuration.md) explains the optional user config file, overrides, and plugin tables. + The documentation lives in `docs/` and is built with Zensical; the API reference is generated from the docstrings with mkdocstrings. diff --git a/settings.toml b/settings.toml deleted file mode 100644 index c3fe47e..0000000 --- a/settings.toml +++ /dev/null @@ -1,18 +0,0 @@ -asas_dtlookahead = 300 # ASAS lookahead time [sec] -asas_pzr = 5 # ASAS horizontal PZ margin [nm] -asas_pzh = 1000 # ASAS vertical PZ margin [ft] -asas_marh = 1.05 # ASAS protected zone factors - horizontal -asas_marv = 1.05 # ASAS protected zone factors - vertical - -# Plugin settings -plugin_path = "example_plugins" # Directory to search for plugins -enabled_plugins = ["TANGRAM"] # Plugins to load at startup - -# Tangram bridge plugin (enabled_plugins = ["TANGRAM"], installed by `just sync`). -# Uncomment [tangram] and any key below to override its default. The whole table is -# optional; when absent the defaults shown here apply. Keep it last in the file — in -# TOML every bare key after a [table] header belongs to that table. -# [tangram] -# redis_url = "redis://127.0.0.1:6379" # Redis used by the tangram deployment -# channel = "minisky" # publishes to::*, listens on from::* -# max_hz = 5 # wall-clock cap on snapshot publish rate diff --git a/tests/conftest.py b/tests/conftest.py index 6dfb175..b8d8754 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,15 +12,21 @@ import pytest from minisky import MiniSky -from minisky.core.settings import DEFAULT_SETTINGS_FILE, MiniSkySettings +from minisky.core.config import MiniSkyConfig from minisky.simulation import Simulation from tests._types import RunCommand, StepUntil @pytest.fixture(scope="session") -def runtime() -> Iterator[MiniSky]: +def config() -> MiniSkyConfig: + """Immutable default runtime configuration shared by tests.""" + return MiniSkyConfig() + + +@pytest.fixture(scope="session") +def runtime(config: MiniSkyConfig) -> Iterator[MiniSky]: """Session-wide explicit MiniSky runtime.""" - instance = MiniSky(MiniSkySettings.from_file(DEFAULT_SETTINGS_FILE)) + instance = MiniSky(config) yield instance instance.close() diff --git a/tests/integration/test_conflict.py b/tests/integration/test_conflict.py index 5c5c1b5..66bb171 100644 --- a/tests/integration/test_conflict.py +++ b/tests/integration/test_conflict.py @@ -129,7 +129,7 @@ def test_sethpz_status_uses_default(self, runtime: MiniSky, run_cmd: RunCommand) def test_hpz_default_consistent_after_reset(self, runtime: MiniSky, sim: Simulation) -> None: # reset() must restore the same default as __init__ - assert runtime.traffic.cd.hpz_def == pytest.approx(runtime.settings.asas_pzh * FT) + assert runtime.traffic.cd.hpz_def == pytest.approx(runtime.config.asas_pzh * FT) def test_zoner_with_callsign_sets_aircraft_rpz( self, runtime: MiniSky, run_cmd: RunCommand diff --git a/tests/integration/test_plugin.py b/tests/integration/test_plugin.py index 88f6264..6150261 100644 --- a/tests/integration/test_plugin.py +++ b/tests/integration/test_plugin.py @@ -1,34 +1,55 @@ -"""Integration tests for runtime-owned plugin discovery and loading.""" +"""Integration tests for runtime-owned plugin loading and lifecycle.""" from __future__ import annotations +import asyncio +import importlib import warnings +from collections.abc import AsyncGenerator, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import cast +import numpy as np import pytest +from pydantic import BaseModel -from minisky import MiniSky -from minisky.plugin.plugin import Plugin +from minisky import MiniSky, MiniSkyConfig +from minisky import plugin as plugin_api from minisky.simulation import Simulation -from tests._types import RunCommand +from minisky.traffic import Traffic +from minisky.traffic.autopilot import Autopilot + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" class TestDiscovery: - def test_discover_finds_example_plugins(self, runtime: MiniSky) -> None: - runtime.plugins.discover() - assert "EXAMPLE" in runtime.plugins.plugins + def test_discovery_does_not_import(self, monkeypatch: pytest.MonkeyPatch) -> None: + class LazyEntryPoint: + name = "lazy" - def test_discovery_does_not_import(self, runtime: MiniSky) -> None: - plugin = runtime.plugins.plugins["EXAMPLE"] - if not plugin.loaded: - assert plugin.module is None + def load(self) -> object: + pytest.fail("discovery imported the plugin") - def test_manage_plugins_list(self, runtime: MiniSky) -> None: - ok, text = runtime.plugins.manage("LIST") + module = importlib.import_module("minisky.plugin.plugin") + monkeypatch.setattr(module.metadata, "entry_points", lambda *, group: (LazyEntryPoint(),)) + runtime = MiniSky(MiniSkyConfig()) + try: + assert "LAZY" in runtime.plugins.plugins + finally: + runtime.close() + + def test_listing(self, runtime: MiniSky) -> None: + ok, text = runtime.plugins.listing() assert ok assert "EXAMPLE" in text - def test_unknown_plugin_load_fails(self, runtime: MiniSky) -> None: - ok, message = runtime.plugins.load("NOSUCHPLUGIN") + @pytest.mark.anyio + async def test_unknown_plugin_load_fails(self, runtime: MiniSky) -> None: + ok, message = await runtime.plugins.load("NOSUCHPLUGIN") assert not ok assert "not found" in message.lower() @@ -39,37 +60,528 @@ def test_discovery_emits_no_deprecation_warning(self, runtime: MiniSky) -> None: assert "EXAMPLE" in runtime.plugins.plugins -@pytest.fixture -def loaded_example(runtime: MiniSky) -> Plugin: - """Load the EXAMPLE plugin once into the session runtime.""" - plugin = runtime.plugins.plugins["EXAMPLE"] - if not plugin.loaded: - ok, message = runtime.plugins.load("EXAMPLE") +@dataclass +class FakeEntryPoint: + name: str + declaration: object + + def load(self) -> object: + return self.declaration + + +def install(monkeypatch: pytest.MonkeyPatch, *entries: FakeEntryPoint) -> None: + module = importlib.import_module("minisky.plugin.plugin") + monkeypatch.setattr(module.metadata, "entry_points", lambda *, group: entries) + + +def run_command(runtime: MiniSky, command: str) -> str: + runtime.commands.stack(command) + runtime.simulation.step() + return runtime.console.read_output_buffer() + + +@pytest.mark.anyio +async def test_example_commands_and_entity_are_runtime_owned() -> None: + runtime = MiniSky(MiniSkyConfig()) + try: + ok, message = await runtime.plugins.load("EXAMPLE") assert ok, message - return plugin + record = runtime.plugins.plugins["EXAMPLE"] + assert record.loaded + assert tuple(runtime.plugins.loaded_plugins) == ("EXAMPLE",) + run_command(runtime, "CRE KL001,A320,52,4,90,FL100,250") + assert "150" in run_command(runtime, "PASSENGERS KL001 150") + assert "150" in run_command(runtime, "PASSENGERS KL001") -class TestLoading: - def test_load_registers_plugin(self, runtime: MiniSky, loaded_example: Plugin) -> None: - assert loaded_example.loaded - assert "EXAMPLE" in runtime.plugins.loaded_plugins + again = await runtime.plugins.load("EXAMPLE") + assert again == (False, "Plugin EXAMPLE already loaded") + finally: + await runtime.aclose() - def test_double_load_rejected(self, runtime: MiniSky, loaded_example: Plugin) -> None: - ok, message = runtime.plugins.load("EXAMPLE") - assert not ok - assert "already loaded" in message.lower() - - def test_plugin_stack_command_registered( - self, sim: Simulation, loaded_example: Plugin, run_cmd: RunCommand - ) -> None: - run_cmd("CRE KL001,A320,52,4,90,FL100,250") - output = run_cmd("PASSENGERS KL001 150") - assert "150" in output - - def test_plugin_entity_tracks_aircraft( - self, sim: Simulation, loaded_example: Plugin, run_cmd: RunCommand - ) -> None: - run_cmd("CRE KL001,A320,52,4,90,FL100,250") - run_cmd("PASSENGERS KL001 42") - output = run_cmd("PASSENGERS KL001") - assert "42" in output + +@pytest.mark.anyio +async def test_example_entity_sizes_existing_traffic_and_retires() -> None: + from minisky_example import Example + + runtime = MiniSky(MiniSkyConfig()) + runtime.traffic.cre("KL001", "A320", lat=52.0, lon=4.0, hdg=90, alt=3000, spd=150) + ok, message = await runtime.plugins.load("EXAMPLE") + assert ok, message + record = runtime.plugins.plugins["EXAMPLE"] + entity = cast(Example, record.entities[0]) + assert record.entities == (entity,) + assert len(entity.npassengers) == 1 + assert entity._traffic is runtime.traffic + + await runtime.aclose() + assert entity._retired + assert entity._traffic is None + + +@pytest.mark.anyio +async def test_entity_backfill_follows_lifespan_startup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + entered = asyncio.Event() + release = asyncio.Event() + + class Callsigns(plugin_api.Entity): + def __init__(self) -> None: + super().__init__() + with self.settrafarrays(): + self.names = np.array([], dtype=object) + + def create(self, n: int = 1) -> None: + super().create(n) + self.names[-n:] = self.traffic.callsign[-n:] + + entity = Callsigns() + + @asynccontextmanager + async def lifespan(_runtime: plugin_api.PluginRuntime) -> AsyncGenerator[None]: + entered.set() + await release.wait() + yield + + def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: + context.mount(entity) + return context.finish(lifespan=lifespan) + + install(monkeypatch, FakeEntryPoint("callsigns", plugin_api.Plugin(build=build))) + runtime = MiniSky(MiniSkyConfig()) + load_task = asyncio.create_task(runtime.plugins.load("CALLSIGNS")) + try: + await entered.wait() + runtime.traffic.cre("KL001", alt=3000.0, spd=150.0) + release.set() + ok, message = await load_task + assert ok, message + assert entity.names.tolist() == ["KL001"] + finally: + release.set() + if not load_task.done(): + await load_task + await runtime.aclose() + + +@pytest.mark.anyio +async def test_typed_declaration_builds_validated_runtime_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Config(BaseModel): + value: int + + @dataclass(frozen=True) + class State: + value: int + random: object + + def build(context: plugin_api.PluginContext[Config]) -> plugin_api.PluginSpec: + context.mount(State(context.config.value, context.python_random)) + return context.finish() + + install( + monkeypatch, FakeEntryPoint("typed", plugin_api.Plugin(build=build, config_class=Config)) + ) + runtime = MiniSky(MiniSkyConfig(plugins={"typed": {"value": 7}})) + try: + ok, message = await runtime.plugins.load("TYPED") + assert ok, message + state = State(7, runtime.python_random) + assert runtime.variables.varlist["typed"] == (state, ["value", "random"]) + assert runtime.plugins.plugins["TYPED"].spec == plugin_api.PluginSpec((state,), state) + finally: + await runtime.aclose() + + +@pytest.mark.anyio +async def test_mount_binds_command_to_exact_instance_and_infers_text( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Component: + def __init__(self) -> None: + self.values: list[int] = [] + + @plugin_api.command(arguments="int") + def record(self, value: int) -> tuple[bool, str]: + """Record an integer.""" + self.values.append(value) + return True, f"recorded {value}" + + component = Component() + + def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: + context.mount(component) + return context.finish() + + install(monkeypatch, FakeEntryPoint("mounted", plugin_api.Plugin(build=build))) + runtime = MiniSky(MiniSkyConfig()) + try: + assert "RECORD" not in runtime.commands.cmddict + ok, message = await runtime.plugins.load("MOUNTED") + assert ok, message + command = runtime.commands.cmddict["RECORD"] + assert command.callback.__self__ is component + assert command.brief == "RECORD value" + assert command.help == "Record an integer." + + runtime.commands.stack("RECORD 7") + runtime.simulation.step() + assert component.values == [7] + finally: + await runtime.aclose() + + +@pytest.mark.anyio +async def test_multiple_hook_declarations_keep_independent_timing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[tuple[str, float]] = [] + + class Component: + @plugin_api.hook("preupdate", name="before") + @plugin_api.hook("update", interval=2.0, name="after") + def pulse(self, dt: float) -> None: + events.append(("pulse", dt)) + + component = Component() + + def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: + context.mount(component, expose=False) + return context.finish() + + install(monkeypatch, FakeEntryPoint("hooks", plugin_api.Plugin(build=build))) + runtime = MiniSky(MiniSkyConfig()) + try: + ok, message = await runtime.plugins.load("HOOKS") + assert ok, message + runtime.plugins.preupdate() + runtime.plugins.update() + runtime.plugins.preupdate() + runtime.plugins.update() + assert events == [("pulse", 1.0), ("pulse", 1.0), ("pulse", 2.0)] + finally: + await runtime.aclose() + + +@pytest.mark.anyio +async def test_failing_hook_is_disabled_without_disabling_plugin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = {"broken": 0, "healthy": 0} + + class Component: + @plugin_api.hook + def update(self) -> None: + calls["broken"] += 1 + raise RuntimeError("hook failed") + + @plugin_api.hook("update", name="healthy") + def healthy(self) -> None: + calls["healthy"] += 1 + + component = Component() + + def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: + context.mount(component, expose=False) + return context.finish() + + install(monkeypatch, FakeEntryPoint("hooks", plugin_api.Plugin(build=build))) + runtime = MiniSky(MiniSkyConfig()) + try: + ok, message = await runtime.plugins.load("HOOKS") + assert ok, message + runtime.plugins.update() + runtime.plugins.update() + assert calls == {"broken": 1, "healthy": 2} + assert tuple(runtime.plugins.loaded_plugins) == ("HOOKS",) + finally: + await runtime.aclose() + + +@pytest.mark.anyio +async def test_replacement_visibility_is_runtime_local_and_removed_on_shutdown() -> None: + from minisky_example_customautopilot import CustomAutoPilot + + runtime_a = MiniSky(MiniSkyConfig()) + runtime_b = MiniSky(MiniSkyConfig()) + try: + assert runtime_a.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT")[0] is False + assert runtime_b.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT")[0] is False + + alt_callback = runtime_a.commands.cmddict["ALT"].callback + ok, message = await runtime_a.plugins.load("CUSTOMAUTOPILOT") + assert ok, message + assert runtime_a.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT")[0] is True + assert type(runtime_a.traffic.ap) is CustomAutoPilot + assert runtime_b.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT")[0] is False + + await runtime_a.plugins.aclose() + assert type(runtime_a.traffic.ap) is Autopilot + assert runtime_a.replaceables.select("AUTOPILOT", "CUSTOMAUTOPILOT")[0] is False + assert runtime_a.commands.cmddict["ALT"].callback is alt_callback + finally: + await runtime_a.aclose() + await runtime_b.aclose() + + +@pytest.mark.anyio +async def test_replacement_arrays_size_existing_traffic( + monkeypatch: pytest.MonkeyPatch, +) -> None: + @plugin_api.replacement + class ArrayAutopilot(Autopilot): + def __init__(self, traffic: Traffic, get_simulation: Callable[[], Simulation]) -> None: + super().__init__(traffic, get_simulation) + self.alt_commands = 0 + with self.settrafarrays(): + self.plugin_value = np.array([]) + + def selaltcmd(self, idx: int | np.ndarray, alt: float, vspd: float | None = None): + self.alt_commands += 1 + return super().selaltcmd(idx, alt, vspd) + + def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: + return context.finish(replacements=(ArrayAutopilot,)) + + install(monkeypatch, FakeEntryPoint("arrays", plugin_api.Plugin(build=build))) + runtime = MiniSky(MiniSkyConfig()) + try: + runtime.traffic.cre("KL001", alt=3000.0, spd=150.0) + ok, message = await runtime.plugins.load("ARRAYS") + assert ok, message + alt_callback = runtime.commands.cmddict["ALT"].callback + assert runtime.replaceables.select("AUTOPILOT", "ARRAYAUTOPILOT")[0] is True + selected = cast(ArrayAutopilot, runtime.traffic.ap) + runtime.commands.stack("ALT KL001 FL100") + runtime.simulation.step() + assert type(selected) is ArrayAutopilot + assert selected.plugin_value.tolist() == [0.0] + assert selected.alt_commands == 1 + assert runtime.commands.cmddict["ALT"].callback is alt_callback + finally: + await runtime.aclose() + + +@pytest.mark.anyio +async def test_lifespan_wraps_publication_and_runtime_is_revoked( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[tuple[str, bool]] = [] + capability: plugin_api.PluginRuntime | None = None + + class Component: + @plugin_api.command(name="LIFECYCLE") + def command(self) -> None: + pass + + @asynccontextmanager + async def lifespan(runtime_api: plugin_api.PluginRuntime) -> AsyncGenerator[None]: + nonlocal capability + capability = runtime_api + events.append(("enter", "LIFECYCLE" in runtime.commands.cmddict)) + with pytest.raises(RuntimeError, match="not published"): + runtime_api.stack_command("LIFECYCLE") + try: + yield + finally: + with pytest.raises(RuntimeError, match="revoked"): + runtime_api.status() + events.append(("exit", "LIFECYCLE" in runtime.commands.cmddict)) + + def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: + context.mount(Component(), expose=False) + return context.finish(lifespan=lifespan) + + install(monkeypatch, FakeEntryPoint("lifecycle", plugin_api.Plugin(build=build))) + runtime = MiniSky(MiniSkyConfig()) + ok, message = await runtime.plugins.load("LIFECYCLE") + assert ok, message + assert events == [("enter", False)] + assert "LIFECYCLE" in runtime.commands.cmddict + assert capability is not None + capability.stack_command("LIFECYCLE") + assert runtime.simulation.step() + + await runtime.aclose() + assert events == [("enter", False), ("exit", False)] + + +@pytest.mark.anyio +async def test_shutdown_cancels_pending_command_before_lifespan_exit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[str] = [] + + class Component: + @plugin_api.command(name="BLOCK") + async def block(self) -> None: + events.append("command started") + try: + await asyncio.Event().wait() + finally: + events.append("command cancelled") + + @asynccontextmanager + async def lifespan(_runtime: plugin_api.PluginRuntime) -> AsyncGenerator[None]: + try: + yield + finally: + events.append("lifespan exited") + + def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: + context.mount(Component(), expose=False) + return context.finish(lifespan=lifespan) + + install(monkeypatch, FakeEntryPoint("blocked", plugin_api.Plugin(build=build))) + runtime = MiniSky(MiniSkyConfig()) + ok, message = await runtime.plugins.load("BLOCKED") + assert ok, message + runtime.commands.stack("BLOCK") + assert not runtime.simulation.step() + await asyncio.sleep(0) + + await runtime.aclose() + + assert events == ["command started", "command cancelled", "lifespan exited"] + assert not runtime.commands.command_pending + + +@pytest.mark.anyio +async def test_failed_lifespan_startup_is_atomic( + monkeypatch: pytest.MonkeyPatch, +) -> None: + capability: plugin_api.PluginRuntime | None = None + console_messages: list[str] = [] + + class Component: + @plugin_api.command(name="FAILEDSTART") + def command(self) -> None: + pass + + @asynccontextmanager + async def lifespan(runtime_api: plugin_api.PluginRuntime) -> AsyncGenerator[None]: + nonlocal capability + capability = runtime_api + runtime_api.subscribe_console(console_messages.append) + raise RuntimeError("startup failed") + yield + + def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: + context.mount(Component()) + return context.finish(lifespan=lifespan) + + install(monkeypatch, FakeEntryPoint("failedstart", plugin_api.Plugin(build=build))) + runtime = MiniSky(MiniSkyConfig()) + ok, message = await runtime.plugins.load("FAILEDSTART") + + assert not ok + assert "startup failed" in message + assert "FAILEDSTART" not in runtime.commands.cmddict + assert "failedstart" not in runtime.variables.varlist + assert not runtime.plugins.plugins["FAILEDSTART"].loaded + assert "FAILEDSTART" not in runtime.plugins.loaded_plugins + assert capability is not None + with pytest.raises(RuntimeError, match="revoked"): + capability.status() + + runtime.console.echo("after failed startup") + assert console_messages == [] + await runtime.aclose() + + +@pytest.mark.anyio +async def test_load_configured_continues_after_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: + return context.finish() + + install( + monkeypatch, + FakeEntryPoint("first", plugin_api.Plugin(build=build)), + FakeEntryPoint("broken", object()), + FakeEntryPoint("last", plugin_api.Plugin(build=build)), + ) + runtime = MiniSky(MiniSkyConfig(plugins={"first": {}, "broken": {}, "last": {}})) + try: + loaded = await runtime.plugins.load_configured() + assert loaded == ("FIRST", "LAST") + assert tuple(runtime.plugins.loaded_plugins) == ("FIRST", "LAST") + assert not runtime.plugins.plugins["BROKEN"].loaded + finally: + await runtime.aclose() + + +@pytest.mark.anyio +async def test_shutdown_is_reverse_order_and_aggregates_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[str] = [] + + def declaration(name: str) -> plugin_api.Plugin: + @asynccontextmanager + async def lifespan(runtime_api: plugin_api.PluginRuntime) -> AsyncGenerator[None]: + events.append(f"enter {name}") + try: + yield + finally: + with pytest.raises(RuntimeError, match="revoked"): + runtime_api.status() + events.append(f"exit {name}") + raise RuntimeError(f"{name} shutdown failed") + + def build(context: plugin_api.PluginContext[object]) -> plugin_api.PluginSpec: + return context.finish(lifespan=lifespan) + + return plugin_api.Plugin(build=build) + + install( + monkeypatch, + FakeEntryPoint("first", declaration("first")), + FakeEntryPoint("second", declaration("second")), + ) + runtime = MiniSky(MiniSkyConfig()) + assert (await runtime.plugins.load("FIRST"))[0] + assert (await runtime.plugins.load("SECOND"))[0] + + with pytest.raises(ExceptionGroup) as exc_info: + await runtime.aclose() + + assert events == ["enter first", "enter second", "exit second", "exit first"] + assert [str(error) for error in exc_info.value.exceptions] == [ + "second shutdown failed", + "first shutdown failed", + ] + assert runtime.plugins.loaded_plugins == {} + + +@pytest.mark.anyio +async def test_concurrent_duplicate_loads_are_serialized() -> None: + runtime = MiniSky(MiniSkyConfig()) + try: + results = await asyncio.gather( + runtime.plugins.load("EXAMPLE"), + runtime.plugins.load("EXAMPLE"), + ) + assert sorted(results) == [ + (False, "Plugin EXAMPLE already loaded"), + (True, "Successfully loaded plugin EXAMPLE"), + ] + finally: + await runtime.aclose() + + +@pytest.mark.anyio +async def test_plugin_stack_load_uses_awaitable_command_boundary() -> None: + runtime = MiniSky(MiniSkyConfig()) + try: + runtime.commands.stack("PLUGINS LOAD EXAMPLE") + assert runtime.simulation.step() is False + assert runtime.commands.command_pending + await runtime.commands.wait_for_pending() + assert runtime.simulation.step() is True + assert runtime.plugins.plugins["EXAMPLE"].loaded + finally: + await runtime.aclose() diff --git a/tests/integration/test_stack.py b/tests/integration/test_stack.py index 359e80f..acecdbe 100644 --- a/tests/integration/test_stack.py +++ b/tests/integration/test_stack.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from io import StringIO from pathlib import Path @@ -41,6 +42,58 @@ def test_command_stacked_during_processing_is_kept( runtime.simulation.step() assert runtime.traffic.ntraf == 1 + def test_awaitable_command_pauses_step_and_preserves_order( + self, runtime: MiniSky, sim: Simulation + ) -> None: + async def exercise() -> None: + events: list[str] = [] + release = asyncio.Event() + + async def pause() -> None: + events.append("pause:start") + await release.wait() + events.append("pause:end") + + def queued() -> None: + events.append("queued") + + def late() -> None: + events.append("late") + + prepared = ( + runtime.commands.prepare_command(pause, name="TESTPAUSE"), + runtime.commands.prepare_command(queued, name="TESTQUEUED"), + runtime.commands.prepare_command(late, name="TESTLATE"), + ) + runtime.commands.validate_commands(prepared) + runtime.commands.install_commands(prepared) + try: + runtime.simulation.op() + start = runtime.simulation.simt + runtime.commands.stack("TESTPAUSE;TESTQUEUED") + + assert not runtime.simulation.step() + await asyncio.sleep(0) + assert events == ["pause:start"] + assert runtime.commands.command_pending + assert runtime.simulation.simt == start + + runtime.commands.stack("TESTLATE") + assert not runtime.simulation.step() + assert runtime.simulation.simt == start + + release.set() + await runtime.commands.wait_for_pending() + assert runtime.simulation.step() + assert events == ["pause:start", "pause:end", "queued", "late"] + assert runtime.simulation.simt == start + runtime.simulation.simdt + finally: + runtime.commands.remove_commands(prepared) + runtime.commands.reset() + await asyncio.sleep(0) + + asyncio.run(exercise()) + class TestCommands: def test_cre_via_stack(self, runtime: MiniSky, run_cmd: RunCommand) -> None: diff --git a/tests/integration/test_streaming.py b/tests/integration/test_streaming.py index e2e9431..5ed787a 100644 --- a/tests/integration/test_streaming.py +++ b/tests/integration/test_streaming.py @@ -45,7 +45,10 @@ def test_snapshot_structure_and_units( # FL100 == 10000 ft == 3048 m, altitude stays SI (metres) on the wire here. assert ac["alt"][0] == pytest.approx(3048.0, abs=1.0) # Conflict counters are present and zero for a single aircraft. - assert (ac["nconf_cur"], ac["nconf_tot"], ac["nlos_cur"], ac["nlos_tot"]) == (0, 0, 0, 0) + assert ac["nconf_cur"] == 0 + assert ac["nconf_tot"] == 0 + assert ac["nlos_cur"] == 0 + assert ac["nlos_tot"] == 0 assert ac["inconf"] == [False] diff --git a/tests/integration/test_tangram_bridge.py b/tests/integration/test_tangram_bridge.py index 95196ae..fc15afe 100644 --- a/tests/integration/test_tangram_bridge.py +++ b/tests/integration/test_tangram_bridge.py @@ -9,15 +9,19 @@ import pytest from redis.client import PubSub -from example_plugins.tangram import TangramBridge from minisky import MiniSky from minisky.simulation import Simulation, SimulationState -from minisky.streaming import build_snapshot +from minisky_tangram import TangramBridge Observer = tuple[fakeredis.FakeRedis, PubSub] StepUntil = Callable[[Callable[[], bool]], int] +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + @pytest.fixture def redis_server() -> fakeredis.FakeServer: return fakeredis.FakeServer() @@ -27,27 +31,25 @@ def redis_server() -> fakeredis.FakeServer: def bridge( runtime: MiniSky, sim: Simulation, redis_server: fakeredis.FakeServer ) -> Iterator[TangramBridge]: + del sim bridge = TangramBridge( "redis://fake", "minisky", max_hz=1000, - snapshot_builder=lambda: build_snapshot( - runtime.simulation, runtime.traffic, runtime.runner, runtime.commands - ), - console=runtime.console, - simulation=runtime.simulation, - runner=runtime.runner, - traffic=runtime.traffic, - get_scenname=runtime.commands.get_scenname, - stack_command=runtime.commands.stack, redis_factory=lambda url: fakeredis.FakeRedis(server=redis_server), ) - ok, msg = bridge.start() + # TODO(abraham): load tangram through PluginManager when thread shutdown + # ownership is hardened. + plugin_runtime = runtime.plugins._plugin_runtime() + plugin_runtime._activate() + plugin_runtime.subscribe_console(bridge.capture_console) + ok, msg = bridge.start(plugin_runtime) assert ok, msg # The I/O thread subscribes asynchronously; commands published before the # subscription is live would be silently lost (pub/sub has no replay). assert bridge.ready.wait(timeout=5.0), "bridge did not subscribe in time" yield bridge + plugin_runtime._revoke() bridge.stop() @@ -66,7 +68,7 @@ def wait_for( pred: Callable[[dict[str, Any]], bool] = lambda payload: True, timeout: float = 5.0, ) -> dict[str, Any]: - """Read pattern messages until one on the given topic satisfies pred.""" + """Read pattern messages until a message on the given topic satisfies pred.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: message = pubsub.get_message(timeout=0.05) diff --git a/tests/test_api.py b/tests/test_api.py index e948555..be08a1e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -14,16 +14,16 @@ from fastapi import FastAPI from fastapi.testclient import TestClient -from minisky import MiniSky +from minisky import MiniSky, MiniSkyConfig pytestmark = pytest.mark.api @pytest.fixture(scope="module") -def server_app() -> FastAPI: +def server_app(config: MiniSkyConfig) -> FastAPI: from minisky.server import create_app - return create_app() + return create_app(MiniSky(config)) @pytest.fixture(scope="module") diff --git a/tests/unit/test_tangram_plugin.py b/tests/unit/test_tangram_plugin.py index c17004a..e28aa63 100644 --- a/tests/unit/test_tangram_plugin.py +++ b/tests/unit/test_tangram_plugin.py @@ -2,8 +2,8 @@ import pytest -from example_plugins.tangram import convert_snapshot, extract_command from minisky.streaming import Snapshot +from minisky_tangram import convert_snapshot, extract_command def make_snapshot() -> Snapshot: diff --git a/uv.lock b/uv.lock index ae3e134..04d50ce 100644 --- a/uv.lock +++ b/uv.lock @@ -19,9 +19,25 @@ resolution-markers = [ [manifest] members = [ "minisky", + "minisky-example", + "minisky-example-customautopilot", + "minisky-tangram", "tangram-minisky", ] +[manifest.dependency-groups] +dev = [ + { name = "fakeredis", specifier = ">=2.26" }, + { name = "ipykernel", specifier = ">=6.29.5" }, + { name = "pyright", specifier = ">=1.1.390" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.8.0" }, +] +docs = [ + { name = "mkdocstrings-python", specifier = ">=2.0.5" }, + { name = "zensical", specifier = ">=0.0.47" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -1237,7 +1253,7 @@ wheels = [ [[package]] name = "minisky" version = "0.1.0" -source = { editable = "." } +source = { editable = "packages/minisky" } dependencies = [ { name = "colorama" }, { name = "fastapi", extra = ["standard"] }, @@ -1246,6 +1262,7 @@ dependencies = [ { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "openap" }, { name = "pandas" }, + { name = "platformdirs" }, { name = "prompt-toolkit" }, { name = "pyarrow" }, { name = "requests" }, @@ -1255,19 +1272,6 @@ dependencies = [ { name = "typer" }, ] -[package.dev-dependencies] -dev = [ - { name = "fakeredis" }, - { name = "ipykernel" }, - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] -docs = [ - { name = "mkdocstrings-python" }, - { name = "zensical" }, -] - [package.metadata] requires-dist = [ { name = "colorama", specifier = ">=0.4.6" }, @@ -1276,6 +1280,7 @@ requires-dist = [ { name = "numpy", specifier = ">=2.2.2" }, { name = "openap", specifier = ">=2.4" }, { name = "pandas", specifier = ">=2.2.3" }, + { name = "platformdirs", specifier = ">=4.3.6" }, { name = "prompt-toolkit", specifier = ">=3.0.50" }, { name = "pyarrow", specifier = ">=19.0.1" }, { name = "requests", specifier = ">=2.32.3" }, @@ -1284,17 +1289,48 @@ requires-dist = [ { name = "typer", specifier = ">=0.15.0" }, ] -[package.metadata.requires-dev] -dev = [ - { name = "fakeredis", specifier = ">=2.26" }, - { name = "ipykernel", specifier = ">=6.29.5" }, - { name = "pyright", specifier = ">=1.1.390" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "ruff", specifier = ">=0.8.0" }, +[[package]] +name = "minisky-example" +version = "0.1.0" +source = { editable = "packages/minisky-example" } +dependencies = [ + { name = "minisky" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -docs = [ - { name = "mkdocstrings-python", specifier = ">=2.0.5" }, - { name = "zensical", specifier = ">=0.0.47" }, + +[package.metadata] +requires-dist = [ + { name = "minisky", editable = "packages/minisky" }, + { name = "numpy", specifier = ">=2.2.2" }, +] + +[[package]] +name = "minisky-example-customautopilot" +version = "0.1.0" +source = { editable = "packages/minisky-example-customautopilot" } +dependencies = [ + { name = "minisky" }, +] + +[package.metadata] +requires-dist = [{ name = "minisky", editable = "packages/minisky" }] + +[[package]] +name = "minisky-tangram" +version = "0.1.0" +source = { editable = "packages/minisky-tangram" } +dependencies = [ + { name = "minisky" }, + { name = "pydantic" }, + { name = "redis" }, +] + +[package.metadata] +requires-dist = [ + { name = "minisky", editable = "packages/minisky" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "redis", specifier = ">=5.0" }, ] [[package]] @@ -2772,7 +2808,7 @@ wheels = [ [[package]] name = "tangram-minisky" version = "0.1.0" -source = { editable = "example_plugins/tangram/tangram_minisky" } +source = { editable = "packages/tangram-minisky" } dependencies = [ { name = "tangram-core" }, ] diff --git a/zensical.toml b/zensical.toml index f273e8c..4904aa6 100644 --- a/zensical.toml +++ b/zensical.toml @@ -3,13 +3,14 @@ site_name = "MiniSky" site_description = "A minimal command line air traffic simulator with REST API (a BlueSky fork)" repo_url = "https://github.com/open-aviation/minisky" repo_name = "open-aviation/minisky" -watch = ["minisky"] +watch = ["packages/minisky/minisky"] nav = [ { "Home" = "index.md" }, { "Getting started" = "getting-started.md" }, { "Architecture" = "architecture.md" }, { "Upstream decisions" = "upstream.md" }, { "Guides" = [ + { "Configuration" = "guides/configuration.md" }, { "Command-line interface" = "guides/cli.md" }, { "Running scenarios" = "guides/running-scenarios.md" }, { "REST API server" = "guides/rest-api.md" }, @@ -70,7 +71,7 @@ search = {} [project.plugins.mkdocstrings.handlers.python] # tangram_minisky lives in its own workspace and is not installed in the docs # environment; point griffe at its source so it can analyse it statically. -paths = ["example_plugins/tangram/tangram_minisky/src"] +paths = ["packages/tangram-minisky/src"] inventories = [ "https://docs.python.org/3/objects.inv", "https://fastapi.tiangolo.com/objects.inv", @@ -103,7 +104,11 @@ attr_list = {} "pymdownx.superfences" = {} "pymdownx.highlight" = {} "pymdownx.inlinehilite" = {} -"pymdownx.snippets" = {} + +[project.markdown_extensions."pymdownx.snippets"] +base_path = ["."] +check_paths = true +dedent_subsections = true [project.markdown_extensions."pymdownx.tabbed"] alternate_style = true