diff --git a/changelog/43.added.md b/changelog/43.added.md new file mode 100644 index 0000000..1aef7b1 --- /dev/null +++ b/changelog/43.added.md @@ -0,0 +1 @@ +A file written against an earlier format version now loads, in both text formats. A release that changes a syntax registers one migration under its own version with `qp.register_migration`, and reading applies every migration between the version in the file's header and the running one, oldest first, to the lines in memory — the file on disk is never rewritten, and writing it back out writes today's version. A release that breaks nothing registers nothing, so the chain carries an entry per breaking change rather than per release, and an older file with nothing behind it loads as it is. A migration rewrites lines one for one and may not add or drop any, which is what keeps a `ParseError`'s line number and every `source_map` entry naming a line of the file its author opened; one that breaks that count raises `ValueError` and names itself. `.qp` and `.wfl` share the version scale, since each header carries the library version cut to `major.minor`, but not their rewrites: `file_format="qp"` or `"wfl"` picks the table, and a change to vocabulary the two files share is one rewrite registered twice. `qp.serialization.known_migrations` lists what is registered for a format. A vendor extension gets the same mechanism for the wire form of its own operations through `qp.register_vendor_migration`, keyed to the extension's version: the `require ` line says which release wrote the body, and the extension's rewrites carry it up to the installed one, so renaming an operation no longer orphans the files a user already has. diff --git a/changelog/43.changed.md b/changelog/43.changed.md new file mode 100644 index 0000000..f026639 --- /dev/null +++ b/changelog/43.changed.md @@ -0,0 +1,3 @@ +Both readers now take a header version to be exactly `major.minor`, and refuse any version above the running one. A file from a later release used to load when its major matched, on the grounds that a minor only ever adds; it is refused now, because a reader has no way to know what a later release changed and no migration runs backwards. A header carrying a patch (`#!QProgram 0.2.3`) or a bare major (`#!QProgram 0`) is refused as well: a patch release changes code and never the format, so a file has no patch to declare. The `.qp` grammar's header terminal already said as much, and the hand-written parser now agrees with it. + +A `require ` line is read by that same rule against the extension it names, which changes two things and merges the two failures into one message. A line carrying a patch (`require myvendor 0.1.9`) is refused rather than rounded down, and a line naming an earlier major is migrated rather than refused, so `major versions must match` and `minor version too old` are replaced by `file requires myvendor 0.7, newer than the installed myvendor 0.1.0 — install myvendor 0.7 or newer`. What an extension registers with `register_vendor_version` is unaffected: that is a package version, and its patch component is still read and ignored. diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index 10c4f91..1969f28 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -26,7 +26,7 @@ qprogram/ │ ├── conftest.py # shared schema / program / waveform fixtures │ └── _dummy_vendor.py # a complete in-tree vendor extension, used as a fixture └── src/qprogram/ - ├── __init__.py # the public surface: 105 names, parser entry points lazy + ├── __init__.py # the public surface: 107 names, parser entry points lazy ├── py.typed # PEP 561 marker: the package ships its own annotations ├── qprogram.py # QProgram builder, control-flow contexts, vendor registry ├── buses.py # BusSchema, BusRef, BusNaming, typed presets @@ -61,6 +61,7 @@ qprogram/ ├── parser.py # loads / load ├── registry.py # registry-driven dispatch ├── _specs.py # per-op serialize/parse callbacks + ├── migrations.py # rewrites that load a file from an older version, core or vendor └── _format.py # the .qp format version constant ``` diff --git a/docs/developer/contributing.md b/docs/developer/contributing.md index e3e79ac..395176a 100644 --- a/docs/developer/contributing.md +++ b/docs/developer/contributing.md @@ -191,8 +191,8 @@ Use this when you are not sure which file to touch. | New sweep source | `src/qprogram/sweeps/builtin.py` or `combinators.py`, exported from `sweeps/__init__.py`, and added to the `register_sweep_source` loop in `src/qprogram/serialization/_specs.py`. That call also registers the class's `TOKEN` with the capability registry. | | Parser change | `src/qprogram/serialization/parser.py`. | | Writer change | `src/qprogram/serialization/writer.py`. | -| Grammar change | `src/qprogram/grammar/qp.lark`, kept in step with the parser by `tests/test_grammar.py`. | -| New vendor operation | The vendor's own package. See [Building a vendor extension](vendor-extensions.md). | +| Grammar change | `src/qprogram/grammar/qp.lark`, kept in step with the parser by `tests/test_grammar.py`. A change that stops an existing file from parsing also needs a [migration](serialization-internals.md#adding-a-migration) in `src/qprogram/serialization/migrations.py`, registered under the version that ships it, so files written before it keep loading. | +| New vendor operation | The vendor's own package. See [Building a vendor extension](vendor-extensions.md). A change to an existing operation's wire form also needs a [`register_vendor_migration`](vendor-extensions.md#keeping-older-files-loading) in that package, registered under the version that ships it. | | New vendor package | A separate package depending on `qprogram`. Same guide. | | Docs | `docs/`, with the nav in `zensical.toml`. | | Changelog entry | One fragment in `changelog/`. Never edit `CHANGELOG.md` by hand. | diff --git a/docs/developer/serialization-internals.md b/docs/developer/serialization-internals.md index 5109525..9e627b3 100644 --- a/docs/developer/serialization-internals.md +++ b/docs/developer/serialization-internals.md @@ -29,10 +29,159 @@ metadata falls back to `"0.0"`. The `.wfl` format's `WAVEFORM_LIBRARY_FORMAT_VERSION` is derived the same way, which is why the two headers carry the same number. -The version is emitted in the `#!QProgram` header and checked on load. Only the -major component is binding: a file whose major differs is rejected with -`Unsupported format version`, and any minor within the same major loads, so a -`0.4` file opens under a `0.2` runtime. +The version is emitted in the `#!QProgram` header and checked on load. A file +from a later release is rejected with `Unsupported format version`, whichever +component moved; an older file is migrated up to this version. The header +carries `major.minor` exactly, since a patch release cannot change the format, +and a `require ` line is read the same way against its extension's +version. + +## Migrations + +`src/qprogram/serialization/migrations.py` holds the rewrites that let today's +parser read yesterday's syntax. Each one is registered under the version that +broke something: + +```python +_SWEEP_KEYWORD = re.compile(r"(?<=^ )sweep\b") + + +@register_migration("0.3") +def _sweep_became_for(lines: list[str]) -> list[str]: + return [_SWEEP_KEYWORD.sub("for", line) for line in lines] +``` + +`_parse_header` reads the version off the header and, when the file is older +than the running one, replaces the parser's lines with the result of running +every migration in `(file version, running version]`, oldest first. Nothing +else in the parser knows a migration happened, and the file on disk is never +touched: the rewrite lives as long as the parse. `WaveformLibrary.loads` does +the same through `_migrated` in `src/qprogram/waveform_library.py`, against the +`"wfl"` table. + +Three rules make that safe to rely on: + +- **One migration per breaking change, not per release.** A release that leaves + the syntax alone registers nothing, and a file two releases behind collects + every step in between. This is why the registry is a sorted list of steps + rather than a chain of parent revisions: there is no node to write for a + quiet release. +- **Lines in, as many lines out.** A migration may rewrite a line, and may look + at its neighbours, but may not add or drop one. That is what keeps a + `ParseError`'s line number and every `source_map` entry naming a line of the + file its author opened. `migrate_lines` checks the count and raises + `ValueError` naming the migration that broke it. A change that genuinely + needs to restructure lines is the point at which this mechanism grows a line + map; until then the invariant is worth more than the flexibility. +- **The header is not a migration's business.** The rewrite is handed every line + including the header, so the indices line up with the file, but the reader has + already read the version off it and moves past it. +- **One table per format, one version scale for both.** `FORMAT_VERSION` and + `WAVEFORM_LIBRARY_FORMAT_VERSION` are the same library version cut the same + way, so `_RUNNING_VERSION` bounds both chains and a release's breaking change + carries the same number in either file. The rewrites stay apart, since + `"pi" = Square(...)` in a library and `play "drive" Square(...)` in a program + are not the same text; a change to the vocabulary they do share is one + function registered under both formats, which is what the stacked decorator + in `register_migration`'s docstring shows. + +A migration registered under a version that has not shipped yet is skipped, +since the runner only applies steps up to the running version. That makes it +safe to write the migration in the same commit as the change that needs it, +before the release is cut. + +### Adding a migration + +Say 0.3 renames the `Rectangular` waveform to `Square`. Waveform constructors +are keyed by class name in the registry, so one rename changes how a pulse is +spelled in a program body and in a library entry at once, and both of these are +files somebody already has on disk: + + +``` +#!QProgram 0.2 + +body: + play "drive_q0" Rectangular(amplitude=0.5, duration=200) +``` + +``` +#!WaveformLibrary 0.2 +"pi_pulse" q[0].drive = Rectangular(amplitude=0.5, duration=200) +``` + +The rewrite goes at the bottom of +`src/qprogram/serialization/migrations.py`, below the runners, so that +registration happens on the import both readers already perform. The module +imports no `re` today, so the first migration brings it: + +```python +# src/qprogram/serialization/migrations.py +_RECTANGULAR = re.compile(r"\bRectangular\(") + + +@register_migration("0.3") +@register_migration("0.3", file_format="wfl") +def _rectangular_became_square(lines: list[str]) -> list[str]: + """Rewrite the constructor 0.3 renamed, in a program body and in a library entry alike.""" + return [_RECTANGULAR.sub("Square(", line) for line in lines] +``` + +Two decisions are worth spelling out. The decorator is stacked because a +waveform constructor is vocabulary the two formats share; a change to the body +grammar, a block header or an operation keyword, is `"qp"` alone, and the +`"alias" coord = waveform` entry line is `"wfl"` alone. The pattern is anchored +because a migration is text in and text out with no parse in between: +`\bRectangular\(` matches the constructor call and nothing else on the line, +leaving a waveform aliased `"Rectangular"` and a program label reading +`Rectangular pulse calibration` alone, where a pattern matching the bare word +would rewrite both. + +The rewrite earns two tests in two places, because `tests/test_migrations.py` +empties both tables around every test so that its fixtures can register +throwaway rewrites without the shipped chain running underneath them. That puts +the registered chain out of reach there, so what that file tests is the function +itself, called on the lines it would be handed: + +```python +# tests/test_migrations.py +def test_rectangular_became_square_leaves_an_alias_of_the_same_name_alone(): + entry = '"Rectangular" q[0].drive = Rectangular(amplitude=0.5, duration=200)' + assert _rectangular_became_square([entry]) == ['"Rectangular" q[0].drive = Square(amplitude=0.5, duration=200)'] +``` + +Whether it runs on the file that needs it is the other half, and belongs +wherever the registered chain is left in place, such as +`tests/test_serialization.py`: + +```python +# tests/test_serialization.py +def test_a_file_written_before_the_waveform_rename_still_loads(): + text = '#!QProgram 0.2\n\nbody:\n play "drive_q0" Rectangular(amplitude=0.5, duration=200)\n' + assert isinstance(qp.loads(text).body.elements[0].waveform, qp.waveforms.Square) +``` + +The version there is written out rather than taken from `tests/_header.py`, +since the point of the test is that one particular older version still loads. It +starts passing when 0.3 is cut: until then the file it writes carries the +running version rather than an earlier one, so nothing runs and the assertion +fails. That is the one claim about a migration its own release has to make true, +which is why the unit test above it is what guards the rewrite in review. + +### Vendor migrations + +One level down, a `require ` line has the same problem: an extension +that renames an operation orphans the files its users already have. +`register_vendor_migration(vendor, version)` is the same mechanism against that +extension's version. `_check_vendor_compat` runs the vendor's chain over the +lines when the line asks for an earlier release than the one installed, which is +why an earlier vendor major is no longer refused. The ceiling there is the +installed extension, not the library, so `migrate_vendor_lines` takes it as an +argument rather than reading `_RUNNING_VERSION`. `.wfl` files declare no vendor, +having no `require` line, so vendor tables are consulted for `.qp` only. The +same worked example from inside an extension package, registration site and test +included, is under [keeping older files +loading](vendor-extensions.md#keeping-older-files-loading). ## The registries diff --git a/docs/developer/vendor-extensions.md b/docs/developer/vendor-extensions.md index e2865df..1181660 100644 --- a/docs/developer/vendor-extensions.md +++ b/docs/developer/vendor-extensions.md @@ -435,16 +435,16 @@ class under the same name is a no-op. `register_vendor_version(vendor, version)` takes a semver string with at least integer `major.minor`; `"0.1"` and `"0.1.0"` are both accepted and the patch -component is informational. A version with fewer components raises `vendor -version '1' must have at least major.minor components`, and a non-integer -component raises `vendor version '0.x' has non-integer major/minor -components`. Reading the value from `importlib.metadata` keeps a single source -of truth in `pyproject.toml`, but note what the fallback does: when the package -is not installed as a distribution, `__version__` becomes `"0.0.0"` and the -extension advertises major 0, minor 0, so any file written as -`require fake_inst 0.1` is rejected as "minor version too old". Registering the -version is also what marks the vendor as active, which is the check -`try_activate_vendor` makes. +component is informational, since what an extension registers is a package +version. A version with fewer components raises `vendor version '1' must have at +least major.minor components`, and a non-integer component raises `vendor +version '0.x' has non-integer major/minor components`. Reading the value from +`importlib.metadata` keeps a single source of truth in `pyproject.toml`, but note +what the fallback does: when the package is not installed as a distribution, +`__version__` becomes `"0.0.0"` and the extension advertises major 0, minor 0, so +a file written as `require fake_inst 0.1` asks for more than it provides and is +refused. Registering the version is also what marks the vendor as active, which +is the check `try_activate_vendor` makes. `register_vendor_operation(vendor, name, cls, *, serialize=None, parse=None)` keys on `(vendor, name)`. Re-registering the same class refreshes its @@ -678,18 +678,75 @@ backwards-compatible keyword arguments. Bump the major when you remove or rename an operation, rename or reorder a constructor parameter, or change semantics in a way that would break older files. -The parser enforces exactly two conditions, on major.minor with the patch -component ignored: the majors must match, and the installed minor must be at -least the file's. The two failures read: +The parser enforces one condition, on the `major.minor` the line spells out: +the installed extension must be able to provide what the file asks for. ``` -Line 3: file requires fake_inst 1.0 (major 1); installed fake_inst is 0.1.0 (major 0) — major versions must match -Line 3: file requires fake_inst 0.9 or compatible; installed fake_inst is 0.1.0 — minor version too old +Line 3: file requires fake_inst 1.0, newer than the installed fake_inst 0.1.0 — install fake_inst 1.0 or newer +Line 3: file version '0.9.1' must be exactly major.minor ``` -An existing file therefore keeps parsing as long as you only add to the -operation set on the same major, and a newer extension on that major always -reads older files. +### Keeping older files loading + +An older line always loads. When the release that broke the wire form also +registers a migration for it, the body is rewritten on the way in and the older +file parses as if it had been written today. + +Say 1.0 gives `beep` a second required argument, `volume`, which by the rule +above is a major bump. Every file written against 0.3 or earlier is one token +short of what the constructor now takes: + +``` +#!QProgram 0.2 + +require fake_inst 0.3 + +body: + fake_inst.beep "drive_q0" 100 +``` + +The rewrite goes in `__init__.py` beside the `register_vendor_operation` calls, +so that the import a `require` line triggers is what registers it: + +```python +# qprogram-fakeinst/src/qprogram_fakeinst/__init__.py +_BEEP = re.compile(r"^\s*fake_inst\.beep\b.*$") + + +@qp.register_vendor_migration("fake_inst", "1.0") +def _beep_took_a_volume(lines: list[str]) -> list[str]: + """Give a pre-1.0 beep line the volume that release made required.""" + return [_BEEP.sub(r"\g<0> volume=0.5", line) for line in lines] +``` + +The parser binds a keyword token by name, so the rewritten line arrives at +`Beep.__init__` carrying a value the file never held. Match the operation +keyword and append, rather than matching the argument list: a hand-written line +whose duration is an expression, `fake_inst.beep "drive_q0" (100 - t)`, has +spaces where the writer's own output has none. + +Key it to the version that shipped the change, one per breaking change; a +release that only adds operations needs none, since an older file never mentions +what it does not have. The version is your extension's rather than the format's: +the chain is bounded by the installed extension, so what runs is decided by the +`require fake_inst ` line against the version your package +registered. The rewrite is handed every line of the file, header and `require` +lines included, and has to hand back as many as it received, which is what keeps +a diagnostic's line number pointing at the file the user wrote. A `.wfl` +library declares no vendor, so it never sees a vendor rewrite at all. + +What proves it is a load rather than a direct call to the function, since the +registration and the version check are half of what is being tested: + +```python +def test_a_0_3_file_gets_the_volume_1_0_made_required(): + text = '#!QProgram 0.2\n\nrequire fake_inst 0.3\n\nbody:\n fake_inst.beep "drive_q0" 100\n' + assert qp.loads(text).body.elements[0].volume == 0.5 +``` + +Without a migration nothing is lost either: an older file still loads, on the +assumption that nothing between the two versions broke. The migration is what +turns that assumption into a guarantee. When the vendor is not registered at all, the message depends on whether auto-activation is on. The default suggests installing the package that @@ -726,7 +783,7 @@ mistakes in a vendor package on the path between installed and usable. | No `qprogram.vendors` entry point | A `.qp` file using `fake_inst.*` loads only in a process that already imported the package. Elsewhere: `no matching extension is registered in this environment` | | Entry point present, no `register_vendor_version` call | `VendorActivationError: ... imported from entry point 'qprogram_fakeinst' but did not register a protocol version` | | `register_vendor` called on the pre-combined class | `ValueError: vendor name 'fake_inst' collides with a QProgram attribute` | -| Package not installed as a distribution | `__version__` falls back to `"0.0.0"`, so every `require fake_inst 0.1` fails as "minor version too old" | +| Package not installed as a distribution | `__version__` falls back to `"0.0.0"`, so every `require fake_inst 0.1` asks for more than is installed and is refused | | Operation class not registered | `SerializationError: Cannot serialize operation class 'Beep': it is not registered with the .qp serializer.` | | Constructor parameter renamed without a major bump | Older files fail to parse, or bind the value to the wrong parameter | | Constructor parameter name differs from the attribute | The value is silently omitted from the written file | diff --git a/docs/guide/index.md b/docs/guide/index.md index d29e0d5..474c01f 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -19,7 +19,7 @@ where they are used. | [Capabilities, diagnostics, and profiles](capabilities.md) | `PlatformCapabilities`, the routing that decides which slot checks a node, the ten diagnostic codes and what produces each, the `ExecutionPlan` and `explain()`, numeric limits, predicates, and `Profile` bundles. | | [Running programs](execution.md) | `qp.simulate` and `ReferencePlatform`: the result shapes a run produces, measurement models and the mock default, what the reference executor does not model, and what implementing `PlatformProtocol` involves. | | [Plotting results](plotting.md) | `QProgramResult.plot`: the figure a result's shape asks for, the `channels` argument that decides what becomes of the `IQ` dimension, where an axis label comes from, the `Quantity` that restates a coordinate in the units you want to read it in, the `Style` and `Theme` dataclasses, and registering a renderer of your own. | -| [Saving and loading](serialization.md) | `dumps`, `loads`, `save`, and `load`: what the round trip preserves and what it drops, the format version and `require` lines, vendor activation at parse time, the normalizations the writer applies, and the `WaveformLibrary` that quoted aliases resolve through, with its own `.wfl` file. | +| [Saving and loading](serialization.md) | `dumps`, `loads`, `save`, and `load`: what the round trip preserves and what it drops, the format version and `require` lines, how a file from an earlier version migrates on the way in, vendor activation at parse time, the normalizations the writer applies, and the `WaveformLibrary` that quoted aliases resolve through, with its own `.wfl` file. | Two worked programs, each given in full from the builder calls to the result array, are in [Examples](../examples/index.md). The grammar behind the wire diff --git a/docs/guide/serialization.md b/docs/guide/serialization.md index ce4179b..06818a6 100644 --- a/docs/guide/serialization.md +++ b/docs/guide/serialization.md @@ -174,15 +174,29 @@ both sides read. It is the installed library version truncated to #!QProgram 0.2 ``` -Only the major component is binding. The parser checks the header before -anything else and rejects a different major, so `#!QProgram 1.0` fails with -`Line 1: Unsupported format version 1.0` while `#!QProgram 0.7` loads on -today's parser, which reads it with the features it knows. That is the -compatibility contract: minor versions add sections, operations, and -constructs without breaking older readers, and a major bump is reserved for a -change that does. Since the version comes from the library, a release that -leaves the format alone still moves the minor, and the library's own 1.0 is -where files written by an 0.x release stop loading. +The parser checks the header before anything else, and what it does next +depends on which side of the running version the file is on. + +An older file is migrated. A release that changes the syntax registers one +migration under its own version, and loading applies every migration newer than +the file's version, oldest first, to the lines in memory — the file on disk is +never rewritten. So a program saved by any earlier release parses against +today's grammar, and a release that broke nothing registers nothing. The +rewrites work line for line, which is what keeps a `ParseError`'s line number +and `source_map` pointing at lines of the file you opened; +[Migrations](../developer/serialization-internals.md#migrations) is how one is +written. + +A newer file is refused: `#!QProgram 0.9` fails with `Line 1: Unsupported +format version 0.9`, because a release cannot know what a later one changed, and +there is no migration that runs backwards. The version is `major.minor` exactly +— a patch release changes code, never the format, so a file has no patch to +declare, and `#!QProgram 0.2.3` is refused with the same message. + +That is the compatibility contract: a minor adds sections, operations, and +constructs, a major is reserved for a change that breaks the older spelling +outright, and either way the file that a release wrote goes on loading under +every release after it. A program that uses vendor operations or vendor blocks carries one `require` line per vendor, directly after the header: @@ -204,16 +218,20 @@ installed extension registered, truncated to `major.minor`, because that is the granularity the compatibility check works at. The parser resolves each line against the extension registered in this -environment, and does it before reading the body. The majors must be equal and -the installed minor must be at least the file's, which gives two failures with -distinct messages: +environment, and does it before reading the body. The rule is the header's, one +level down: the line asks for a `major.minor`, anything the installed extension +cannot provide is refused, and anything older loads with the extension's own +migrations applied to the body first. ``` -Line 3: file requires myvendor 2.0 (major 2); installed myvendor is 0.1.0 (major 0) — major versions must match -Line 3: file requires myvendor 0.7 or compatible; installed myvendor is 0.1.0 — minor version too old +Line 3: file requires myvendor 2.0, newer than the installed myvendor 0.1.0 — install myvendor 2.0 or newer +Line 3: file version '0.7.1' must be exactly major.minor ``` -A patch component is informational and is ignored by the comparison. A +So an extension that renames an operation registers a rewrite for it — +`qp.register_vendor_migration("myvendor", "0.4")` — and the files its users +already have keep loading. A patch component in the line is refused rather than +ignored, since a patch release of an extension has no wire form of its own. A `require` line that appears after the first section is rejected outright, so the dependency list is always readable off the top of the file: @@ -483,6 +501,14 @@ looked up in the same registry, so a class registered with vendor waveform needs its package imported before the file loads. An empty library writes as the header alone and loads back empty. +The header carries a version, and it is the same number a `.qp` file written by +the same release carries. It is read the same way too: `major.minor` exactly, a +later version refused, and an earlier one migrated by the rewrites registered +for the `"wfl"` format. The two formats keep separate tables, since +`"pi" = Square(...)` in a library and `play "drive" Square(...)` in a program +are not the same line, so a release that changes both registers its rewrite +under both. + What `dumps` refuses to write is a waveform that is not concrete, since a calibration set that carries a `Variable` is not something an instrument can be handed: diff --git a/docs/index.md b/docs/index.md index 904dd5c..4fa502b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -168,18 +168,22 @@ pattern. The package is pre-1.0, so the Python API can change between releases without a deprecation cycle. The `.qp` format version follows the library version -truncated to `major.minor`, so this release writes `#!QProgram 0.2`. Only the -major component is binding: a `0.7` file still loads on this parser, and a `1.0` -file raises `ParseError` with `Unsupported format version 1.0`. Accepting a -newer minor is deliberate, and the cost is that a file using grammar this parser -does not know fails somewhere in its body instead of at the header. A file with -no header at all fails immediately with `Missing #!QProgram header`. +truncated to `major.minor`, so this release writes `#!QProgram 0.2`. A file from +an *earlier* version always loads: a release that changes the syntax registers a +migration under its own version, and loading applies every migration between the +file's version and this one to the lines in memory, leaving the file on disk as +it is. A file from a *later* version is refused with `ParseError` and +`Unsupported format version 0.9`, since this release has no way to know what a +later one changed. The header carries `major.minor` and nothing else — a patch +release cannot change the format, so a file has no patch to declare — and a file +with no header at all fails immediately with `Missing #!QProgram header`. Vendor compatibility is checked one `require` line at a time, before any of the -body is built, so a rejected file leaves no partially loaded program: the majors -must match, the installed minor must be at least the one the file asks for, a -patch component is accepted and ignored, and a vendor that is installed but not -yet imported is activated through its `qprogram.vendors` entry point. +body is built, so a rejected file leaves no partially loaded program: the line +asks for a `major.minor`, anything the installed extension cannot provide is +refused, anything older loads with that extension's own migrations applied to +the body first, and a vendor that is installed but not yet imported is activated +through its `qprogram.vendors` entry point. [Format version and the require line](guide/serialization.md#format-version-and-the-require-line) has the message each failure produces and the argument that turns activation off. diff --git a/docs/reference/api-qprogram.md b/docs/reference/api-qprogram.md index 8582a78..428c389 100644 --- a/docs/reference/api-qprogram.md +++ b/docs/reference/api-qprogram.md @@ -571,6 +571,41 @@ call site: `qp.loads` is read off the package like any other attribute. ::: qprogram.serialization.parser.loads ::: qprogram.serialization.parser.load +### Migrations + +A file whose header declares an earlier format version is rewritten in memory +on the way in, by the migrations registered for the versions in between. One +migration covers one breaking change to the syntax, so a release that breaks +nothing registers none, and a file two releases behind collects both steps. The +rewrite works on lines and must return as many as it was given, which is what +keeps a `ParseError`'s line number and the source map pointing at lines of the +file on disk. + +Both text formats read this way. `.qp` and `.wfl` carry the same version, since +each is the library version cut to `major.minor`, so one running version bounds +both chains; the tables are separate, because the same line of text means one +thing in a program body and another in a library entry. `file_format` picks the +table, and a rewrite that both formats need is registered twice. See +[Migrations](../developer/serialization-internals.md#migrations) for how to +write one. + +A vendor extension has the same problem for the wire form of its own +operations, and `register_vendor_migration` is the same mechanism against the +version in the file's `require` line: an older line loads, with that extension's +rewrites applied to the body first. Those chains are per vendor and bounded by +the installed extension rather than by the library. + +::: qprogram.serialization.migrations.register_migration +::: qprogram.serialization.migrations.register_vendor_migration +::: qprogram.serialization.migrations.known_migrations +::: qprogram.serialization.migrations.known_vendor_migrations +::: qprogram.serialization.migrations.migrate_lines +::: qprogram.serialization.migrations.migrate_vendor_lines + +::: qprogram.serialization.migrations.Migration + options: + show_root_full_path: false + ## Platform protocol ::: qprogram.PlatformProtocol diff --git a/docs/reference/errors.md b/docs/reference/errors.md index dcd32f7..f08b561 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -251,8 +251,8 @@ program. Nothing in the writer checks this for you. does not follow the grammar or fails a compatibility check, and what `WaveformLibrary.load` and `WaveformLibrary.loads` raise on a `.wfl` document. Compatibility accounts for the first group of `.qp` cases: a -missing `#!QProgram` header, a header whose major version differs from the -parser's, a `require` declaration that cannot be satisfied, and `require` +missing `#!QProgram` header, a header whose version is newer than the parser's +or is not `major.minor`, a `require` declaration that cannot be satisfied, and `require` lines that do not sit directly after the header. The rest are grammar: a second `schema:` declaration, a schema with no elements or a malformed `info=` value, a bus path that does not resolve against the schema, a @@ -269,14 +269,14 @@ ParseError: Line 2: file requires vendor 'nosuchvendor' 1.0 but no matching extension is registered in this environment — install the package that declares the 'qprogram.vendors' entry point for 'nosuchvendor', or import the extension before loading -ParseError: Line 2: file requires myvendor 99.0 (major 99); installed -myvendor is 1.2.0 (major 1) — major versions must match -ParseError: Line 2: file requires myvendor 1.9 or compatible; installed -myvendor is 1.2.0 — minor version too old +ParseError: Line 2: file requires myvendor 99.0, newer than the installed +myvendor 1.2.0 — install myvendor 99.0 or newer +ParseError: Line 2: file version '1.9.1' must be exactly major.minor ``` -Majors must match exactly, the file's minor must be no newer than the -installed extension's, and a patch component is read but ignored. +A `require` line asks for a `major.minor`, and anything the installed extension +cannot provide is refused. An older version loads, with that extension's +migrations applied to the body first. An id declared in a `.qp` file is checked twice, and the two failures come back differently. A malformed id is rejected by the parser's own pattern diff --git a/docs/reference/qp-format.md b/docs/reference/qp-format.md index 00b9835..46334ff 100644 --- a/docs/reference/qp-format.md +++ b/docs/reference/qp-format.md @@ -63,14 +63,15 @@ unexpected top-level line 'bodyy:'; expected `metadata:`, `schema:`, The header is exactly `#!QProgram .`, matched by the terminal `/#!QProgram[ \t]+[0-9]+\.[0-9]+/`. Blank lines before it are skipped. -Only the major component is binding. The running format version is -`qprogram.serialization._format.FORMAT_VERSION`, currently `"0.2"`, and a file -loads when its major matches, whatever its minor: `#!QProgram 0.7` parses under -this release. A different major, or a header with no version at all, stops the -parse on line 1: +The running format version is `qprogram.serialization._format.FORMAT_VERSION`, +currently `"0.2"`. A file at that version parses directly, and an older one is +migrated on the way in (see [Versioning](#versioning)). A newer version, or a +header whose version is not exactly two integer components — a bare major, a +patch, anything else — stops the parse on line 1: ``` -Line 1: Unsupported format version 1.0 +Line 1: Unsupported format version 0.9 +Line 1: Unsupported format version 0.2.3 Line 1: Unsupported format version unknown ``` @@ -95,24 +96,27 @@ already imported. Write the line anyway; it is what makes the file self-contained. What the parser does with the lines it finds is resolve each one against the -installed extension. The major version must match exactly, the installed minor -must be greater than or equal to the file's, and a patch component is accepted -and ignored, since compatibility is decided at major.minor. The writer -truncates the version it emits to `major.minor` for the same reason. A vendor -that is not imported yet is activated on the spot through its -`qprogram.vendors` entry point. That discovery is the reason to write the line: -a file missing it never triggers the import, and the first dotted operation -then fails as an unknown vendor operation instead. +installed extension, by the rule the header follows one level up. The version in +the line is exactly `major.minor`: a patch release of an extension changes code, +not the wire form, so `require myvendor 0.1.9` is refused rather than rounded +down. A line asking for more than the installed extension provides is refused. An +older line loads, and the migrations that extension registered between the two +versions rewrite the body first, so a program saved against any earlier release +of the extension goes on loading — an earlier major included. The writer emits +`major.minor` for the same reason the check reads it. A vendor that is not +imported yet is activated on the spot through its `qprogram.vendors` entry point. +That discovery is the reason to write the line: a file missing it never triggers +the import, and the first dotted operation then fails as an unknown vendor +operation instead. Each failure stops the parse before the body is read, and names both versions. Against an installed `myvendor 0.1.3`, the three shapes are: ``` -Line 3: file requires myvendor 1.0 (major 1); installed myvendor is 0.1.3 -(major 0) — major versions must match +Line 3: file requires myvendor 1.0, newer than the installed myvendor 0.1.3 +— install myvendor 1.0 or newer -Line 3: file requires myvendor 0.2 or compatible; installed myvendor is 0.1.3 -— minor version too old +Line 3: file version '0.1.9' must be exactly major.minor Line 3: file requires vendor 'othervendor' 0.1 but no matching extension is registered in this environment — install the package that declares the @@ -120,9 +124,6 @@ registered in this environment — install the package that declares the before loading ``` -`require myvendor 0.1.9` against that same installation loads, since the -file's patch component is ignored. - `qp.loads(text, auto_activate=False)` turns entry-point discovery off, and the third message then ends differently: @@ -1096,11 +1097,30 @@ The header version (`#!QProgram 0.2`) is the format version, and it is the library version truncated to `major.minor`: `FORMAT_VERSION` reads the installed distribution's version, so `qprogram` 0.2.1 writes `0.2`. New minor versions add operations, waveforms, control-flow constructs, or sections in -backward-compatible ways, and a parser accepts any minor within its own major. -Major version bumps are reserved for breaking changes, and an older parser -refuses to read a higher major version. Tying the two together means a release -that does not touch the format still moves the minor, which costs nothing under -the contract, and that the library's own major bump is the format's. +backward-compatible ways, and major version bumps are reserved for changes that +break the older spelling outright. A reader refuses any version above its own, +whichever component moved, since it cannot know what a later release did. Tying +the format version to the library's means a release that does not touch the +format still moves the minor, which costs nothing under the contract, and that +the library's own major bump is the format's. A patch release moves neither: a +file's version is `major.minor` and carries no patch component, because a patch +cannot have changed the format. + +A file older than the running version is not refused, whatever its major. +Loading migrates it: a release that changes the syntax registers one migration +under its own version with +[`register_migration`][qprogram.serialization.migrations.register_migration], +and the reader applies every registered migration newer than the file's version, +oldest first, to the lines it is about to read. Both formats work this way, each +with its own table — `file_format="qp"` for a program, `"wfl"` for a waveform +library — bounded by the one version they share. The file on disk is untouched, +and writing the program back out writes today's version. A migration rewrites +lines one for one — it may not add or drop any — so a diagnostic's line number +and every `source_map` entry still name a line of the original file; a migration +that breaks that count raises `ValueError` and names itself. Two migrations may +share a version, and run in registration order. Since the chain has an entry per +breaking change rather than per release, a version with no migration behind it +needs none, and a file from a release that changed nothing loads as it is. Vendor protocol versions (`require myvendor 0.1`) are independent: they describe the vendor's operation set, not the file format. The vendor extension @@ -1193,13 +1213,22 @@ The header version comes from `WAVEFORM_LIBRARY_FORMAT_VERSION` in `library_major_minor` in `qprogram/_version.py`, so the two headers carry the same number on any given release. The formats are still checked separately: a `.wfl` file is read by the waveform library's own reader, and its version says -nothing about the `.qp` grammar. Only the major component is compared, so -`#!WaveformLibrary 0`, `0.2.3`, and `0.7` all load on today's reader while a -different major is refused outright, and the compatibility contract is the same -as `.qp`'s: a minor version may add entry forms and waveform vocabulary, a -major bump is reserved for a change an older reader cannot handle. The version -token is read as the last whitespace-separated token on the header line, so a -header with anything after the version reports that trailing token as an -unsupported version. The writer always emits the current version, which means -rewriting a `0.7` file on a `0.2` reader writes `0.2` and drops the claim to -have come from a newer minor. +nothing about the `.qp` grammar. + +The rules that reader applies are `.qp`'s. A later version is refused, whichever +component moved, and an earlier one is migrated rather than refused, by the +migrations registered for the `"wfl"` format (see [Versioning](#versioning)). The two formats share the version scale but not +their rewrites, since the same line of text means one thing in a program body +and another in a library entry: a migration names the format it reads, and +vocabulary the two files do share — a renamed waveform constructor — is one +rewrite registered twice. The compatibility contract is the same as `.qp`'s +too: a minor version may add entry forms and waveform vocabulary, and a major +bump is reserved for a change an older reader cannot handle. + +The version is `major.minor` in both formats, so `#!WaveformLibrary 0.2.3` and a +bare `#!WaveformLibrary 0` are refused for the same reasons `#!QProgram 0.2.3` +and `#!QProgram 0` are. The token itself is the last whitespace-separated one on +the header line, so a header with anything after the version reports that +trailing token as an unsupported version. The writer always emits the current +version, which means an older file that is read and written back out comes back +carrying today's number. diff --git a/src/qprogram/__init__.py b/src/qprogram/__init__.py index 0445659..0cf0748 100644 --- a/src/qprogram/__init__.py +++ b/src/qprogram/__init__.py @@ -79,8 +79,10 @@ from qprogram.result import MeasurementHandle, MeasurementResult, QProgramResult from qprogram.serialization import ( dumps, + register_migration, register_sweep_source, register_vendor_block, + register_vendor_migration, register_vendor_operation, register_vendor_version, register_waveform, @@ -224,9 +226,11 @@ "or_", "reference_capabilities", "register_capability_tokens", + "register_migration", "register_profile", "register_sweep_source", "register_vendor_block", + "register_vendor_migration", "register_vendor_operation", "register_vendor_version", "register_waveform", diff --git a/src/qprogram/_version.py b/src/qprogram/_version.py index 44b8f6f..64fdbe5 100644 --- a/src/qprogram/_version.py +++ b/src/qprogram/_version.py @@ -11,12 +11,13 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""The library version, truncated for the headers the file formats carry. +"""The library version, truncated for the headers the file formats carry, and how to read one. Both text formats the package writes — ``.qp`` and ``.wfl`` — stamp their header with the -library version cut to ``major.minor``, so the one derivation lives here. It reads the installed -distribution metadata rather than ``qprogram.__version__``, and imports nothing from the package, -so a module may take the version without pulling the package's import graph in with it. +library version cut to ``major.minor``, so the one derivation lives here, next to the parsing +every version comparison goes through. It reads the installed distribution metadata rather than +``qprogram.__version__``, and imports nothing from the package, so a module may take the version +without pulling the package's import graph in with it. """ from __future__ import annotations @@ -38,3 +39,58 @@ def library_major_minor() -> str: major, _, rest = release.partition(".") minor = rest.partition(".")[0] return f"{major}.{minor or '0'}" + + +def parse_major_minor(version: str) -> tuple[int, int]: + """Split a version string into its major and minor components. + + A patch component is accepted and ignored: the format version and vendor compatibility are + both decided at major.minor. + + Args: + version (str): Version text from a ``#!QProgram`` header, a ``require`` line, or a + registered vendor. + + Returns: + The ``(major, minor)`` pair. + + Raises: + ValueError: If the string has no minor component, or either component is not an integer. + """ + parts = version.split(".") + if len(parts) < 2: + msg = f"version {version!r} must have at least major.minor" + raise ValueError(msg) + try: + return int(parts[0]), int(parts[1]) + except ValueError as e: + msg = f"version {version!r} has non-integer major/minor components" + raise ValueError(msg) from e + + +def parse_file_version(version: str) -> tuple[int, int]: + """Read the version a file's header declares, which is exactly ``major.minor``. + + Stricter than `parse_major_minor`, which takes a version from a package and tolerates the + patch component such a version carries. A file has no patch: the format changes at + ``major.minor``, and a release that only moves the patch cannot have changed it. + + Args: + version (str): The version token from a ``#!QProgram`` or ``#!WaveformLibrary`` header. + + Returns: + The ``(major, minor)`` pair. + + Raises: + ValueError: If the string is not exactly two integer components. + """ + parts = version.split(".") + expected_parts = 2 + if len(parts) != expected_parts: + msg = f"file version {version!r} must be exactly major.minor" + raise ValueError(msg) + try: + return int(parts[0]), int(parts[1]) + except ValueError as e: + msg = f"file version {version!r} has non-integer major/minor components" + raise ValueError(msg) from e diff --git a/src/qprogram/serialization/__init__.py b/src/qprogram/serialization/__init__.py index dbce3b6..be88516 100644 --- a/src/qprogram/serialization/__init__.py +++ b/src/qprogram/serialization/__init__.py @@ -21,6 +21,13 @@ """ from qprogram.serialization import _specs as _core_specs +from qprogram.serialization.migrations import ( + Migration, + known_migrations, + known_vendor_migrations, + register_migration, + register_vendor_migration, +) from qprogram.serialization.registry import ( BlockSpec, OperationSpec, @@ -41,16 +48,21 @@ __all__ = [ "BlockSpec", + "Migration", "OperationSpec", "ParseError", "dumps", + "known_migrations", "known_sweep_sources", + "known_vendor_migrations", "load", "loads", "register_block", + "register_migration", "register_operation", "register_sweep_source", "register_vendor_block", + "register_vendor_migration", "register_vendor_operation", "register_vendor_version", "register_waveform", diff --git a/src/qprogram/serialization/_format.py b/src/qprogram/serialization/_format.py index 0c19e51..8914bd4 100644 --- a/src/qprogram/serialization/_format.py +++ b/src/qprogram/serialization/_format.py @@ -28,6 +28,7 @@ """``major.minor`` version emitted in the ``#!QProgram`` header and accepted by the parser. The format version follows the library version truncated to ``major.minor``, so ``qprogram`` -0.2.1 writes ``#!QProgram 0.2``. Compatibility contract: the parser rejects files whose -*major* version differs from this one; minor differences within the same major are accepted. +0.2.1 writes ``#!QProgram 0.2``. Compatibility contract: a file at an earlier version is migrated +up to this one on load, and a file at a later version is refused, since a release cannot know what +a later one changed. A patch never appears in a file, having no way to change the format. """ diff --git a/src/qprogram/serialization/migrations.py b/src/qprogram/serialization/migrations.py new file mode 100644 index 0000000..d5a7d3a --- /dev/null +++ b/src/qprogram/serialization/migrations.py @@ -0,0 +1,294 @@ +# Copyright 2026 Qilimanjaro Quantum Tech +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Rewrites that carry an older ``.qp`` or ``.wfl`` file up to the running format version. + +A breaking change to either syntax gets one migration, registered under the version that +introduced it. Reading a file whose header declares an earlier version applies every migration +newer than that version, oldest first, to the lines in memory; the file on disk is never touched. +A release that breaks nothing registers nothing, so the chain has an entry per breaking change +rather than per release. + +The two formats share one version scale, since ``FORMAT_VERSION`` and +``WAVEFORM_LIBRARY_FORMAT_VERSION`` are both the library version cut to ``major.minor``, which is +why one running version bounds both chains. They do not share their rewrites: the same line of +text means different things in a program body and in a library entry, so a migration is +registered for one ``file_format`` and only ever sees files of that kind. A change to vocabulary +the two do share — a renamed waveform constructor, say — is one rewrite registered twice: + +```python +@register_migration("0.3") +@register_migration("0.3", file_format="wfl") +def _square_became_rectangular(lines: list[str]) -> list[str]: ... +``` + +A vendor extension has the same problem one level down, for the wire form of its own operations, +and `register_vendor_migration` is the same mechanism against its own version: the ``require + `` line says which release of the extension wrote the body, and the +extension's migrations carry it up to the installed one. Those chains are per vendor and bounded +by the installed extension rather than by the library. + +A migration is handed the file's lines and must return as many as it was given. That invariant is +what keeps a `ParseError`'s line number and every source-map entry true of the file its author +opened, so the runners refuse a migration that changes the count. + +Lives in a leaf module — only `qprogram._version` — so both readers can import it without +touching the writer↔parser import cycle. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Final, NamedTuple + +from qprogram._version import library_major_minor, parse_file_version, parse_major_minor + +MigrationFn = Callable[[list[str]], list[str]] +"""Rewrite applied to the lines of a file older than the version its migration is registered under.""" + +FILE_FORMATS: Final = ("qp", "wfl") +"""The two text formats the package reads, as the names `register_migration` keys its table by.""" + +# Both format versions are the library version cut to major.minor, so one ceiling bounds both +# chains: a migration applies up to here and a migration registered ahead of its release waits. +_RUNNING_VERSION: Final[str] = library_major_minor() + + +class Migration(NamedTuple): + """One registered rewrite, with the version whose breaking change it repairs. + + Attributes: + version: ``(major, minor)`` of the release that introduced the change. Files declaring an + earlier version get this migration; files at this version or later do not. + name: The rewrite's function name, or its repr when the callable has none, for the error + message that reports a broken line count. + migrate: The rewrite itself. + """ + + version: tuple[int, int] + name: str + migrate: MigrationFn + + +_migrations: dict[str, list[Migration]] = {file_format: [] for file_format in FILE_FORMATS} +_vendor_migrations: dict[str, list[Migration]] = {} + + +def _table(file_format: str) -> list[Migration]: + """Return the migration table for one file format. + + Args: + file_format (str): One of `FILE_FORMATS`. + + Returns: + The list the format's migrations live in, in application order. + + Raises: + ValueError: If ``file_format`` names no format this package reads. + """ + try: + return _migrations[file_format] + except KeyError as e: + known = ", ".join(repr(name) for name in FILE_FORMATS) + msg = f"unknown file format {file_format!r}; the formats that carry migrations are {known}" + raise ValueError(msg) from e + + +def register_migration(version: str, *, file_format: str = "qp") -> Callable[[MigrationFn], MigrationFn]: + """Register a line rewrite that carries files older than ``version`` up to it. + + Write one when a release changes a syntax in a way the previous spelling cannot survive, and + key it to that release. The rewrite receives every line of the file, header included, and + returns the same number of lines; it is expected to leave the header alone, since the reader + has already taken the version off it. + + Two migrations may share a version — a release is free to break two things — and run in + registration order. + + Args: + version (str): The releasing version, as ``major.minor``. A patch component is ignored. + file_format (str, optional): Which format the rewrite reads, ``"qp"`` for a program or + ``"wfl"`` for a waveform library. A rewrite needed by both is registered twice. + + Returns: + The decorator that registers the rewrite and hands it back unchanged. + + Raises: + ValueError: If ``version`` does not parse as ``major.minor``, or ``file_format`` names no + format this package reads. + + Example: + ```python + @register_migration("0.3") + def _sweep_became_loop(lines: list[str]) -> list[str]: + return [_SWEEP_KEYWORD.sub("loop", line) for line in lines] + ``` + """ + table = _table(file_format) + target = parse_major_minor(version) + + def decorate(migrate: MigrationFn) -> MigrationFn: + table.append(Migration(target, getattr(migrate, "__name__", repr(migrate)), migrate)) + table.sort(key=lambda m: m.version) + return migrate + + return decorate + + +def register_vendor_migration(vendor: str, version: str) -> Callable[[MigrationFn], MigrationFn]: + r"""Register a rewrite that carries files older than ``version`` of a vendor extension up to it. + + The vendor equivalent of `register_migration`, keyed to the extension's own version rather + than the library's: a ``require `` line says which release of the + extension wrote the body, and every migration this vendor registered above that version runs + before the body is read. A vendor migration only ever sees ``.qp`` lines, since a ``.wfl`` + file carries no ``require`` line and so claims no vendor version. + + Call it where the extension registers everything else, at import time. + + Args: + vendor (str): Vendor name, as it appears in the ``require`` line and in + ``vendor.operation`` calls. + version (str): The extension version that introduced the change, as ``major.minor``. A + patch component is ignored. + + Returns: + The decorator that registers the rewrite and hands it back unchanged. + + Raises: + ValueError: If ``version`` does not parse as ``major.minor``. + + Example: + ```python + @register_vendor_migration("qdac", "0.3") + def _play_took_a_dwell(lines: list[str]) -> list[str]: + return [_QDAC_PLAY.sub(r"\g<0> dwell=1", line) for line in lines] + ``` + """ + target = parse_major_minor(version) + table = _vendor_migrations.setdefault(vendor, []) + + def decorate(migrate: MigrationFn) -> MigrationFn: + table.append(Migration(target, getattr(migrate, "__name__", repr(migrate)), migrate)) + table.sort(key=lambda m: m.version) + return migrate + + return decorate + + +def known_migrations(file_format: str = "qp") -> tuple[Migration, ...]: + """Return every migration registered for one format, the oldest version first. + + Args: + file_format (str, optional): Which format to report, ``"qp"`` or ``"wfl"``. + + Returns: + The registered migrations, in the order `migrate_lines` applies them. + + Raises: + ValueError: If ``file_format`` names no format this package reads. + """ + return tuple(_table(file_format)) + + +def known_vendor_migrations(vendor: str) -> tuple[Migration, ...]: + """Return every migration a vendor extension registered, the oldest version first. + + Args: + vendor (str): Vendor name. + + Returns: + The registered migrations, in the order `migrate_vendor_lines` applies them. Empty for a + vendor that has never broken a wire form, and for one that is not installed. + """ + return tuple(_vendor_migrations.get(vendor, ())) + + +def migrate_lines(lines: list[str], file_version: str, *, file_format: str = "qp") -> list[str]: + """Bring lines written against ``file_version`` up to the running format version. + + A migration applies when its version is newer than the file's and no newer than the running + one: a file two releases behind collects both steps, and a migration registered ahead of its + release is left out until the version it names ships. + + Args: + lines (list[str]): The file's lines, header included. + file_version (str): The version the file's header declares. + file_format (str, optional): Which format's migrations to run, ``"qp"`` or ``"wfl"``. + + Returns: + The migrated lines, or the argument itself when nothing applies. + + Raises: + ValueError: If ``file_version`` is not exactly ``major.minor``, ``file_format`` names no + format this package reads, or a migration returns a different number of lines than it + was given. + """ + return _apply(_table(file_format), lines, parse_file_version(file_version), parse_file_version(_RUNNING_VERSION)) + + +def migrate_vendor_lines(lines: list[str], vendor: str, file_version: str, installed_version: str) -> list[str]: + """Bring lines written against an older release of a vendor extension up to the installed one. + + Args: + lines (list[str]): The file's lines, header and ``require`` lines included. + file_version (str): The version the file's ``require`` line declares. + vendor (str): Vendor name, naming the table to run. + installed_version (str): Version the installed extension registered, patch and all. + + Returns: + The migrated lines, or the argument itself when nothing applies. + + Raises: + ValueError: If ``file_version`` is not exactly ``major.minor``, or a migration returns a + different number of lines than it was given. + """ + table = _vendor_migrations.get(vendor) + if not table: + return lines + return _apply(table, lines, parse_file_version(file_version), parse_major_minor(installed_version)) + + +def _apply(table: list[Migration], lines: list[str], declared: tuple[int, int], current: tuple[int, int]) -> list[str]: + """Run the migrations of one table that sit between two versions. + + A migration applies when its version is newer than the file's and no newer than the running + one: a file two releases behind collects both steps, and a migration registered ahead of its + release is left out until the version it names ships. + + Args: + table (list[Migration]): The migrations to consider, oldest version first. + lines (list[str]): The file's lines. + declared (tuple[int, int]): Version the file declares. + current (tuple[int, int]): Version to bring it up to. + + Returns: + The migrated lines, or the argument itself when nothing applies. + + Raises: + ValueError: If a migration returns a different number of lines than it was given. + """ + for migration in table: + if not declared < migration.version <= current: + continue + migrated = migration.migrate(list(lines)) + if len(migrated) != len(lines): + version = ".".join(str(part) for part in migration.version) + msg = ( + f"migration {migration.name!r} to {version} returned {len(migrated)} lines for " + f"{len(lines)}: a migration must preserve the line count, so that a diagnostic " + f"still points at the line of the file it came from" + ) + raise ValueError(msg) + lines = migrated + return lines diff --git a/src/qprogram/serialization/parser.py b/src/qprogram/serialization/parser.py index afb8494..19cbd38 100644 --- a/src/qprogram/serialization/parser.py +++ b/src/qprogram/serialization/parser.py @@ -28,6 +28,8 @@ from pathlib import Path from typing import TYPE_CHECKING, ClassVar, cast +from qprogram._version import parse_file_version +from qprogram._version import parse_major_minor as _parse_major_minor from qprogram.blocks.conditional import Conditional from qprogram.blocks.parallel import Parallel from qprogram.blocks.sweep import Sweep @@ -41,6 +43,7 @@ from qprogram.serialization import _specs as _core_specs from qprogram.serialization._format import FORMAT_VERSION from qprogram.serialization._specs import _parse_number +from qprogram.serialization.migrations import migrate_lines, migrate_vendor_lines from qprogram.serialization.registry import ( get_block_spec, get_operation_spec, @@ -102,6 +105,9 @@ class _QuotedStr(str): def loads(text: str, *, auto_activate: bool = True) -> QProgram: """Parse a ``.qp``-format string into a [`QProgram`][qprogram.QProgram]. + A document written against an earlier format version is migrated in memory first, by the + rewrites registered for the versions in between (see `qprogram.serialization.migrations`). + Args: text (str): The ``.qp`` document to parse. auto_activate (bool, optional): Whether a ``require `` line whose extension is not @@ -120,6 +126,8 @@ def loads(text: str, *, auto_activate: bool = True) -> QProgram: such as a variable id that is a reserved ``.qp`` keyword. TypeError: If a constructor call in the file does not fit its class's signature — an inline waveform, or a sweep source nested inside a combinator's argument list. + ValueError: If a migration this load runs returns a different number of lines than it was + given, which would take every line number in the file's diagnostics with it. """ return _Parser(text, auto_activate=auto_activate).parse() @@ -143,6 +151,8 @@ def load(path: str, *, auto_activate: bool = True) -> QProgram: such as a variable id that is a reserved ``.qp`` keyword. TypeError: If a constructor call in the file does not fit its class's signature — an inline waveform, or a sweep source nested inside a combinator's argument list. + ValueError: If a migration this load runs returns a different number of lines than it was + given. See `loads`. """ return loads(Path(path).read_text(encoding="utf-8"), auto_activate=auto_activate) @@ -298,11 +308,16 @@ def _indent(self) -> int: def _parse_header(self) -> None: """Consume the leading ``#!QProgram `` header, skipping any blank lines before it. - Only the major component of the version is binding: a file whose major matches the running - format version loads whatever its minor is. + The version is exactly ``major.minor``; a file carries no patch, since a release that only + moves the patch cannot have changed the format. A file older than the running version is + migrated in place: the registered migrations for every version in between rewrite the + lines this parser goes on to read (see `qprogram.serialization.migrations`). A newer + version is refused, having been written by a release this one knows nothing about. Raises: - ParseError: If the header is missing or declares a different major format version. + ParseError: If the header is missing, its version is not ``major.minor``, or it + declares a version newer than this release writes. + ValueError: If a migration returns a different number of lines than it was given. """ while self._pos < len(self._lines) and not self._stripped(): self._pos += 1 @@ -311,9 +326,17 @@ def _parse_header(self) -> None: msg = "Missing #!QProgram header" raise ParseError(msg, self._pos + 1) version = line.split()[-1] if len(line.split()) > 1 else "unknown" - if version.split(".")[0] != FORMAT_VERSION.split(".", maxsplit=1)[0]: + try: + declared = parse_file_version(version) + except ValueError as e: + msg = f"Unsupported format version {version}" + raise ParseError(msg, self._pos + 1) from e + current = parse_file_version(FORMAT_VERSION) + if declared > current: msg = f"Unsupported format version {version}" raise ParseError(msg, self._pos + 1) + if declared < current: + self._lines = migrate_lines(self._lines, version) self._pos += 1 def _parse_requires(self) -> None: @@ -348,18 +371,27 @@ def _parse_requires(self) -> None: def _check_vendor_compat(self, vendor: str, file_version: str) -> None: """Check one ``require`` line against the vendor extension registered in this environment. - Majors must match exactly and the file's minor must be no newer than the installed - extension's; a patch component is informational and ignored. When auto-activation is on and - the vendor is not registered yet, its ``qprogram.vendors`` entry point is imported first so - the comparison runs against the extension the file expects. + The rule is the header's, against the extension's version instead of the library's. The + version in the line is exactly ``major.minor``, since a patch release of an extension + changes code and not the wire form. A line asking for more than the installed extension + provides is refused. An older one is accepted, and the migrations that extension + registered in between rewrite the body before it is read, so a program saved against any + earlier release of the extension still loads (see `qprogram.serialization.migrations`). + + When auto-activation is on and the vendor is not registered yet, its ``qprogram.vendors`` + entry point is imported first, so the comparison runs against the extension the file + expects. Args: vendor (str): Vendor name from the ``require`` line. file_version (str): Version the file requires, as ``major.minor``. Raises: - ParseError: If the vendor cannot be resolved, either version is malformed, the majors - differ, or the file needs a newer minor than the installed extension provides. + ParseError: If the vendor cannot be resolved, the line's version is not + ``major.minor``, the installed version does not parse, or the file asks for a + newer extension than this environment has. + ValueError: If one of the vendor's migrations returns a different number of lines than + it was given. """ installed = get_vendor_version(vendor) if installed is None and self._auto_activate: @@ -384,23 +416,21 @@ def _check_vendor_compat(self, vendor: str, file_version: str) -> None: ) raise ParseError(msg, self._pos + 1) try: - file_major, file_minor = _parse_major_minor(file_version) - inst_major, inst_minor = _parse_major_minor(installed) + required = parse_file_version(file_version) except ValueError as e: raise ParseError(str(e), self._pos + 1) from e - if file_major != inst_major: - msg = ( - f"file requires {vendor} {file_version} (major {file_major}); " - f"installed {vendor} is {installed} (major {inst_major}) — " - f"major versions must match" - ) - raise ParseError(msg, self._pos + 1) - if file_minor > inst_minor: + try: + available = _parse_major_minor(installed) + except ValueError as e: + raise ParseError(str(e), self._pos + 1) from e + if required > available: msg = ( - f"file requires {vendor} {file_version} or compatible; " - f"installed {vendor} is {installed} — minor version too old" + f"file requires {vendor} {file_version}, newer than the installed " + f"{vendor} {installed} — install {vendor} {file_version} or newer" ) raise ParseError(msg, self._pos + 1) + if required < available: + self._lines = migrate_vendor_lines(self._lines, vendor, file_version, installed) # -- metadata ------------------------------------------------------------ @@ -1693,31 +1723,6 @@ def _unescape_str(s: str) -> str: return "".join(out) -def _parse_major_minor(version: str) -> tuple[int, int]: - """Split a version string into its major and minor components. - - A patch component is accepted and ignored: vendor compatibility is decided at major.minor. - - Args: - version (str): Version text from a ``require`` line or a registered vendor. - - Returns: - The ``(major, minor)`` pair. - - Raises: - ValueError: If the string has no minor component, or either component is not an integer. - """ - parts = version.split(".") - if len(parts) < 2: - msg = f"version {version!r} must have at least major.minor" - raise ValueError(msg) - try: - return int(parts[0]), int(parts[1]) - except ValueError as e: - msg = f"version {version!r} has non-integer major/minor components" - raise ValueError(msg) from e - - def _to_expression(value: object) -> Expression: """Promote a parsed value into an [`Expression`][qprogram.Expression]-compatible operand. diff --git a/src/qprogram/waveform_library.py b/src/qprogram/waveform_library.py index cafb349..b408f72 100644 --- a/src/qprogram/waveform_library.py +++ b/src/qprogram/waveform_library.py @@ -37,7 +37,7 @@ from pathlib import Path from typing import TYPE_CHECKING, cast -from qprogram._version import library_major_minor +from qprogram._version import library_major_minor, parse_file_version from qprogram.buses import BusRef from qprogram.errors import ValidationError @@ -51,7 +51,7 @@ _LibraryKey = tuple["str | None", "int | tuple[int, ...] | None", "str | None", str] # Version of the ``.wfl`` text format. Like the ``.qp`` FORMAT_VERSION it is the library version -# truncated to ``major.minor``, and only the major is compared on load. +# truncated to ``major.minor``: an earlier file is migrated on load, a later one is refused. WAVEFORM_LIBRARY_FORMAT_VERSION = library_major_minor() # Entry coordinate: ``element[idx].kind`` (exact) or ``element[*].kind`` (family). idx may be a tuple @@ -247,6 +247,11 @@ def loads(cls, text: str) -> WaveformLibrary: comment ahead of it is an error. After the header, blank lines and ``#`` comment lines are skipped and every other line must be an entry. + A document written against an earlier version of the format is migrated in memory first, + by the rewrites registered for the ``"wfl"`` format between its version and this one (see + `qprogram.serialization.migrations`). A later version is refused, having been written by a + release this one knows nothing about. + Args: text (str): The ``.wfl`` document to parse. @@ -254,11 +259,13 @@ def loads(cls, text: str) -> WaveformLibrary: The reconstructed library, entries in file order. Raises: - ParseError: On a missing or incompatible header, a malformed entry, or an unknown - waveform type. Waveform types are looked up in the global serialization registry, so - every built-in is always available while a vendor waveform needs its package - imported first. + ParseError: On a missing or unreadable header, a header from a newer release, a + malformed entry, or an unknown waveform type. Waveform types are looked up in the global + serialization registry, so every built-in is always available while a vendor + waveform needs its package imported first. ValidationError: If an entry names an empty waveform name. + ValueError: If a migration this read runs returns a different number of lines than it + was given. """ from qprogram.serialization.parser import ( # ruff: ignore[import-outside-top-level] ParseError, @@ -276,11 +283,7 @@ def loads(cls, text: str) -> WaveformLibrary: if pos >= len(lines) or not lines[pos].strip().startswith("#!WaveformLibrary"): msg = "Missing #!WaveformLibrary header" raise ParseError(msg, pos + 1) - header = lines[pos].split() - version = header[-1] if len(header) > 1 else "unknown" - if version.split(".", maxsplit=1)[0] != WAVEFORM_LIBRARY_FORMAT_VERSION.split(".", maxsplit=1)[0]: - msg = f"Unsupported WaveformLibrary format version {version}" - raise ParseError(msg, pos + 1) + lines = _migrated(lines, pos) pos += 1 for offset, raw in enumerate(lines[pos:], start=pos): @@ -345,6 +348,44 @@ def __repr__(self) -> str: return f"WaveformLibrary({len(self._entries)} entries)" +def _migrated(lines: list[str], pos: int) -> list[str]: + """Check the version on a ``.wfl`` header and bring older lines up to this release. + + The rule is the one `qprogram.serialization.parser` applies to a ``.qp`` header, against the + same number: the version is exactly ``major.minor``, an earlier one is migrated, and a later + one is refused, having been written by a release this one knows nothing about. + + Args: + lines (list[str]): The document's lines. + pos (int): Index of the header line. + + Returns: + The lines to parse: the argument itself, or what the migrations between the version the + header declares and the running one made of it. + + Raises: + ParseError: If the version is not ``major.minor``, or is newer than this release writes. + ValueError: If a migration returns a different number of lines than it was given. + """ + from qprogram.serialization.migrations import migrate_lines # ruff: ignore[import-outside-top-level] + from qprogram.serialization.parser import ParseError # ruff: ignore[import-outside-top-level] + + header = lines[pos].split() + version = header[-1] if len(header) > 1 else "unknown" + try: + declared = parse_file_version(version) + except ValueError as e: + msg = f"Unsupported WaveformLibrary format version {version}" + raise ParseError(msg, pos + 1) from e + current = parse_file_version(WAVEFORM_LIBRARY_FORMAT_VERSION) + if declared > current: + msg = f"Unsupported WaveformLibrary format version {version}" + raise ParseError(msg, pos + 1) + if declared < current: + return migrate_lines(lines, version, file_format="wfl") + return lines + + def _format_coord(element: str | None, idx: int | tuple[int, ...] | None, kind: str | None) -> str: """Render an entry coordinate for ``.wfl``; ``""`` for the global tier. diff --git a/tests/test_grammar.py b/tests/test_grammar.py index cca3b38..95b1063 100644 --- a/tests/test_grammar.py +++ b/tests/test_grammar.py @@ -245,6 +245,9 @@ def test_property_fragment_programs_are_grammatical(p: QProgram) -> None: "else-with-condition": (HEADER + "\n\nbody:\n if m0.state == 0:\n sync\n else m0.state:\n sync\n"), "dangling-dict": HEADER + '\n\nbody:\n set_parameter "a" "b" matrix={"a": 1.0\n', "fragment-missing-parens": HEADER + "\n\nfragment f1:\n sync\n\nbody:\n", + # A header version is two integer components: the terminal says so, and so does the parser. + "header-version-with-patch": "#!QProgram 0.2.3\n\nbody:\n", + "header-version-bare-major": "#!QProgram 0\n\nbody:\n", } diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..978d10a --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,449 @@ +# Copyright 2026 Qilimanjaro Quantum Tech +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for loading a file written against an earlier format version. + +The migrations a release registers are what let today's parser read yesterday's syntax. The +fixtures here register throwaway migrations, since the released format has needed none yet, and +the tests cover which of them a given header pulls in, the order they run, and the line-count +invariant that keeps error lines pointing at the file the author wrote. +""" + +from __future__ import annotations + +import re + +import pytest +from _header import HEADER, WFL_HEADER + +import qprogram as qp +from qprogram.serialization import migrations +from qprogram.serialization._format import FORMAT_VERSION +from qprogram.serialization.migrations import ( + known_migrations, + known_vendor_migrations, + migrate_lines, + register_migration, + register_vendor_migration, +) +from qprogram.serialization.registry import get_vendor_version, register_vendor_version + + +@pytest.fixture(autouse=True) +def _empty_registry(): + """Register into empty tables and restore whatever the package shipped afterwards.""" + saved = {file_format: list(table) for file_format, table in migrations._migrations.items()} + saved_vendors = {vendor: list(table) for vendor, table in migrations._vendor_migrations.items()} + for table in migrations._migrations.values(): + table.clear() + migrations._vendor_migrations.clear() + yield + for file_format, table in saved.items(): + migrations._migrations[file_format][:] = table + migrations._vendor_migrations.clear() + migrations._vendor_migrations.update(saved_vendors) + + +def _older(offset: int = 1) -> str: + """Return a version one or more minors below the running one, as an earlier release wrote it.""" + major, minor = FORMAT_VERSION.split(".")[:2] + return f"{major}.{int(minor) - offset}" + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + + +def test_register_migration_returns_the_function_unchanged(): + def rewrite(lines): + return lines + + assert register_migration(FORMAT_VERSION)(rewrite) is rewrite + + +def test_known_migrations_is_ordered_oldest_first(): + register_migration(FORMAT_VERSION)(lambda lines: lines) + register_migration(_older())(lambda lines: lines) + assert [m.version for m in known_migrations()] == sorted(m.version for m in known_migrations()) + + +@pytest.mark.parametrize( + ("version", "message"), + [ + ("", r"at least major\.minor"), + ("1", r"at least major\.minor"), + ("banana", r"at least major\.minor"), + ("0.x", "non-integer major/minor"), + ], +) +def test_register_migration_rejects_a_malformed_version(version, message): + with pytest.raises(ValueError, match=message): + register_migration(version) + + +# --------------------------------------------------------------------------- +# Which migrations a header pulls in +# --------------------------------------------------------------------------- + + +def test_an_older_file_is_migrated_on_the_way_in(): + """The released syntax has no `sweep` keyword; a migration is what makes the old file readable.""" + + @register_migration(FORMAT_VERSION) + def _sweep_became_for(lines): + return [line.replace(" sweep ", " for ") for line in lines] + + p = qp.loads(f"#!QProgram {_older()}\n\nbody:\n sweep x in Range(start=0, stop=3, step=1):\n sync\n") + assert isinstance(p.body.elements[0], qp.blocks.Sweep) + + +def test_a_current_file_is_left_alone(): + register_migration(FORMAT_VERSION)(lambda lines: [line.replace("sync", "wait_trigger") for line in lines]) + assert qp.loads(HEADER + "\n\nbody:\n sync\n").body.elements + + +@pytest.mark.parametrize("offset", [1, 99]) +def test_a_newer_file_is_refused(offset): + """Migrations only run forward, so a file from a later release has nothing to bring it back.""" + major, minor = FORMAT_VERSION.split(".")[:2] + ahead = f"{major}.{int(minor) + offset}" + with pytest.raises(qp.ParseError, match="Unsupported format version"): + qp.loads(f"#!QProgram {ahead}\n\nbody:\n sync\n") + with pytest.raises(qp.ParseError, match="Unsupported WaveformLibrary format version"): + qp.WaveformLibrary.loads(f"#!WaveformLibrary {ahead}\n") + + +def test_a_migration_ahead_of_its_release_is_left_out(): + """Registering against an unreleased version does nothing until that version ships.""" + major, minor = FORMAT_VERSION.split(".")[:2] + register_migration(f"{major}.{int(minor) + 1}")(lambda lines: [line.replace("sync", "bogus_op") for line in lines]) + assert qp.loads(f"#!QProgram {_older()}\n\nbody:\n sync\n").body.elements + + +def test_every_step_between_the_file_and_the_running_version_runs(): + """A file two releases behind collects both migrations, oldest first.""" + order: list[str] = [] + + @register_migration(_older()) + def _first(lines): + order.append("first") + return [line.replace("aaa", "bbb") for line in lines] + + @register_migration(FORMAT_VERSION) + def _second(lines): + order.append("second") + return [line.replace("bbb", "sync") for line in lines] + + p = qp.loads(f"#!QProgram {_older(2)}\n\nbody:\n aaa\n") + assert order == ["first", "second"] + assert p.body.elements + + +def test_two_migrations_on_one_version_run_in_registration_order(): + order: list[str] = [] + + @register_migration(FORMAT_VERSION) + def _first(lines): + order.append("first") + return lines + + @register_migration(FORMAT_VERSION) + def _second(lines): + order.append("second") + return lines + + qp.loads(f"#!QProgram {_older()}\n\nbody:\n sync\n") + assert order == ["first", "second"] + + +def test_an_older_major_loads_too(monkeypatch): + """The gate is a migration path, not a matching major: an 0.9 file loads on a 1.3 runtime.""" + monkeypatch.setattr("qprogram.serialization.parser.FORMAT_VERSION", "1.3") + monkeypatch.setattr("qprogram.serialization.migrations._RUNNING_VERSION", "1.3") + + @register_migration("1.0") + def _barrier_became_sync(lines): + return [line.replace(" barrier", " sync") for line in lines] + + assert qp.loads("#!QProgram 0.9\n\nbody:\n barrier\n").body.elements + + +def test_an_older_file_loads_when_no_migration_applies(): + """The common case: a release that broke nothing leaves an older file readable as it is.""" + assert qp.loads(f"#!QProgram {_older()}\n\nbody:\n sync\n").body.elements + + +def test_a_newer_major_is_still_refused(): + with pytest.raises(qp.ParseError, match=r"Unsupported format version 99\.0"): + qp.loads("#!QProgram 99.0\n\nbody:\n sync\n") + + +@pytest.mark.parametrize("version", ["unknown", "banana", "0", "0.2.3"]) +def test_a_version_that_is_not_major_minor_is_refused(version): + """A header carries two integer components: no bare major, no patch, nothing else.""" + with pytest.raises(qp.ParseError, match=f"Unsupported format version {re.escape(version)}"): + qp.loads(f"#!QProgram {version}\n\nbody:\n sync\n") + + +# --------------------------------------------------------------------------- +# The line-count invariant +# --------------------------------------------------------------------------- + + +def test_a_migration_that_drops_a_line_is_refused(): + @register_migration(FORMAT_VERSION) + def _drops_a_line(lines): + return lines[:-1] + + text = f"#!QProgram {_older()}\n\nbody:\n sync\n" + with pytest.raises(ValueError, match=r"_drops_a_line.*preserve the line count"): + qp.loads(text) + + +def test_a_migration_that_adds_a_line_is_refused(): + @register_migration(FORMAT_VERSION) + def _adds_a_line(lines): + return [*lines, " sync"] + + text = f"#!QProgram {_older()}\n\nbody:\n sync\n" + with pytest.raises(ValueError, match=r"_adds_a_line.*preserve the line count"): + qp.loads(text) + + +def test_an_error_after_a_migration_still_names_the_line_of_the_file(): + """The point of the invariant: line 5 of the diagnostic is line 5 of what the author wrote.""" + + @register_migration(FORMAT_VERSION) + def _sweep_became_for(lines): + return [line.replace(" sweep ", " for ") for line in lines] + + text = f"#!QProgram {_older()}\n\nbody:\n sweep x in Range(start=0, stop=3, step=1):\n bogus_op 1\n" + with pytest.raises(qp.ParseError) as excinfo: + qp.loads(text) + assert excinfo.value.line_num == 5 + assert text.splitlines()[4].strip() == "bogus_op 1" + + +def test_the_source_map_of_a_migrated_file_points_at_the_original_lines(): + @register_migration(FORMAT_VERSION) + def _sweep_became_for(lines): + return [line.replace(" sweep ", " for ") for line in lines] + + p = qp.loads(f"#!QProgram {_older()}\n\nbody:\n sweep x in Range(start=0, stop=3, step=1):\n sync\n") + assert sorted(p.source_map.values()) == [4, 5] + + +# --------------------------------------------------------------------------- +# The waveform library format +# --------------------------------------------------------------------------- + + +def test_an_older_library_is_migrated_on_the_way_in(): + @register_migration(FORMAT_VERSION, file_format="wfl") + def _sq_became_square(lines): + return [line.replace("= Sq(", "= Square(") for line in lines] + + library = qp.WaveformLibrary.loads(f'#!WaveformLibrary {_older()}\n"pi" = Sq(amplitude=0.5, duration=40)\n') + assert library.get("drive", "pi").amplitude == pytest.approx(0.5) + + +def test_a_current_library_is_left_alone(): + register_migration(FORMAT_VERSION, file_format="wfl")( + lambda lines: [line.replace("Square", "Bogus") for line in lines] + ) + text = f'{WFL_HEADER}\n"pi" = Square(amplitude=0.5, duration=40)\n' + assert qp.WaveformLibrary.loads(text).get("drive", "pi") is not None + + +def test_a_later_library_major_is_still_refused(): + with pytest.raises(qp.ParseError, match=r"Unsupported WaveformLibrary format version 9\.0"): + qp.WaveformLibrary.loads("#!WaveformLibrary 9.0\n") + + +def test_a_library_version_carrying_a_patch_is_refused(): + """A file has no patch to carry: the format changes at major.minor and nowhere else.""" + with pytest.raises(qp.ParseError, match="Unsupported WaveformLibrary format version"): + qp.WaveformLibrary.loads(f'#!WaveformLibrary {FORMAT_VERSION}.7\n"pi" = Square(0.5, 40)\n') + + +@pytest.mark.parametrize("version", ["unknown", "banana", "0", "0.2.3"]) +def test_a_library_version_that_is_not_major_minor_is_refused(version): + """The same rule as a program header: two integer components, no more and no fewer.""" + with pytest.raises(qp.ParseError, match=f"Unsupported WaveformLibrary format version {re.escape(version)}"): + qp.WaveformLibrary.loads(f"#!WaveformLibrary {version}\n") + + +def test_the_two_formats_do_not_share_their_rewrites(): + """The same line means different things in a program and in a library, so tables are separate.""" + seen: list[str] = [] + + @register_migration(FORMAT_VERSION) + def _program_only(lines): + seen.append("qp") + return lines + + @register_migration(FORMAT_VERSION, file_format="wfl") + def _library_only(lines): + seen.append("wfl") + return lines + + qp.loads(f"#!QProgram {_older()}\n\nbody:\n sync\n") + assert seen == ["qp"] + qp.WaveformLibrary.loads(f'#!WaveformLibrary {_older()}\n"pi" = Square(amplitude=0.5, duration=40)\n') + assert seen == ["qp", "wfl"] + + +def test_one_rewrite_can_be_registered_for_both_formats(): + """Vocabulary the two files share — a waveform constructor — is one rewrite, registered twice.""" + formats: list[str] = [] + + @register_migration(FORMAT_VERSION) + @register_migration(FORMAT_VERSION, file_format="wfl") + def _square_became_rectangular(lines): + formats.append("called") + return [line.replace("Rectangular(", "Square(") for line in lines] + + qp.loads(f'#!QProgram {_older()}\n\nbody:\n play "drive" Rectangular(amplitude=0.5, duration=40)\n') + library = qp.WaveformLibrary.loads( + f'#!WaveformLibrary {_older()}\n"pi" = Rectangular(amplitude=0.5, duration=40)\n' + ) + assert library.get("drive", "pi").amplitude == pytest.approx(0.5) + assert formats == ["called", "called"] + + +@pytest.mark.parametrize("file_format", ["", "qp ", "QP", "nope"]) +def test_an_unknown_file_format_is_refused(file_format): + with pytest.raises(ValueError, match="unknown file format"): + register_migration(FORMAT_VERSION, file_format=file_format) + + +def test_known_migrations_reports_one_format_at_a_time(): + register_migration(FORMAT_VERSION)(lambda lines: lines) + assert len(known_migrations()) == 1 + assert known_migrations("wfl") == () + + +# --------------------------------------------------------------------------- +# Vendor extensions +# --------------------------------------------------------------------------- + + +@pytest.fixture +def _dummy_at(request, dummy_vendor): # ruff: ignore[unused-function-argument] + """Register the dummy vendor at the version the test asks for, restoring the real one after.""" + saved = get_vendor_version("dummy") + register_vendor_version("dummy", request.param) + yield request.param + register_vendor_version("dummy", saved) + + +@pytest.mark.parametrize("_dummy_at", ["0.5.0"], indirect=True) +def test_an_older_require_line_migrates_the_body(_dummy_at): + """A file written against dummy 0.3 loads on dummy 0.5, through the steps in between.""" + steps: list[str] = [] + + @register_vendor_migration("dummy", "0.4") + def _markers_became_set_markers(lines): + steps.append("0.4") + return [line.replace("dummy.markers", 'dummy.set_markers "bus" "0001"') for line in lines] + + @register_vendor_migration("dummy", "0.5") + def _later_still(lines): + steps.append("0.5") + return lines + + p = qp.loads(f"{HEADER}\n\nrequire dummy 0.3\n\nbody:\n dummy.markers\n") + assert steps == ["0.4", "0.5"] + assert p.body.elements + + +@pytest.mark.parametrize("_dummy_at", ["2.1.0"], indirect=True) +def test_an_older_require_major_loads_now(_dummy_at): + """An earlier major used to be refused outright; it migrates like any other older version.""" + + @register_vendor_migration("dummy", "1.0") + def _markers_became_set_markers(lines): + return [line.replace("dummy.markers", 'dummy.set_markers "bus" "0001"') for line in lines] + + assert qp.loads(f"{HEADER}\n\nrequire dummy 0.9\n\nbody:\n dummy.markers\n").body.elements + + +@pytest.mark.parametrize("_dummy_at", ["0.5.0"], indirect=True) +def test_a_require_line_at_the_installed_version_migrates_nothing(_dummy_at): + register_vendor_migration("dummy", "0.5")(lambda lines: [line.replace("dummy.", "bogus.") for line in lines]) + assert qp.loads(f'{HEADER}\n\nrequire dummy 0.5\n\nbody:\n dummy.set_markers "bus" "0001"\n').body.elements + + +@pytest.mark.parametrize("_dummy_at", ["0.5.0"], indirect=True) +def test_a_vendor_migration_ahead_of_the_installed_release_is_left_out(_dummy_at): + """The ceiling is the installed extension, so a rewrite for its next release waits.""" + register_vendor_migration("dummy", "0.6")(lambda lines: [line.replace("dummy.", "bogus.") for line in lines]) + assert qp.loads(f'{HEADER}\n\nrequire dummy 0.3\n\nbody:\n dummy.set_markers "bus" "0001"\n').body.elements + + +def test_a_vendor_with_no_migrations_is_no_obstacle(dummy_vendor): # ruff: ignore[unused-function-argument] + """The common case: nothing registered, so an older require line loads as it is.""" + assert qp.loads(f'{HEADER}\n\nrequire dummy 0.0\n\nbody:\n dummy.set_markers "bus" "0001"\n').body.elements + + +@pytest.mark.parametrize("_dummy_at", ["0.5.0"], indirect=True) +def test_a_vendor_migration_must_preserve_the_line_count(_dummy_at): + @register_vendor_migration("dummy", "0.5") + def _drops_a_line(lines): + return lines[:-1] + + with pytest.raises(ValueError, match=r"_drops_a_line.*preserve the line count"): + qp.loads(f'{HEADER}\n\nrequire dummy 0.3\n\nbody:\n dummy.set_markers "bus" "0001"\n') + + +def test_vendor_tables_are_separate_from_the_format_table(dummy_vendor): # ruff: ignore[unused-function-argument] + register_migration(FORMAT_VERSION)(lambda lines: lines) + register_vendor_migration("dummy", "0.1")(lambda lines: lines) + assert [m.name for m in known_vendor_migrations("dummy")] == [""] + assert known_vendor_migrations("nonexistent_vendor") == () + assert len(known_migrations()) == 1 + + +def test_known_vendor_migrations_is_ordered_oldest_first(): + register_vendor_migration("dummy", "0.9")(lambda lines: lines) + register_vendor_migration("dummy", "0.2")(lambda lines: lines) + assert [m.version for m in known_vendor_migrations("dummy")] == [(0, 2), (0, 9)] + + +# --------------------------------------------------------------------------- +# The runner on its own +# --------------------------------------------------------------------------- + + +def test_migrate_lines_returns_its_argument_when_nothing_applies(): + lines = ["#!QProgram " + FORMAT_VERSION, "body:", " sync"] + assert migrate_lines(lines, FORMAT_VERSION) is lines + + +def test_migrate_lines_rejects_a_malformed_file_version(): + with pytest.raises(ValueError, match=r"major\.minor"): + migrate_lines(["#!QProgram banana"], "banana") + + +def test_a_migration_cannot_corrupt_the_caller_s_lines(): + """A migration that mutates what it is handed works on a copy.""" + + @register_migration(FORMAT_VERSION) + def _mutates_in_place(lines): + lines[-1] = " sync" + return lines + + lines = [f"#!QProgram {_older()}", "body:", " aaa"] + assert migrate_lines(lines, _older())[-1] == " sync" + assert lines[-1] == " aaa" diff --git a/tests/test_parser.py b/tests/test_parser.py index 2a94f1a..e415025 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -15,6 +15,8 @@ from __future__ import annotations +import re + import numpy as np import pytest from _header import HEADER @@ -283,11 +285,11 @@ def test_loads_unsupported_major_version_raises(): loads("#!QProgram 99.0\n\nbody:\n") -def test_loads_minor_within_major_works(): - # Any minor is accepted as long as the major is the running one. +def test_loads_newer_minor_raises(): + # Nothing from the future loads: this release cannot know what a later minor changed. major = FORMAT_VERSION.split(".")[0] - text = f"#!QProgram {major}.99\n\nbody:\n" - loads(text) + with pytest.raises(ParseError, match="Unsupported format version"): + loads(f"#!QProgram {major}.99\n\nbody:\n") def test_loads_empty_program(): @@ -319,15 +321,18 @@ def test_loads_require_malformed_raises(dummy_vendor): # ruff: ignore[unused-fu loads(text) -def test_loads_require_major_mismatch_raises(dummy_vendor): # ruff: ignore[unused-function-argument] - text = HEADER + "\n\nrequire dummy 99.0\n\nbody:\n" - with pytest.raises(ParseError, match="major versions must match"): +@pytest.mark.parametrize("required", ["99.0", "0.99"]) +def test_loads_require_newer_than_installed_raises(dummy_vendor, required): # ruff: ignore[unused-function-argument] + """Whichever component is ahead, the environment cannot provide what the file asks for.""" + text = HEADER + f"\n\nrequire dummy {required}\n\nbody:\n" + with pytest.raises(ParseError, match=f"install dummy {re.escape(required)} or newer"): loads(text) -def test_loads_require_minor_too_old_raises(dummy_vendor): # ruff: ignore[unused-function-argument] - text = HEADER + "\n\nrequire dummy 0.99\n\nbody:\n" - with pytest.raises(ParseError, match="minor version too old"): +def test_loads_require_with_a_patch_raises(dummy_vendor): # ruff: ignore[unused-function-argument] + """A `require` line names a wire form, and a patch release of an extension has none of its own.""" + text = HEADER + "\n\nrequire dummy 0.1.0\n\nbody:\n" + with pytest.raises(ParseError, match=r"must be exactly major\.minor"): loads(text)