Metadata-Version: 2.5
Name: beebium
Version: 0.12.0
Summary: Python client for the Beebium BBC Micro emulator
Project-URL: Homepage, https://github.com/rob-smallshire/beebium
Project-URL: Repository, https://github.com/rob-smallshire/beebium
Author-email: Robert Smallshire <robert@smallshire.org.uk>
License-Expression: GPL-3.0-or-later
License-File: COPYING.txt
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Emulators
Requires-Python: >=3.12
Requires-Dist: grpcio>=1.60.0
Requires-Dist: protobuf>=4.25.0
Requires-Dist: stevedore>=5.0.0
Provides-Extra: discovery
Requires-Dist: zeroconf>=0.131.0; extra == 'discovery'
Provides-Extra: imaging
Requires-Dist: pillow>=10.0.0; extra == 'imaging'
Description-Content-Type: text/markdown

<!-- Generated by scripts/generate_readme.py from readme/README.md.j2 and
     the tested snippets in readme/snippets/. Do not edit this file directly:
     edit the template or a snippet and run scripts/generate_readme.py.
     See CONTRIBUTING.md. -->
# Beebium Python Client

## What is Beebium?

Beebium is a headless, cycle-accurate BBC Micro emulator with a client-server
architecture. The emulator runs as a server process -- one per machine variant
(Model B, B+, B+ 128K, B with ROM/RAM board) -- and is driven entirely over
gRPC, so front-ends and automation talk to it across a well-defined protocol
rather than being wired into the emulation core.

## What is the Python client?

`beebium` is the Python client for that protocol: a typed API over the gRPC
services for controlling and automating emulator instances from Python -- type
on the keyboard, read the screen in any display mode, peek and poke memory,
drive the debugger, mount discs, and more. It is built for pytest-based testing
of BBC Micro software: a `bbc` fixture gives each test a fresh machine.

### Relationship to `beebium-server`

`beebium-server` is a separate, **optional** package: platform wheels that carry
the emulator itself -- the server binaries, the ROMs, the presets and the
bundled extensions. Install both and you have a complete, self-contained
headless system:

```bash
pip install beebium beebium-server
```

With `beebium-server` present, `Beebium.launch()` needs no arguments -- it finds
the server and its ROMs in that wheel. Without it, the client works just as well
with a server installed any other way (Homebrew, the `.deb`/`.rpm` packages,
Scoop, or a checkout build) or one already running, reached with
`Beebium.connect()`.

## Installation

With [uv](https://docs.astral.sh/uv/):

```bash
uv add beebium beebium-server        # add to your project
# or, into a plain virtualenv:
uv pip install beebium beebium-server
```

With pip:

```bash
pip install beebium beebium-server
```

`beebium-server` is optional (see above). On an externally-managed system
install into a virtualenv (or use `uv`/`pipx`).

### Extras

```bash
pip install beebium[imaging]     # + Pillow, for saving captured frames as images
pip install beebium[discovery]   # + zeroconf, for discovering servers over mDNS
```

## Usage

Every example below is a real test that runs against the `beebium-server` wheel
(see `readme/snippets/`), so the code here is the code that runs.

### Launch a server

The flagship path: with `beebium-server` installed, `launch()` takes no
arguments and stops the server again when the block exits.

```python
from beebium.client import Beebium

# With the beebium-server package installed, launch() needs no arguments:
# the emulator binary and its ROMs come from that wheel, and the server is
# stopped again when the block exits.
with Beebium.launch() as bbc:
    bbc.expect("BASIC")            # wait for the boot banner / BASIC prompt
    bbc.keyboard.type("PRINT 2+2")
    bbc.keyboard.press_return()
    print(bbc.expect("4"))         # -> 4
```

### Connect to a running server

```python
from beebium.client import Beebium

# Connect to a server that is already running. The target defaults to
# localhost on port 48875 (0xBEEB); pass "host:port" to reach another.
with Beebium.connect() as bbc:
    bbc.debugger.stop()
    print(f"PC = 0x{bbc.cpu.pc:04X}")
```

### Choose a variant or a specific server

```python
from beebium.client import Beebium

# Pick a machine variant: "model-b" (default), "model-b-plus",
# "model-b-plus-128k" or "model-b-romram". To run a specific install
# instead of the default search, pass server="/path/to/beebium" (a binary
# or an install root), or set the BEEBIUM_SERVER environment variable.
with Beebium.launch(variant="model-b-plus") as bbc:
    bbc.expect("BASIC")
```

### Type on the keyboard

Given a launched or connected `bbc`:

```python
bbc.keyboard.type("PRINT 2+2")     # type text, shifting as needed
bbc.keyboard.press_return()

bbc.keyboard.key_down("A")         # or drive individual keys
bbc.keyboard.key_up("A")
```

Typing needs no wait for boot: the server presses nothing until the guest has
finished resetting and asked the MOS for typed input, so text typed straight
after a launch or a Break is not lost in the reset. A test that wants boot
finished before doing something other than typing calls `bbc.wait_until_ready()`.
Game control, by `key_down()` and `key_up()`, is never held. A guest that never
asks the MOS for input (a game scanning the keyboard itself) never becomes
ready, so `wait_until_typing_complete()` and `wait_until_ready()` give it
`ready_within=5.0` emulated seconds after its reset and then raise; pass a
larger budget, or `None`, for a boot known to be slow.

Typing is paced in emulated time, 80 ms a character at 2 MHz (12.5 characters
an emulated second) whatever the speed multiplier, so N characters need
N x 0.08 emulated seconds before the last key, a final RETURN say, has gone in.
Rather than budget that, run the machine and call
`bbc.keyboard.wait_until_typing_complete()`. A reset of any kind -- Break,
Ctrl-Break, `bbc.debugger.reset()` -- discards the line being typed and
everything queued behind it rather than finishing it in the new session;
`typing_status().discarded_by_reset` counts what was lost, and
`wait_until_typing_complete()` raises on it. Type again after the reset for
the new session. A modifier meant as a reset option must stay down for emulated
time after Break is released, so use `ctrl_break()` and `shift_break()` rather
than `ctrl_down()`, `break_down()`, `break_up()`, `ctrl_up()` by hand.

### Read the screen

`bbc.video.screen_text()` reads the displayed text in any mode. For MODE 7
(teletext) work, `beebium.client.screen` adds helpers -- `read_mode7_screen`,
`screen_contains`, `find`, `dump_screen` -- that read the screen as displayed,
following hardware scrolling and shadow modes.

```python
# Read the text the machine is displaying, whatever the screen mode.
text = bbc.video.screen_text().text

# Wait (up to a timeout) for text to appear -- pexpect-style automation.
bbc.expect("BASIC")
```

To compare the display with memory, `bbc.video.screen_region()` says where the
displayed screen is, as the server computes it from the live 6845, latch and
paging: the region the video reads (`main_ram`; `shadow_ram` on a B+ or B+ 128K
in a shadow mode; always `main_ram` on an Integra-B, whose shadow RAM is the
CPU's), and the address ranges in display order, wrapping as the hardware
does. `bbc.memory.region(r.region).peek[start:end]` then reads each range.

### Read and write memory

```python
# Side-effect-free peek -- safe even for I/O registers.
via_flags = bbc.memory.address.peek[0xFE4D]        # System VIA IFR
zero_page = bbc.memory.address.peek[0x0000:0x0100]

# Follow a 6502 vector (16-bit little-endian).
wrchv = bbc.memory.address.peek.word(0x020E)       # the OSWRCH vector

# Bus access reads and writes through the memory bus, like real hardware.
bbc.memory.address.bus[0x1900] = 0x42
value = bbc.memory.address.bus[0x1900]
```

### Drive the debugger

```python
state = bbc.debugger.stop()            # pause; returns the execution state
print(state.is_running)                # -> False
print(bbc.cpu.registers)               # A=.. X=.. Y=.. SP=.. PC=.. P=.. [flags]

# Break the next time the OS writes a character, then let it run. The
# breakpoint is removed automatically when the block exits.
with bbc.debugger.breakpoint(0xFFEE):  # OSWRCH entry point
    bbc.keyboard.type("X")             # the echoed key drives OSWRCH
    bbc.debugger.run_and_wait_for_stop()
    print(f"stopped at PC=0x{bbc.cpu.pc:04X}")
```

### Run state

Whether a call needs the machine running, and whether it leaves it running or
stopped, is in each method's docstring as two lines, `Requires: ...` and
`Leaves the machine: ...`; this table collects them (a test keeps the two in
step). The calls that run to a point -- `run_for_emulated_seconds`,
`run_until_or_timeout` and the debugger's run-to methods -- leave the machine
STOPPED; call `bbc.debugger.run()` before anything that needs it running.
`launch()` starts the machine itself once it has connected, so a launch means
the same thing in emulated time on every host; `start_stopped=True` leaves it
stopped at the reset vector, having executed no instruction, for a test that
sets breakpoints or watchpoints before `bbc.debugger.run()`.

| Call | Requires | Leaves the machine |
|---|---|---|
| `Beebium.launch` | nothing | RUNNING |
| `Beebium.connect` | nothing | unchanged |
| `Beebium.expect` | a running machine, unless the text is already on the screen | unchanged |
| `Beebium.boot_disc` | any run state | RUNNING |
| `Beebium.wait_until_ready` | a running machine | unchanged |
| `Beebium.run_for_emulated_seconds` | any run state | STOPPED |
| `Beebium.run_until_or_timeout` | any run state | STOPPED |
| `Beebium.run_fields` | any run state | STOPPED |
| `Debugger.run` | a stopped machine | RUNNING |
| `Debugger.stop` | any run state | STOPPED |
| `Debugger.reset` | any run state | STOPPED |
| `Debugger.step` | a stopped machine | STOPPED |
| `Debugger.step_cycles` | a stopped machine | STOPPED |
| `Debugger.ensure_running` | any run state | RUNNING |
| `Debugger.ensure_stopped` | any run state | STOPPED |
| `Debugger.running` | any run state | as it was on entry |
| `Debugger.wait_for_stop` | any run state | STOPPED |
| `Debugger.run_and_wait_for_stop` | any run state | STOPPED |
| `Debugger.run_to_cycle` | any run state | STOPPED |
| `Debugger.run_to` | any run state | STOPPED |
| `Debugger.measure` | any run state | STOPPED |
| `Keyboard.type` | any run state | unchanged |
| `Keyboard.wait_until_typing_complete` | a running machine and no reset part-way, unless nothing is queued | unchanged |
| `Keyboard.wait_until_ready` | a running machine | unchanged |
| `Keyboard.press_break` | a running machine | RUNNING |
| `Keyboard.ctrl_break` | a running machine | RUNNING |
| `Keyboard.shift_break` | a running machine | RUNNING |
| `Keyboard.tap_caps_lock` | a running machine | RUNNING |
| `Keyboard.tap_shift_lock` | a running machine | RUNNING |
| `Keyboard.text_input` | a running machine | unchanged |
| `Video.capture_frame` | any run state; with after_cycle, a running machine | unchanged |
| `TubeSystem.run` | any run state | RUNNING |
| `TubeSystem.stop` | any run state | STOPPED |
| `TubeSystem.run_until_or_timeout` | any run state | STOPPED |
| `TubeSystem.run_for` | any run state | STOPPED |

### Coprocessor run state

A coprocessor -- `bbc.connect_coprocessor()`, or `TubeSystem.coprocessor` --
runs on the host's emulation thread and advances only while the host runs. Its
debugger is the same `Debugger`, and each method's docstring carries two more
lines, `Coprocessor requires: ...` and `Leaves the coprocessor: ...`, which this
table collects (a test keeps them in step). A coprocessor call never changes
whether the host runs. A coprocessor `run()` with the host stopped is refused:
the server says the host is stopped and names `TubeSystem.run()`, which runs
the host first and then the coprocessor. So `run_to`, `measure` and the other
run-to methods need the host running; stepping does not.

| Call | Requires | Leaves the coprocessor |
|---|---|---|
| `Debugger.run` | a stopped coprocessor and a running host | RUNNING |
| `Debugger.stop` | any run state | STOPPED |
| `Debugger.reset` | any run state | STOPPED |
| `Debugger.step` | a stopped coprocessor | STOPPED |
| `Debugger.step_cycles` | a stopped coprocessor | STOPPED |
| `Debugger.ensure_running` | a running host | RUNNING |
| `Debugger.ensure_stopped` | any run state | STOPPED |
| `Debugger.running` | a running host | as it was on entry |
| `Debugger.wait_for_stop` | a running host | STOPPED |
| `Debugger.run_and_wait_for_stop` | a running host | STOPPED |
| `Debugger.run_to_cycle` | a running host | STOPPED |
| `Debugger.run_to` | a running host | STOPPED |
| `Debugger.measure` | a running host | STOPPED |

`debugger.cycle_count` is a property on the host and the coprocessor alike,
counting each processor's own cycles.

### Testing with pytest

Installing `beebium` registers the pytest plugin, so the `bbc` fixture is
available with no extra configuration:

```python
# The beebium pytest plugin registers a `bbc` fixture: a fresh BBC Micro,
# launched and torn down per test. Install beebium and it is available with no
# conftest wiring.
def test_print(bbc):
    bbc.expect("BASIC")
    bbc.keyboard.type("PRINT 2+2")
    bbc.keyboard.press_return()
    assert bbc.expect("4") == "4"
```

`bbc` is a bare Model B. To configure it, override the `beebium_preset` fixture
(or `beebium_extra_args`, for extra server arguments) in a `conftest.py`:

```python
# In conftest.py: every `bbc` in scope boots this preset (an id from
# `list-presets`, or a preset file path) -- here a Model B with a disc system.
import pytest


@pytest.fixture(scope="session")
def beebium_preset():
    return "model-b-disc"


def test_has_a_disc_filing_system(bbc):
    bbc.expect("BASIC")
    bbc.keyboard.type("*HELP")
    bbc.keyboard.press_return()
    bbc.expect("DFS")
```

A project-wide default can also come from `--beebium-preset` or a
`beebium_preset` ini key; a fixture override takes precedence over both. When a
test needs more than one machine, or a machine configured on the spot, use the
`launch_bbc` factory the other fixtures are built on:

```python
# launch_bbc launches machines on demand -- any variant, any preset, as many
# as a test needs -- and tears every one of them down when the test ends.
def test_same_program_on_two_machines(launch_bbc):
    model_b = launch_bbc(preset="model-b-disc")
    b_plus = launch_bbc(variant="model-b-plus")
    for bbc in (model_b, b_plus):
        bbc.expect("BASIC")
        bbc.keyboard.type("PRINT 6*7")
        bbc.keyboard.press_return()
        assert bbc.expect("42") == "42"
```

Companion fixtures: `bbc_shared` (one machine per module, configured like
`bbc`), `stopped_bbc` (starts paused), `bbc_tube` (a 65C02 second processor),
and the session-scoped `mos_filepath` / `basic_filepath`. Point the plugin at a
server or ROMs with `--beebium-server` / `--beebium-rom-dir` (or the
`BEEBIUM_SERVER` / `BEEBIUM_ROM_DIR` environment variables).

### gRPC fork support

Importing `beebium.client` sets `GRPC_ENABLE_FORK_SUPPORT=false` unless it is
already set. The client forks only to start processes, and grpcio's fork
handlers, run in a child forked while a channel is busy, can kill that child
before it starts. That happens only where a child really is forked -- on macOS,
or on Linux with a `preexec_fn` or `os.fork()`, since Linux `subprocess`
otherwise uses `vfork()`, which runs no fork handlers -- but the setting is made
everywhere. The setting must be made before grpc is first imported, so a
program that imports grpc itself, or another gRPC-based library, before
`beebium.client` should set the variable in its environment.

### Further reading

Fuller documentation and runnable programs live in the repository: see
[`docs/`](https://github.com/rob-smallshire/beebium/tree/master/docs) for the
gRPC service reference and subsystem guides, and
[`examples/`](https://github.com/rob-smallshire/beebium/tree/master/clients/beebium-python-client/examples)
for end-to-end scripts. (Later: links to the published API reference.)

## Contributing

Development happens in the
[Beebium repository](https://github.com/rob-smallshire/beebium). This README is
**generated** -- do not edit `README.md` directly. See
[CONTRIBUTING.md](CONTRIBUTING.md) for the checkout, uv and wheel-test workflow
and for how to edit the README (its template and snippets).

## License

GPL-3.0-or-later. See [COPYING.txt](COPYING.txt).
