From e808b930204625c260d4a847ba713b18cdcc10f6 Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Tue, 8 Sep 2026 00:32:38 +0300 Subject: [PATCH 1/2] Take the .qp and .wfl format version from the library version Both headers carried a literal 1.0. They now read the installed version, cut to major.minor, so this release writes `#!QProgram 0.2` and `#!WaveformLibrary 0.2`, and the library moves to 0.2.0. The one derivation lives in `qprogram/_version.py`, stdlib-only, which keeps `serialization/_format.py` a leaf the writer and the parser can both import and keeps `waveform_library.py` clear of the serialization package it otherwise imports only inside its methods. The compatibility check is untouched: any minor within the running major loads, a different major does not, so a file from 0.1.x is refused on its major. The suite builds its headers from the running version through `tests/_header.py` rather than pinning one. --- changelog/42.changed.md | 1 + docs/developer/architecture.md | 3 +- docs/developer/serialization-internals.md | 23 ++- docs/developer/vendor-extensions.md | 2 +- docs/examples/active-reset.md | 2 +- docs/examples/cpmg-fragments.md | 2 +- docs/examples/cz-chevron.md | 2 +- docs/examples/multiplexed-readout.md | 4 +- docs/examples/qubit-spectroscopy.md | 2 +- docs/examples/rabi.md | 2 +- docs/examples/resonator-spectroscopy.md | 2 +- docs/examples/single-shot-readout.md | 2 +- docs/examples/t1-and-ramsey.md | 2 +- docs/getting-started.md | 2 +- docs/guide/control-flow.md | 2 +- docs/guide/fragments.md | 4 +- docs/guide/measurements.md | 2 +- docs/guide/serialization.md | 19 +- docs/guide/variables.md | 2 +- docs/guide/waveforms.md | 2 +- docs/index.md | 14 +- docs/reference/errors.md | 12 +- docs/reference/index.md | 7 +- docs/reference/qp-format.md | 57 ++--- pyproject.toml | 2 +- src/qprogram/_version.py | 40 ++++ src/qprogram/grammar/qp.lark | 4 +- src/qprogram/serialization/_format.py | 11 +- src/qprogram/serialization/parser.py | 2 +- src/qprogram/waveform_library.py | 6 +- tests/_header.py | 27 +++ tests/test_coverage_gaps.py | 49 ++--- tests/test_fragments_serialization.py | 46 ++--- tests/test_grammar.py | 35 ++-- tests/test_lsp.py | 16 +- tests/test_parser.py | 240 +++++++++++----------- tests/test_paths.py | 3 +- tests/test_specs.py | 3 +- tests/test_vendor_discovery.py | 3 +- tests/test_waveform_library.py | 24 ++- tests/test_writer.py | 10 +- uv.lock | 2 +- 42 files changed, 397 insertions(+), 298 deletions(-) create mode 100644 changelog/42.changed.md create mode 100644 src/qprogram/_version.py create mode 100644 tests/_header.py diff --git a/changelog/42.changed.md b/changelog/42.changed.md new file mode 100644 index 0000000..587a870 --- /dev/null +++ b/changelog/42.changed.md @@ -0,0 +1 @@ +The `.qp` format version now follows the library version, truncated to `major.minor`, so this release writes `#!QProgram 0.2` instead of the fixed `1.0`. `FORMAT_VERSION` is now derived rather than a literal: `library_major_minor` in the new `qprogram/_version.py` reads the installed distribution's version through `importlib.metadata`, and imports nothing else from the package, so `qprogram/serialization/_format.py` stays the leaf module the writer and the parser both import. The compatibility check is unchanged — the parser accepts any minor within its own major and rejects a different one — so a file written by an earlier release no longer loads: `#!QProgram 1.0` now fails with `Line 1: Unsupported format version 1.0`. The `.wfl` waveform library format follows the library version through the same helper, so a `WaveformLibrary` now writes `#!WaveformLibrary 0.2` and refuses a `1.0` file with `Line 1: Unsupported WaveformLibrary format version 1.0`. diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index d5d79cb..10c4f91 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -37,6 +37,7 @@ qprogram/ ├── errors.py # exception hierarchy ├── _reserved.py # RESERVED_KEYWORDS ├── _structural.py # ast_eq / ast_hash helpers + ├── _version.py # the library version, cut to major.minor for the headers ├── vendor.py # VendorNamespace base ├── platform.py # PlatformProtocol: capabilities, validate, plan, explain, execute ├── protocol.py # capability descriptors, Diagnostic, Profile, token registry @@ -60,7 +61,7 @@ qprogram/ ├── parser.py # loads / load ├── registry.py # registry-driven dispatch ├── _specs.py # per-op serialize/parse callbacks - └── _format.py # the format version constant + └── _format.py # the .qp format version constant ``` `qprogram` is the whole language: the AST, the `.qp` format, the capability diff --git a/docs/developer/serialization-internals.md b/docs/developer/serialization-internals.md index b0b0dbe..5109525 100644 --- a/docs/developer/serialization-internals.md +++ b/docs/developer/serialization-internals.md @@ -13,17 +13,26 @@ source and keep their intra-package imports, since `import qprogram` from inside the package would close an import cycle. Anything written against the installed package uses `import qprogram as qp`. -The format version is one constant, shared by both directions: +The format version is one constant, shared by both directions, and it follows +the library version truncated to `major.minor`: ```python # src/qprogram/serialization/_format.py -FORMAT_VERSION: Final[str] = "1.0" +FORMAT_VERSION: Final[str] = library_major_minor() ``` -It is emitted in the `#!QProgram` header and checked on load. Only the major -component is binding: a file whose major differs is rejected with +`library_major_minor` lives in `src/qprogram/_version.py` and reads the +installed distribution version through `importlib.metadata`, not +`qprogram.__version__`, so `_format` stays a leaf importing one stdlib-only +module and nothing else in the package. A source tree with no installed +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 -`1.4` file opens under a `1.0` runtime. +`0.4` file opens under a `0.2` runtime. ## The registries @@ -242,7 +251,7 @@ Put together, a program with metadata, a schema, a fragment, an `average`, and a sweep writes as: ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "ordering demo" @@ -483,7 +492,7 @@ For a sweep whose values are large or live outside the program, use the file source instead. The path, not the data, is what the `.qp` file carries: ``` -#!QProgram 1.0 +#!QProgram 0.2 body: var amp diff --git a/docs/developer/vendor-extensions.md b/docs/developer/vendor-extensions.md index afb9ae8..e2865df 100644 --- a/docs/developer/vendor-extensions.md +++ b/docs/developer/vendor-extensions.md @@ -588,7 +588,7 @@ in the program but no version is registered.` A complete file for a two-operation program looks like this: ``` -#!QProgram 1.0 +#!QProgram 0.2 require fake_inst 0.1 diff --git a/docs/examples/active-reset.md b/docs/examples/active-reset.md index 5a98198..d55600c 100644 --- a/docs/examples/active-reset.md +++ b/docs/examples/active-reset.md @@ -88,7 +88,7 @@ from. ## What it produces ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "active_reset" diff --git a/docs/examples/cpmg-fragments.md b/docs/examples/cpmg-fragments.md index 04e9e55..1f78df2 100644 --- a/docs/examples/cpmg-fragments.md +++ b/docs/examples/cpmg-fragments.md @@ -78,7 +78,7 @@ free to run them at the same time. ## What it produces ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "cpmg" diff --git a/docs/examples/cz-chevron.md b/docs/examples/cz-chevron.md index 6e98cb3..55ede2e 100644 --- a/docs/examples/cz-chevron.md +++ b/docs/examples/cz-chevron.md @@ -90,7 +90,7 @@ per-bus counter, so both come out as `m0` under different bus prefixes: ## What it looks like on disk ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "cz_chevron" diff --git a/docs/examples/multiplexed-readout.md b/docs/examples/multiplexed-readout.md index 05213c1..7b2a818 100644 --- a/docs/examples/multiplexed-readout.md +++ b/docs/examples/multiplexed-readout.md @@ -74,7 +74,7 @@ number. Four measurements on four buses are all `m0`; three on one bus would be ## What it produces ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "multiplexed_rabi" @@ -187,7 +187,7 @@ library.save("chip.wfl") ``` ``` -#!WaveformLibrary 1.0 +#!WaveformLibrary 0.2 "readout" q[0].readout = IQPair(I=Square(amplitude=0.9, duration=1000), Q=Square(amplitude=0.0, duration=1000)) "readout" q[2].readout = IQPair(I=Square(amplitude=0.7, duration=3000), Q=Square(amplitude=0.0, duration=3000)) "readout" q[*].readout = IQPair(I=Square(amplitude=0.5, duration=2000), Q=Square(amplitude=0.0, duration=2000)) diff --git a/docs/examples/qubit-spectroscopy.md b/docs/examples/qubit-spectroscopy.md index 73a99af..21a2d06 100644 --- a/docs/examples/qubit-spectroscopy.md +++ b/docs/examples/qubit-spectroscopy.md @@ -82,7 +82,7 @@ Both are the same decisions the [Rabi example](rabi.md) explains at length. ## What it produces ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "qubit_spectroscopy" diff --git a/docs/examples/rabi.md b/docs/examples/rabi.md index a94715b..726dbc4 100644 --- a/docs/examples/rabi.md +++ b/docs/examples/rabi.md @@ -78,7 +78,7 @@ after a `.qp` round-trip still finds the right record. `qp.dumps(program)` returns this, and it is the whole file: ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "rabi" diff --git a/docs/examples/resonator-spectroscopy.md b/docs/examples/resonator-spectroscopy.md index 94de032..bfe0904 100644 --- a/docs/examples/resonator-spectroscopy.md +++ b/docs/examples/resonator-spectroscopy.md @@ -61,7 +61,7 @@ coarse enough to find the dip in one pass. The fine scan comes after. ## What the platform makes of it ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "resonator_spectroscopy" diff --git a/docs/examples/single-shot-readout.md b/docs/examples/single-shot-readout.md index a1ff848..3fe9242 100644 --- a/docs/examples/single-shot-readout.md +++ b/docs/examples/single-shot-readout.md @@ -76,7 +76,7 @@ transposes the result from `(2, 2000)` to `(2000, 2)` rather than being free. ## What it produces ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "single_shot_readout" diff --git a/docs/examples/t1-and-ramsey.md b/docs/examples/t1-and-ramsey.md index cdb31f6..54285f4 100644 --- a/docs/examples/t1-and-ramsey.md +++ b/docs/examples/t1-and-ramsey.md @@ -76,7 +76,7 @@ times so that the tail is flat enough to fit a baseline against. ## What it produces ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "t1" diff --git a/docs/getting-started.md b/docs/getting-started.md index f6ae2fd..d65ef46 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -89,7 +89,7 @@ that is how you address the measurement's data after a run. No platform is involved yet. The output is the program in `.qp` form: ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "rabi" diff --git a/docs/guide/control-flow.md b/docs/guide/control-flow.md index 8341f01..531e051 100644 --- a/docs/guide/control-flow.md +++ b/docs/guide/control-flow.md @@ -573,7 +573,7 @@ print(qp.dumps(program)) ``` ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "control-flow-forms" diff --git a/docs/guide/fragments.md b/docs/guide/fragments.md index 86b5648..9d64cca 100644 --- a/docs/guide/fragments.md +++ b/docs/guide/fragments.md @@ -202,7 +202,7 @@ Fragment definitions are top-level sections before `body:`, and a call site is a bare `name(args)` statement. `qp.dumps(p)` on the program above gives: ``` -#!QProgram 1.0 +#!QProgram 0.2 fragment x_pulse(drive, amp): play drive Gaussian(amplitude=amp, duration=40, sigma=8) @@ -283,7 +283,7 @@ accepts, is in [the `.qp` format reference](../reference/qp-format.md#fragments) untouched. On the program above: ``` -#!QProgram 1.0 +#!QProgram 0.2 body: var g diff --git a/docs/guide/measurements.md b/docs/guide/measurements.md index 5c32999..cef21b9 100644 --- a/docs/guide/measurements.md +++ b/docs/guide/measurements.md @@ -201,7 +201,7 @@ The writer always emits the measurement name as a `name=` keyword, so nothing is inferred on the way back in: ``` -#!QProgram 1.0 +#!QProgram 0.2 schema: element q: diff --git a/docs/guide/serialization.md b/docs/guide/serialization.md index 28fc4a6..ce4179b 100644 --- a/docs/guide/serialization.md +++ b/docs/guide/serialization.md @@ -53,7 +53,7 @@ This is `qp.dumps` output for a T1 experiment built on `qp.BusSchema.transmon()`, with an averaging block around a delay sweep: ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "t1" @@ -167,25 +167,28 @@ the handle is allocated before the path is promoted to a `BusRef`. Every file opens with the format version, which the writer takes from `FORMAT_VERSION` in `qprogram/serialization/_format.py`, the single constant -both sides read: +both sides read. It is the installed library version truncated to +`major.minor`, so a `qprogram` 0.2.1 writes: ``` -#!QProgram 1.0 +#!QProgram 0.2 ``` Only the major component is binding. The parser checks the header before -anything else and rejects a different major, so `#!QProgram 2.0` fails with -`Line 1: Unsupported format version 2.0` while `#!QProgram 1.7` loads on +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. +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. A program that uses vendor operations or vendor blocks carries one `require` line per vendor, directly after the header: ``` -#!QProgram 1.0 +#!QProgram 0.2 require myvendor 0.1 @@ -464,7 +467,7 @@ library = qp.WaveformLibrary.loads(text) exactly. This is the library built above: ``` -#!WaveformLibrary 1.0 +#!WaveformLibrary 0.2 "pi_pulse" q[0].drive = IQDrag(amplitude=0.5, duration=40, sigma=8, beta=0.1) "pi_pulse" q[1].drive = IQDrag(amplitude=0.9, duration=40, sigma=8, beta=0.1) "cz" c[0,1].flux = Square(amplitude=0.3, duration=200) diff --git a/docs/guide/variables.md b/docs/guide/variables.md index 78a62b5..83f9a9d 100644 --- a/docs/guide/variables.md +++ b/docs/guide/variables.md @@ -107,7 +107,7 @@ A program declaring one annotated variable and one bare one serializes like this, and the file round-trips back to a program equal to the original: ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "ramsey" diff --git a/docs/guide/waveforms.md b/docs/guide/waveforms.md index e02b139..a28ab17 100644 --- a/docs/guide/waveforms.md +++ b/docs/guide/waveforms.md @@ -419,7 +419,7 @@ arrays are written in full, because the parser has no way to recover dropped samples. ``` -#!QProgram 1.0 +#!QProgram 0.2 body: play "drive_q0" IQDrag(amplitude=0.5, duration=40, sigma=8, beta=0.1) diff --git a/docs/index.md b/docs/index.md index 5e0caeb..904dd5c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -167,13 +167,13 @@ pattern. ## Versions and compatibility The package is pre-1.0, so the Python API can change between releases without a -deprecation cycle. The `.qp` format carries its own version and is at `1.0`, -where only the major component is binding: the writer emits `#!QProgram 1.0`, a -`1.1` file still loads on this parser, and a `2.0` file raises `ParseError` with -`Unsupported format version 2.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`. +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`. 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 diff --git a/docs/reference/errors.md b/docs/reference/errors.md index af01339..dcd32f7 100644 --- a/docs/reference/errors.md +++ b/docs/reference/errors.md @@ -288,11 +288,11 @@ information: ```python import qprogram as qp -qp.loads("#!QProgram 1.0\n\nbody:\n var 1x\n") +qp.loads("#!QProgram 0.2\n\nbody:\n var 1x\n") # ParseError: Line 4: variable id '1x' is invalid: must match # [A-Za-z_][A-Za-z0-9_]* (no spaces or special characters) -qp.loads("#!QProgram 1.0\n\nbody:\n var if\n") +qp.loads("#!QProgram 0.2\n\nbody:\n var if\n") # InvalidVariableIdError: Variable id 'if' is reserved for future QProgram # syntax ... ``` @@ -305,7 +305,7 @@ class's own `TypeError`: ```python import qprogram as qp -qp.loads('#!QProgram 1.0\n\nbody:\n play "b" Gaussian(amplitude=0.5)\n') +qp.loads('#!QProgram 0.2\n\nbody:\n play "b" Gaussian(amplitude=0.5)\n') # TypeError: Gaussian.__init__() missing 2 required positional arguments: # 'duration' and 'sigma' ``` @@ -332,17 +332,17 @@ almost identical mistakes read differently: ```python import qprogram as qp -qp.loads('#!QProgram 1.0\n\nbody:\n play "b" Bogus(amplitude=0.5)\n') +qp.loads('#!QProgram 0.2\n\nbody:\n play "b" Bogus(amplitude=0.5)\n') # ParseError: Unknown waveform or sweep source type: Bogus # ... with line_num == 0, even though the offending line is line 4 -qp.loads("#!QProgram 1.0\n\nbody:\n var x\n for x in Bogus(start=1):\n sync\n") +qp.loads("#!QProgram 0.2\n\nbody:\n var x\n for x in Bogus(start=1):\n sync\n") # ParseError: Line 5: unknown sweep source 'Bogus'; registered sources are # ['Concat', 'File', 'Linspace', 'Logspace', 'Range', 'Repeat', 'Rotate', # 'Values'] # ... with line_num == 5 -qp.loads('#!QProgram 1.0\n\nbody:\n var x\n set_phase "b" ("a" + x)\n') +qp.loads('#!QProgram 0.2\n\nbody:\n var x\n set_phase "b" ("a" + x)\n') # ParseError: cannot use 'a' (_QuotedStr) as an expression operand # ... with line_num == 0 ``` diff --git a/docs/reference/index.md b/docs/reference/index.md index a05d7d4..a013260 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -8,15 +8,16 @@ source wins, and the page is a bug. | Page | What it fixes | |---|---| -| [.qp file format](qp-format.md) | Every production of format version 1.0: sections, the eleven core operation keywords, inline waveform constructors, sweep sources, expressions, the grammar summary, and what the parser rejects with which message; then the `.wfl` waveform library format, which is the other file the package reads. | +| [.qp file format](qp-format.md) | Every production of format version 0.2: sections, the eleven core operation keywords, inline waveform constructors, sweep sources, expressions, the grammar summary, and what the parser rejects with which message; then the `.wfl` waveform library format, which is the other file the package reads. | | [Reserved keywords](reserved.md) | The 29 names in `qp.RESERVED_KEYWORDS`, which construction sites check them, and the wider rule that applies to vendor namespaces. | | [Errors](errors.md) | The `QProgramError` hierarchy, which call raises which, and the two families of argument error that stay outside it as a plain `TypeError`. | | [API reference](api-qprogram.md) | Signatures and docstrings for the names in `qprogram.__all__`, plus the submodule classes and extension points the guides name, rendered from `src/` by mkdocstrings. | Three of the four are checkable against the package at runtime. The format version comes from `qprogram.serialization._format.FORMAT_VERSION`, currently -`"1.0"`, and is what the writer emits in the `#!QProgram` header and what the -parser compares a file's major version against. The canonical grammar ships as +`"0.2"` since it follows the library version truncated to `major.minor`, and is +what the writer emits in the `#!QProgram` header and what the parser compares a +file's major version against. The canonical grammar ships as `src/qprogram/grammar/qp.lark` and is readable with `qprogram.grammar.grammar_text()`. The reserved set is `qp.RESERVED_KEYWORDS`. The API page is generated from one mkdocstrings directive per symbol, so a new diff --git a/docs/reference/qp-format.md b/docs/reference/qp-format.md index f6d627a..00b9835 100644 --- a/docs/reference/qp-format.md +++ b/docs/reference/qp-format.md @@ -20,7 +20,7 @@ A file opens with the header, then carries up to four kinds of declaration ahead of the body: ``` -#!QProgram 1.0 +#!QProgram 0.2 require # zero or more metadata: # optional @@ -64,13 +64,13 @@ 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 `"1.0"`, and a file -loads when its major matches, whatever its minor: `#!QProgram 1.7` parses under +`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: ``` -Line 1: Unsupported format version 2.0 +Line 1: Unsupported format version 1.0 Line 1: Unsupported format version unknown ``` @@ -149,7 +149,7 @@ The scan for the comment marker is quote-aware, and honors `\"` inside a string, so `play "drive#0" "pi"` keeps its bus name and `measure "ro" "w" "wt" name="a\"#b"` keeps the measurement name `a"#b`. The header line is the one place `#` never starts a comment: it is taken whole, so -`#!QProgram 1.0 # note` fails with `Line 1: Unsupported format version note` +`#!QProgram 0.2 # note` fails with `Line 1: Unsupported format version note` rather than parsing as a header with a trailing comment. ## Indentation @@ -165,7 +165,7 @@ less than two columns past its header binds to the enclosing block instead, and nothing warns: ``` -#!QProgram 1.0 +#!QProgram 0.2 body: average 10: @@ -175,7 +175,7 @@ body: reloads and rewrites as ``` -#!QProgram 1.0 +#!QProgram 0.2 body: average 10: @@ -414,7 +414,7 @@ writes its target variable after the `->`. One line of each, as the writer emits them: ``` -#!QProgram 1.0 +#!QProgram 0.2 body: var d_lo label="d.lo" @@ -881,7 +881,7 @@ The writer's output for a program with metadata, a schema, a fragment, an averaged sweep, a conditional, and a two-index bus path: ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "rabi" @@ -918,7 +918,7 @@ the property `tests/test_round_trip.py` and the hypothesis strategies in ## Two-qubit CZ chevron ``` -#!QProgram 1.0 +#!QProgram 0.2 metadata: label: "cz_chevron" @@ -1092,11 +1092,15 @@ package does not close the parser-to-program import cycle. ## Versioning -The header version (`#!QProgram 1.0`) is the format version. New minor +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. +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. Vendor protocol versions (`require myvendor 0.1`) are independent: they describe the vendor's operation set, not the file format. The vendor extension @@ -1117,7 +1121,7 @@ it through its own `dumps`, `loads`, `save`, and `load`, described under A document is a header line and one entry per line: ``` -#!WaveformLibrary 1.0 +#!WaveformLibrary 0.2 "pi_pulse" q[0].drive = IQDrag(amplitude=0.5, duration=40, sigma=8, beta=0.1) "pi_pulse" q[1].drive = IQDrag(amplitude=0.9, duration=40, sigma=8, beta=0.1) "cz" c[0,1].flux = Square(amplitude=0.3, duration=200) @@ -1184,15 +1188,18 @@ back with no line number at all: the library parser wraps that lookup failure in a `ParseError` that carries one. The header version comes from `WAVEFORM_LIBRARY_FORMAT_VERSION` in -`qprogram/waveform_library.py` and is independent of the `.qp` -`FORMAT_VERSION`; the two formats version separately, and a `.wfl` version says -nothing about which `.qp` version it accompanies. Only the major component is -compared, so `#!WaveformLibrary 1`, `1.0.3`, and `1.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 `1.7` file on a `1.0` reader writes `1.0` and -drops the claim to have come from a newer minor. +`qprogram/waveform_library.py`, which follows the library version truncated to +`major.minor` exactly as the `.qp` `FORMAT_VERSION` does — both call +`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. diff --git a/pyproject.toml b/pyproject.toml index b171774..5e6a20c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "qprogram" -version = "0.1.0" +version = "0.2.0" description = "A hardware-agnostic domain-specific language for pulse-level quantum programming." authors = [{name = "Qilimanjaro Quantum Tech", email = "info@qilimanjaro.tech"}] readme = "README.md" diff --git a/src/qprogram/_version.py b/src/qprogram/_version.py new file mode 100644 index 0000000..09878cf --- /dev/null +++ b/src/qprogram/_version.py @@ -0,0 +1,40 @@ +# 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. +"""The library version, truncated for the headers the file formats carry. + +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. +""" + +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, version + + +def library_major_minor() -> str: + """Truncate the installed library version to its ``major.minor`` components. + + Returns: + The first two components of the distribution version, or ``"0.0"`` when the package has + no installed metadata to read. + """ + try: + release = version("qprogram") + except PackageNotFoundError: # pragma: no cover - source tree without installed metadata + return "0.0" + major, _, rest = release.partition(".") + minor = rest.partition(".")[0] + return f"{major}.{minor or '0'}" diff --git a/src/qprogram/grammar/qp.lark b/src/qprogram/grammar/qp.lark index dcd5650..e25f254 100644 --- a/src/qprogram/grammar/qp.lark +++ b/src/qprogram/grammar/qp.lark @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. // -// Canonical machine-readable grammar for the .qp file format (format version 1.x). +// Canonical machine-readable grammar for the .qp file format, whose version follows the +// library's, truncated to major.minor. The HEADER terminal admits any major.minor: which +// ones load is the parser's compatibility check, not the grammar's. // // ROLE — this file is the *normative grammar* and a CI artifact, not the production parser. // The shipping parser stays the hand-written recursive-descent one in diff --git a/src/qprogram/serialization/_format.py b/src/qprogram/serialization/_format.py index 929d242..0c19e51 100644 --- a/src/qprogram/serialization/_format.py +++ b/src/qprogram/serialization/_format.py @@ -14,17 +14,20 @@ """Shared ``.qp`` format constants. The single source of truth for the format version emitted by the writer and accepted by the -parser. Lives in its own leaf module (no qprogram imports) so both sides can import it without -touching the writer↔parser import cycle. +parser. Lives in its own leaf module, importing only `qprogram._version`, which is itself +stdlib-only, so both sides can import it without touching the writer↔parser import cycle. """ from __future__ import annotations from typing import Final -FORMAT_VERSION: Final[str] = "1.0" +from qprogram._version import library_major_minor + +FORMAT_VERSION: Final[str] = library_major_minor() """``major.minor`` version emitted in the ``#!QProgram`` header and accepted by the parser. -Compatibility contract: the parser rejects files whose +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. """ diff --git a/src/qprogram/serialization/parser.py b/src/qprogram/serialization/parser.py index a844075..afb8494 100644 --- a/src/qprogram/serialization/parser.py +++ b/src/qprogram/serialization/parser.py @@ -1641,7 +1641,7 @@ def _find_comment(line: str) -> int: ``#`` following an escaped quote is still recognized as string content rather than a comment. A line whose first two characters are ``#!`` is the format header, and no ``#`` on it starts a - comment: the header is taken whole, so ``#!QProgram 1.0 # note`` keeps its trailing text and + comment: the header is taken whole, so ``#!QProgram 0.2 # note`` keeps its trailing text and fails version parsing. Args: diff --git a/src/qprogram/waveform_library.py b/src/qprogram/waveform_library.py index d801cf2..cafb349 100644 --- a/src/qprogram/waveform_library.py +++ b/src/qprogram/waveform_library.py @@ -37,6 +37,7 @@ from pathlib import Path from typing import TYPE_CHECKING, cast +from qprogram._version import library_major_minor from qprogram.buses import BusRef from qprogram.errors import ValidationError @@ -49,8 +50,9 @@ # (element | None, idx | None, kind | None, name) — None in a slot marks a less-specific tier. _LibraryKey = tuple["str | None", "int | tuple[int, ...] | None", "str | None", str] -# Version of the ``.wfl`` text format (independent of the ``.qp`` FORMAT_VERSION). -WAVEFORM_LIBRARY_FORMAT_VERSION = "1.0" +# 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. +WAVEFORM_LIBRARY_FORMAT_VERSION = library_major_minor() # Entry coordinate: ``element[idx].kind`` (exact) or ``element[*].kind`` (family). idx may be a tuple # (couplers): ``c[0,1].flux``. diff --git a/tests/_header.py b/tests/_header.py new file mode 100644 index 0000000..da86701 --- /dev/null +++ b/tests/_header.py @@ -0,0 +1,27 @@ +# 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. +"""The header lines the suite writes into its ``.qp`` and ``.wfl`` fixtures. + +Both are built from the running format version, which follows the library version, so the +fixtures stay loadable across a release rather than pinning the version a test was written +under. The tests that are about the version itself spell one out. +""" + +from __future__ import annotations + +from qprogram.serialization._format import FORMAT_VERSION +from qprogram.waveform_library import WAVEFORM_LIBRARY_FORMAT_VERSION + +HEADER = f"#!QProgram {FORMAT_VERSION}" +WFL_HEADER = f"#!WaveformLibrary {WAVEFORM_LIBRARY_FORMAT_VERSION}" diff --git a/tests/test_coverage_gaps.py b/tests/test_coverage_gaps.py index c3d34ef..2c0677f 100644 --- a/tests/test_coverage_gaps.py +++ b/tests/test_coverage_gaps.py @@ -24,6 +24,7 @@ import numpy as np import pytest +from _header import HEADER import qprogram as qp from qprogram import BusSchema, ParseError, Variable, serialization @@ -170,7 +171,7 @@ def test_default_parse_operation_skips_empty_tokens(): spec = get_operation_spec(None, "reset_phase") assert spec is not None - parser = _Parser("#!QProgram 1.0\nbody:\n") + parser = _Parser(HEADER + "\nbody:\n") parser._parse_header() op = default_parse_operation(spec, ['"bus"', " ", ""], parser) assert getattr(op, "bus") == "bus" # ruff: ignore[get-attr-with-constant] @@ -206,7 +207,7 @@ def test_resolve_bus_path_returns_none_for_non_path(): """Token that doesn't match the bus-path regex returns None (not a parse error).""" schema = BusSchema.transmon() - parser = _Parser("#!QProgram 1.0\nbody:\n") + parser = _Parser(HEADER + "\nbody:\n") parser._parse_header() parser._program._schema = schema assert parser._resolve_bus_path("not_a_path") is None @@ -214,7 +215,7 @@ def test_resolve_bus_path_returns_none_for_non_path(): def test_resolve_bus_path_no_schema_returns_none(): - parser = _Parser("#!QProgram 1.0\nbody:\n") + parser = _Parser(HEADER + "\nbody:\n") parser._parse_header() # program.schema is None — no resolution. assert parser._resolve_bus_path("q[0].drive") is None @@ -327,7 +328,7 @@ def test_typed_element_factory_base_getitem_via_subclass(): def test_parser_blank_line_inside_block(): """An indented blank line within a block body is skipped, not parsed.""" text = ( - "#!QProgram 1.0\n\n" + HEADER + "\n\n" "body:\n" " average 100:\n" "\n" # blank line at deeper indent — should be ignored @@ -343,7 +344,7 @@ def test_parser_non_block_non_var_line_falls_to_operation(): An unregistered operation name is a hard ``ParseError`` there, never a silent skip. """ - text = '#!QProgram 1.0\n\nbody:\n unknown_op "bus"\n' + text = HEADER + '\n\nbody:\n unknown_op "bus"\n' with pytest.raises(ParseError, match="unknown operation 'unknown_op'"): qp.loads(text) @@ -351,7 +352,7 @@ def test_parser_non_block_non_var_line_falls_to_operation(): def test_parse_operation_empty_line_raises(): """The empty-tokens branch of ``_parse_operation`` raises.""" - parser = _Parser("#!QProgram 1.0\nbody:\n") + parser = _Parser(HEADER + "\nbody:\n") parser._parse_header() with pytest.raises(ParseError, match="empty operation line"): parser._parse_operation("") @@ -360,7 +361,7 @@ def test_parse_operation_empty_line_raises(): def test_parse_value_returns_bare_identifier_as_string(): """An unknown bare identifier — not a variable, not a number — returns the string.""" - parser = _Parser("#!QProgram 1.0\nbody:\n") + parser = _Parser(HEADER + "\nbody:\n") parser._parse_header() assert parser.parse_value("not_a_var") == "not_a_var" @@ -368,7 +369,7 @@ def test_parse_value_returns_bare_identifier_as_string(): def test_parse_value_finds_declared_variable(): """A declared variable is resolved to the Variable object.""" - parser = _Parser("#!QProgram 1.0\nbody:\n") + parser = _Parser(HEADER + "\nbody:\n") parser._parse_header() v = parser.get_or_declare_variable("x") assert parser.parse_value("x") is v @@ -376,7 +377,7 @@ def test_parse_value_finds_declared_variable(): def test_parse_value_number(): - parser = _Parser("#!QProgram 1.0\nbody:\n") + parser = _Parser(HEADER + "\nbody:\n") parser._parse_header() assert parser.parse_value("42") == 42 @@ -420,7 +421,7 @@ def test_parallel_rejects_fewer_than_two_loops(): def test_parse_var_decl_with_only_var_token_raises(): """``_parse_var_decl`` called directly with a one-token line raises.""" - parser = _Parser("#!QProgram 1.0\nbody:\n") + parser = _Parser(HEADER + "\nbody:\n") parser._parse_header() with pytest.raises(qp.ParseError): parser._parse_var_decl("var") @@ -432,7 +433,7 @@ def test_parse_var_decl_with_only_var_token_raises(): def test_parser_top_level_blank_lines_skipped(): - text = "#!QProgram 1.0\n\n\n\nbody:\n var freq\n\n\n" + text = HEADER + "\n\n\n\nbody:\n var freq\n\n\n" p = qp.loads(text) assert p.variables[0].id == "freq" @@ -443,7 +444,7 @@ def test_parser_top_level_unknown_line_raises(): A mistyped section header (``bodyy:``) must not silently produce an empty program. """ text = ( - "#!QProgram 1.0\n\n" + HEADER + "\n\n" "some_unknown_section: stuff\n" # not a known section header "\n" "body:\n" @@ -455,7 +456,7 @@ def test_parser_top_level_unknown_line_raises(): def test_parser_require_after_section_raises(): """A ``require`` line after a section is a hard error with a placement hint.""" - text = '#!QProgram 1.0\n\nmetadata:\n label: "x"\n\nrequire dummy 0.0\n\nbody:\n' + text = HEADER + '\n\nmetadata:\n label: "x"\n\nrequire dummy 0.0\n\nbody:\n' with pytest.raises(ParseError, match="before any section"): qp.loads(text) @@ -463,7 +464,7 @@ def test_parser_require_after_section_raises(): def test_parser_indent_past_end_returns_zero(): """``_indent()`` returns 0 when pos is past the end of the file.""" - parser = _Parser("#!QProgram 1.0\nbody:\n") + parser = _Parser(HEADER + "\nbody:\n") parser._parse_header() parser._pos = 999 assert parser._indent() == 0 @@ -471,7 +472,7 @@ def test_parser_indent_past_end_returns_zero(): def test_parser_stripped_past_end_returns_empty(): - parser = _Parser("#!QProgram 1.0\nbody:\n") + parser = _Parser(HEADER + "\nbody:\n") parser._pos = 999 assert parser._stripped() == "" @@ -482,7 +483,7 @@ def test_parser_require_malformed_version_raises(dummy_vendor): # ruff: ignore[ original = registry._vendor_versions.get("dummy") registry._vendor_versions["dummy"] = "not-a-version" try: - text = "#!QProgram 1.0\n\nrequire dummy 0.1\n\nbody:\n" + text = HEADER + "\n\nrequire dummy 0.1\n\nbody:\n" with pytest.raises(qp.ParseError): qp.loads(text) finally: @@ -493,7 +494,7 @@ def test_parser_require_malformed_version_raises(dummy_vendor): # ruff: ignore[ def test_parser_blank_line_inside_nested_block(): """A blank indented line inside a control-flow block is consumed.""" text = ( - "#!QProgram 1.0\n\n" + HEADER + "\n\n" "body:\n" " var freq\n" " average 100:\n" @@ -511,20 +512,20 @@ def test_parser_blank_line_inside_nested_block(): def test_parser_block_header_without_colon(): """A block keyword without the trailing ``:`` errors with a missing-colon hint.""" - text = "#!QProgram 1.0\n\nbody:\n average 100\n" + text = HEADER + "\n\nbody:\n average 100\n" with pytest.raises(ParseError, match="trailing colon"): qp.loads(text) def test_parser_var_decl_attr_unquoted_value_message(): """The unquoted-value path uses a specific message; check it.""" - text = "#!QProgram 1.0\n\nbody:\n var x label=foo\n" + text = HEADER + "\n\nbody:\n var x label=foo\n" with pytest.raises(qp.ParseError, match="quoted string"): qp.loads(text) def test_parser_blank_lines_before_header(): - text = "\n\n\n#!QProgram 1.0\n\nbody:\n" + text = "\n\n\n" + HEADER + "\n\nbody:\n" p = qp.loads(text) assert p is not None @@ -532,7 +533,7 @@ def test_parser_blank_lines_before_header(): def test_parser_blank_lines_in_inline_schema(): """Inline schema parsing tolerates blank lines between element blocks.""" text = ( - "#!QProgram 1.0\n\n" + HEADER + "\n\n" "schema:\n" " element q:\n" " drive info=IQ\n" @@ -549,7 +550,7 @@ def test_parser_blank_lines_in_inline_schema(): def test_parser_blank_lines_in_element_bus_list(): """The element-bus inline parser tolerates blank lines.""" - text = "#!QProgram 1.0\n\nschema:\n element q:\n drive info=IQ\n\n readout info=IQ+acquires\n\nbody:\n" + text = HEADER + "\n\nschema:\n element q:\n drive info=IQ\n\n readout info=IQ+acquires\n\nbody:\n" p = qp.loads(text) assert set(p.schema.elements["q"].buses.keys()) == {"drive", "readout"} @@ -566,7 +567,7 @@ def test_writer_serialize_math_func_args_recursive(): def test_parser_unknown_operator_in_paren_expression(): """A parenthesized expression with an unknown operator raises with a clear message.""" # Use a custom operator that gets through _tokenize (3 tokens) but doesn't match. - text = '#!QProgram 1.0\n\nbody:\n var x\n set_offset "bus" (x ?? 5)\n' + text = HEADER + '\n\nbody:\n var x\n set_offset "bus" (x ?? 5)\n' with pytest.raises(qp.ParseError, match="unknown operator"): qp.loads(text) @@ -578,6 +579,6 @@ def test_parse_value_with_bus_path_token_returns_string(): which runs once the enclosing operation exists and knows which of its attributes are buses. """ - parser = _Parser("#!QProgram 1.0\nbody:\n") + parser = _Parser(HEADER + "\nbody:\n") parser._parse_header() assert parser.parse_value("q[0].drive") == "q[0].drive" diff --git a/tests/test_fragments_serialization.py b/tests/test_fragments_serialization.py index 3482b81..4784e4d 100644 --- a/tests/test_fragments_serialization.py +++ b/tests/test_fragments_serialization.py @@ -17,6 +17,7 @@ import numpy as np import pytest +from _header import HEADER import qprogram as qp from qprogram import Fragment, QProgram, fragment @@ -100,7 +101,7 @@ def test_fragment_defs_emitted_in_dependency_order(): def test_unused_fragment_definition_round_trips(): - text = '#!QProgram 1.0\n\nfragment unused(bus):\n sync\n\nbody:\n wait "drive" 4\n' + text = HEADER + '\n\nfragment unused(bus):\n sync\n\nbody:\n wait "drive" 4\n' p = qp.loads(text) assert "unused" in p.fragments assert qp.dumps(qp.loads(qp.dumps(p))) == qp.dumps(p) @@ -179,7 +180,7 @@ def f1(f, bus): def test_keyword_arguments_parse(): - text = '#!QProgram 1.0\n\nfragment f1(bus, t):\n wait bus t\n\nbody:\n f1(t=8, bus="drive")\n' + text = HEADER + '\n\nfragment f1(bus, t):\n wait bus t\n\nbody:\n f1(t=8, bus="drive")\n' p = qp.loads(text) call = p.body.elements[0] assert isinstance(call, Call) @@ -188,7 +189,7 @@ def test_keyword_arguments_parse(): def test_expression_and_waveform_arguments_parse(): text = ( - "#!QProgram 1.0\n" + HEADER + "\n" "\n" "fragment f1(wf, t):\n" ' play "drive" wf\n' @@ -221,7 +222,7 @@ def test_fragment_with_vendor_op_emits_require(dummy_vendor): # ruff: ignore[un def test_fragment_measurement_auto_name_allocates_per_fragment(): text = ( - "#!QProgram 1.0\n" + HEADER + "\n" "\n" "fragment ro(bus):\n" ' measure bus "wf" "w"\n' # no name= -> auto-allocated within the fragment @@ -241,67 +242,67 @@ def test_fragment_measurement_auto_name_allocates_per_fragment(): def test_unknown_fragment_call_raises(): - text = '#!QProgram 1.0\n\nbody:\n mystery("drive", 4)\n' + text = HEADER + '\n\nbody:\n mystery("drive", 4)\n' with pytest.raises(ParseError, match="unknown fragment 'mystery'"): qp.loads(text) def test_waveform_constructor_as_statement_raises(): - text = "#!QProgram 1.0\n\nbody:\n Gaussian(amplitude=0.5, duration=40, sigma=8)\n" + text = HEADER + "\n\nbody:\n Gaussian(amplitude=0.5, duration=40, sigma=8)\n" with pytest.raises(ParseError, match="cannot stand alone as a statement"): qp.loads(text) def test_fragment_after_body_raises(): - text = '#!QProgram 1.0\n\nbody:\n wait "d" 4\n\nfragment f1(bus):\n sync\n' + text = HEADER + '\n\nbody:\n wait "d" 4\n\nfragment f1(bus):\n sync\n' with pytest.raises(ParseError, match="before the `body:` section"): qp.loads(text) def test_duplicate_fragment_definition_raises(): - text = "#!QProgram 1.0\n\nfragment f1(a):\n sync\n\nfragment f1(b):\n sync\n\nbody:\n f1(1)\n" + text = HEADER + "\n\nfragment f1(a):\n sync\n\nfragment f1(b):\n sync\n\nbody:\n f1(1)\n" with pytest.raises(ParseError, match="duplicate fragment definition"): qp.loads(text) def test_malformed_fragment_header_raises(): - text = "#!QProgram 1.0\n\nfragment f1 a b:\n sync\n\nbody:\n" + text = HEADER + "\n\nfragment f1 a b:\n sync\n\nbody:\n" with pytest.raises(ParseError, match="invalid fragment header"): qp.loads(text) def test_reserved_fragment_name_raises(): - text = "#!QProgram 1.0\n\nfragment match(a):\n sync\n\nbody:\n" + text = HEADER + "\n\nfragment match(a):\n sync\n\nbody:\n" with pytest.raises(ParseError, match="reserved"): qp.loads(text) def test_invalid_parameter_name_raises(): - text = "#!QProgram 1.0\n\nfragment f1(2bad):\n sync\n\nbody:\n" + text = HEADER + "\n\nfragment f1(2bad):\n sync\n\nbody:\n" with pytest.raises(ParseError, match="invalid fragment parameter"): qp.loads(text) def test_duplicate_parameter_raises(): - text = "#!QProgram 1.0\n\nfragment f1(a, a):\n sync\n\nbody:\n" + text = HEADER + "\n\nfragment f1(a, a):\n sync\n\nbody:\n" with pytest.raises(ParseError, match="already declared"): qp.loads(text) def test_argument_count_mismatch_raises(): - text = "#!QProgram 1.0\n\nfragment f1(a, b):\n wait a b\n\nbody:\n f1(1)\n" + text = HEADER + "\n\nfragment f1(a, b):\n wait a b\n\nbody:\n f1(1)\n" with pytest.raises(ParseError, match="missing argument"): qp.loads(text) def test_unknown_keyword_argument_raises(): - text = "#!QProgram 1.0\n\nfragment f1(a):\n wait a 4\n\nbody:\n f1(a=1, b=2)\n" + text = HEADER + "\n\nfragment f1(a):\n wait a 4\n\nbody:\n f1(a=1, b=2)\n" with pytest.raises(ParseError, match="no parameter 'b'"): qp.loads(text) def test_positional_after_keyword_raises(): - text = "#!QProgram 1.0\n\nfragment f1(a, b):\n wait a b\n\nbody:\n f1(a=1, 2)\n" + text = HEADER + "\n\nfragment f1(a, b):\n wait a b\n\nbody:\n f1(a=1, 2)\n" with pytest.raises(ParseError, match="positional argument after keyword"): qp.loads(text) @@ -309,23 +310,14 @@ def test_positional_after_keyword_raises(): def test_call_to_later_defined_fragment_raises(): """Define-before-use also applies between fragments — gives topological order for free.""" text = ( - "#!QProgram 1.0\n" - "\n" - "fragment outer(bus):\n" - " inner(bus)\n" - "\n" - "fragment inner(bus):\n" - " sync\n" - "\n" - "body:\n" - ' outer("drive")\n' + HEADER + '\n\nfragment outer(bus):\n inner(bus)\n\nfragment inner(bus):\n sync\n\nbody:\n outer("drive")\n' ) with pytest.raises(ParseError, match="unknown fragment 'inner'"): qp.loads(text) def test_fragment_keyword_inside_body_raises(): - text = "#!QProgram 1.0\n\nbody:\n fragment f1(a):\n sync\n" + text = HEADER + "\n\nbody:\n fragment f1(a):\n sync\n" with pytest.raises(ParseError, match="unknown block keyword 'fragment'"): qp.loads(text) @@ -344,7 +336,7 @@ def test_dumps_rejects_bare_fragment(): def test_hand_written_file_matches_python_built_program(): text = ( - "#!QProgram 1.0\n" + HEADER + "\n" "\n" "fragment x_pulse(drive, amp):\n" " play drive Gaussian(amplitude=amp, duration=40, sigma=8)\n" diff --git a/tests/test_grammar.py b/tests/test_grammar.py index fda3489..cca3b38 100644 --- a/tests/test_grammar.py +++ b/tests/test_grammar.py @@ -28,6 +28,7 @@ import lark import numpy as np import pytest +from _header import HEADER from hypothesis import given, settings from test_round_trip import UNICODE_LINE_BREAKS from test_round_trip_property import fragment_programs, programs @@ -164,14 +165,14 @@ def test_vendor_program_is_grammatical(dummy_vendor): # ruff: ignore[unused-fun def test_empty_body_and_header_only_forms(): - assert_grammatical("#!QProgram 1.0\n\nbody:\n") - assert_grammatical("#!QProgram 1.0\nbody:\n") - assert_grammatical("\n\n#!QProgram 1.0\n\nbody:\n") # leading blank lines tolerated + assert_grammatical(HEADER + "\n\nbody:\n") + assert_grammatical(HEADER + "\nbody:\n") + assert_grammatical("\n\n" + HEADER + "\n\nbody:\n") # leading blank lines tolerated def test_comments_anywhere_are_transparent(): text = ( - "#!QProgram 1.0\n" + HEADER + "\n" "# top comment\n" "\n" "body:\n" @@ -191,7 +192,7 @@ def test_comments_anywhere_are_transparent(): def test_hand_written_spacing_variants(): # The parser tolerates a space before the fragment paren; so does the grammar. - assert_grammatical('#!QProgram 1.0\n\nfragment f1 (bus):\n sync\n\nbody:\n f1("drive")\n') + assert_grammatical(HEADER + '\n\nfragment f1 (bus):\n sync\n\nbody:\n f1("drive")\n') @pytest.mark.parametrize("char", UNICODE_LINE_BREAKS) @@ -232,18 +233,18 @@ def test_property_fragment_programs_are_grammatical(p: QProgram) -> None: _SYNTACTIC_REJECTS = { "missing-header": 'body:\n play "d" "p"\n', - "require-without-version": "#!QProgram 1.0\n\nrequire qblox\n\nbody:\n", - "block-missing-colon": '#!QProgram 1.0\n\nbody:\n average 10\n play "d" "p"\n', - "unquoted-metadata-label": "#!QProgram 1.0\n\nmetadata:\n label: rabi experiment\n\nbody:\n", - "var-with-spaces": "#!QProgram 1.0\n\nbody:\n var Wait Duration (ns)\n", - "var-id-starts-digit": "#!QProgram 1.0\n\nbody:\n var 1freq\n", - "unparenthesized-expression": '#!QProgram 1.0\n\nbody:\n wait "d" 100 - t\n', - "unterminated-string": '#!QProgram 1.0\n\nbody:\n play "drive\n', - "for-without-in": "#!QProgram 1.0\n\nbody:\n for g range(0, 1, 0.1):\n sync\n", - "if-without-condition": "#!QProgram 1.0\n\nbody:\n if:\n sync\n", - "else-with-condition": ("#!QProgram 1.0\n\nbody:\n if m0.state == 0:\n sync\n else m0.state:\n sync\n"), - "dangling-dict": '#!QProgram 1.0\n\nbody:\n set_parameter "a" "b" matrix={"a": 1.0\n', - "fragment-missing-parens": "#!QProgram 1.0\n\nfragment f1:\n sync\n\nbody:\n", + "require-without-version": HEADER + "\n\nrequire qblox\n\nbody:\n", + "block-missing-colon": HEADER + '\n\nbody:\n average 10\n play "d" "p"\n', + "unquoted-metadata-label": HEADER + "\n\nmetadata:\n label: rabi experiment\n\nbody:\n", + "var-with-spaces": HEADER + "\n\nbody:\n var Wait Duration (ns)\n", + "var-id-starts-digit": HEADER + "\n\nbody:\n var 1freq\n", + "unparenthesized-expression": HEADER + '\n\nbody:\n wait "d" 100 - t\n', + "unterminated-string": HEADER + '\n\nbody:\n play "drive\n', + "for-without-in": HEADER + "\n\nbody:\n for g range(0, 1, 0.1):\n sync\n", + "if-without-condition": HEADER + "\n\nbody:\n if:\n sync\n", + "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", } diff --git a/tests/test_lsp.py b/tests/test_lsp.py index ca4b147..7b7317d 100644 --- a/tests/test_lsp.py +++ b/tests/test_lsp.py @@ -19,10 +19,12 @@ import subprocess import sys +from _header import HEADER + from qprogram.lsp import FileDiagnostic, check_text, create_server, main _WARNY = ( - "#!QProgram 1.0\n" + HEADER + "\n" "\n" "body:\n" " var v\n" @@ -41,11 +43,11 @@ def test_clean_program_has_no_diagnostics(): - assert check_text('#!QProgram 1.0\n\nbody:\n play "d" "p"\n') == [] + assert check_text(HEADER + '\n\nbody:\n play "d" "p"\n') == [] def test_parse_error_lands_on_its_line(): - text = '#!QProgram 1.0\n\nbody:\n play "d" "p"\n bogus_op "x"\n' + text = HEADER + '\n\nbody:\n play "d" "p"\n bogus_op "x"\n' diagnostics = check_text(text) assert len(diagnostics) == 1 d = diagnostics[0] @@ -81,7 +83,7 @@ def test_whole_file_parse_error_lands_on_line_zero(): def test_validation_error_is_reported(): # Conditional on m0.state while the measurement doesn't request state classification: # parses fine, fails capability validation. - text = '#!QProgram 1.0\n\nbody:\n measure "r" "wf" "w" name="m0" fields=["iq"]\n if m0.state == 0:\n sync\n' + text = HEADER + '\n\nbody:\n measure "r" "wf" "w" name="m0" fields=["iq"]\n if m0.state == 0:\n sync\n' diagnostics = check_text(text) assert any(d.code == "missing-classification" and d.severity == "error" for d in diagnostics) @@ -103,7 +105,7 @@ def _run_cli(*args: str, stdin: str) -> tuple[int, str]: def test_cli_check_outputs_json_and_exit_code(): - code, out = _run_cli("check", "-", stdin='#!QProgram 1.0\n\nbody:\n nope "x"\n') + code, out = _run_cli("check", "-", stdin=HEADER + '\n\nbody:\n nope "x"\n') assert code == 1 # errors -> non-zero payload = json.loads(out) assert payload[0]["code"] == "parse-error" @@ -112,7 +114,7 @@ def test_cli_check_outputs_json_and_exit_code(): def test_cli_check_clean_file_exits_zero(tmp_path): f = tmp_path / "ok.qp" - f.write_text("#!QProgram 1.0\n\nbody:\n sync\n", encoding="utf-8") + f.write_text(HEADER + "\n\nbody:\n sync\n", encoding="utf-8") code, out = _run_cli("check", str(f), stdin="") assert code == 0 assert json.loads(out) == [] @@ -139,7 +141,7 @@ def test_cli_explain_reports_parse_error(): def test_main_callable_directly(tmp_path, capsys): f = tmp_path / "p.qp" - f.write_text("#!QProgram 1.0\n\nbody:\n sync\n", encoding="utf-8") + f.write_text(HEADER + "\n\nbody:\n sync\n", encoding="utf-8") assert main(["check", "--no-validate", str(f)]) == 0 assert json.loads(capsys.readouterr().out) == [] diff --git a/tests/test_parser.py b/tests/test_parser.py index 5d5cdf7..2a94f1a 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -17,6 +17,7 @@ import numpy as np import pytest +from _header import HEADER from qprogram import ( Comparison, @@ -32,6 +33,7 @@ loads, ) from qprogram.buses import BusRef +from qprogram.serialization._format import FORMAT_VERSION from qprogram.serialization.parser import ( _find_comment, _parse_arg, @@ -115,7 +117,7 @@ def test_split_lines(text, expected): @pytest.mark.parametrize("char", ["\n", "\r\n"]) def test_split_lines_agrees_with_splitlines_on_real_terminators(char): """For the terminators the format does have, `_split_lines` is `str.splitlines`.""" - text = char.join(["#!QProgram 1.0", "", "body:", " sync", ""]) + text = char.join([HEADER, "", "body:", " sync", ""]) assert _split_lines(text) == text.splitlines() @@ -282,13 +284,14 @@ def test_loads_unsupported_major_version_raises(): def test_loads_minor_within_major_works(): - # Same major (1) is accepted regardless of minor. - text = "#!QProgram 1.99\n\nbody:\n" + # Any minor is accepted as long as the major is the running one. + major = FORMAT_VERSION.split(".")[0] + text = f"#!QProgram {major}.99\n\nbody:\n" loads(text) def test_loads_empty_program(): - p = loads("#!QProgram 1.0\n\nbody:\n") + p = loads(HEADER + "\n\nbody:\n") assert p.label == "" assert p.variables == [] @@ -299,31 +302,31 @@ def test_loads_empty_program(): def test_loads_with_vendor_require(dummy_vendor): # ruff: ignore[unused-function-argument] - text = "#!QProgram 1.0\n\nrequire dummy 0.0\n\nbody:\n" + text = HEADER + "\n\nrequire dummy 0.0\n\nbody:\n" p = loads(text) assert p is not None def test_loads_unknown_vendor_require_raises(): - text = "#!QProgram 1.0\n\nrequire nonexistent_vendor 1.0\n\nbody:\n" + text = HEADER + "\n\nrequire nonexistent_vendor 1.0\n\nbody:\n" with pytest.raises(ParseError, match="no matching extension"): loads(text) def test_loads_require_malformed_raises(dummy_vendor): # ruff: ignore[unused-function-argument] - text = "#!QProgram 1.0\n\nrequire dummy\n\nbody:\n" + text = HEADER + "\n\nrequire dummy\n\nbody:\n" with pytest.raises(ParseError, match="must specify a version"): loads(text) def test_loads_require_major_mismatch_raises(dummy_vendor): # ruff: ignore[unused-function-argument] - text = "#!QProgram 1.0\n\nrequire dummy 99.0\n\nbody:\n" + text = HEADER + "\n\nrequire dummy 99.0\n\nbody:\n" with pytest.raises(ParseError, match="major versions must match"): loads(text) def test_loads_require_minor_too_old_raises(dummy_vendor): # ruff: ignore[unused-function-argument] - text = "#!QProgram 1.0\n\nrequire dummy 0.99\n\nbody:\n" + text = HEADER + "\n\nrequire dummy 0.99\n\nbody:\n" with pytest.raises(ParseError, match="minor version too old"): loads(text) @@ -334,52 +337,52 @@ def test_loads_require_minor_too_old_raises(dummy_vendor): # ruff: ignore[unuse def test_loads_metadata_label(): - text = '#!QProgram 1.0\n\nmetadata:\n label: "rabi"\n\nbody:\n' + text = HEADER + '\n\nmetadata:\n label: "rabi"\n\nbody:\n' p = loads(text) assert p.label == "rabi" def test_loads_metadata_description(): - text = '#!QProgram 1.0\n\nmetadata:\n label: "x"\n description: "desc"\n\nbody:\n' + text = HEADER + '\n\nmetadata:\n label: "x"\n description: "desc"\n\nbody:\n' p = loads(text) assert p.description == "desc" def test_loads_metadata_unescapes_quotes_and_backslashes(): - text = '#!QProgram 1.0\n\nmetadata:\n label: "say \\"hi\\""\n description: "back\\\\slash"\n\nbody:\n' + text = HEADER + '\n\nmetadata:\n label: "say \\"hi\\""\n description: "back\\\\slash"\n\nbody:\n' p = loads(text) assert p.label == 'say "hi"' assert p.description == "back\\slash" def test_loads_metadata_value_with_colon(): - text = '#!QProgram 1.0\n\nmetadata:\n label: "rabi: trial 2"\n\nbody:\n' + text = HEADER + '\n\nmetadata:\n label: "rabi: trial 2"\n\nbody:\n' p = loads(text) assert p.label == "rabi: trial 2" def test_loads_metadata_hash_inside_string_not_a_comment(): """A ``#`` inside a quoted value — even after an escaped quote — is content.""" - text = '#!QProgram 1.0\n\nmetadata:\n label: "a \\"#1\\""\n\nbody:\n' + text = HEADER + '\n\nmetadata:\n label: "a \\"#1\\""\n\nbody:\n' p = loads(text) assert p.label == 'a "#1"' def test_loads_metadata_unquoted_label_raises(): - text = "#!QProgram 1.0\n\nmetadata:\n label: rabi\n\nbody:\n" + text = HEADER + "\n\nmetadata:\n label: rabi\n\nbody:\n" with pytest.raises(ParseError, match="must be a quoted string"): loads(text) def test_loads_metadata_invalid_line_raises(): - text = "#!QProgram 1.0\n\nmetadata:\n garbage\n\nbody:\n" + text = HEADER + "\n\nmetadata:\n garbage\n\nbody:\n" with pytest.raises(ParseError, match="invalid metadata line"): loads(text) def test_loads_metadata_unknown_key_tolerated(): """Unknown metadata keys are forward-compatible — ignored, not an error.""" - text = '#!QProgram 1.0\n\nmetadata:\n label: "x"\n author: "someone"\n\nbody:\n' + text = HEADER + '\n\nmetadata:\n label: "x"\n author: "someone"\n\nbody:\n' p = loads(text) assert p.label == "x" @@ -390,98 +393,86 @@ def test_loads_metadata_unknown_key_tolerated(): def test_loads_inline_schema(): - text = "#!QProgram 1.0\n\nschema:\n element q:\n drive info=IQ\n readout info=IQ+acquires\n\nbody:\n" + text = HEADER + "\n\nschema:\n element q:\n drive info=IQ\n readout info=IQ+acquires\n\nbody:\n" p = loads(text) assert p.schema is not None assert "q" in p.schema.elements def test_loads_inline_schema_with_naming(): - text = ( - '#!QProgram 1.0\n\nschema:\n naming: "{kind}_{element}{index}_bus"\n element q:\n drive info=IQ\n\nbody:\n' - ) + text = HEADER + '\n\nschema:\n naming: "{kind}_{element}{index}_bus"\n element q:\n drive info=IQ\n\nbody:\n' p = loads(text) assert p.schema.naming.pattern == "{kind}_{element}{index}_bus" def test_loads_rejects_bare_preset_keyword_schema(): - text = "#!QProgram 1.0\n\nschema: transmon\n\nbody:\n" + text = HEADER + "\n\nschema: transmon\n\nbody:\n" with pytest.raises(ParseError, match="invalid schema declaration"): loads(text) def test_loads_rejects_duplicate_schema(): - text = ( - "#!QProgram 1.0\n\n" - "schema:\n" - " element q:\n" - " drive info=IQ\n" - "schema:\n" - " element r:\n" - " drive info=IQ\n" - "\n" - "body:\n" - ) + text = HEADER + "\n\nschema:\n element q:\n drive info=IQ\nschema:\n element r:\n drive info=IQ\n\nbody:\n" with pytest.raises(ParseError, match="duplicate schema"): loads(text) def test_loads_rejects_empty_schema(): - text = "#!QProgram 1.0\n\nschema:\nbody:\n" + text = HEADER + "\n\nschema:\nbody:\n" with pytest.raises(ParseError, match="no element declarations"): loads(text) def test_loads_rejects_invalid_naming_unquoted(): - text = "#!QProgram 1.0\n\nschema:\n naming: foo\n element q:\n drive info=IQ\nbody:\n" + text = HEADER + "\n\nschema:\n naming: foo\n element q:\n drive info=IQ\nbody:\n" with pytest.raises(ParseError, match="quoted string"): loads(text) def test_loads_rejects_unexpected_schema_line(): - text = "#!QProgram 1.0\n\nschema:\n garbage\nbody:\n" + text = HEADER + "\n\nschema:\n garbage\nbody:\n" with pytest.raises(ParseError, match="unexpected line in schema"): loads(text) def test_loads_rejects_bus_info_empty(): - text = "#!QProgram 1.0\n\nschema:\n element q:\n drive info=\nbody:\n" + text = HEADER + "\n\nschema:\n element q:\n drive info=\nbody:\n" with pytest.raises(ParseError): loads(text) def test_loads_rejects_bus_info_unknown_token(): - text = "#!QProgram 1.0\n\nschema:\n element q:\n drive info=banana\nbody:\n" + text = HEADER + "\n\nschema:\n element q:\n drive info=banana\nbody:\n" with pytest.raises(ParseError, match="unknown token"): loads(text) def test_loads_rejects_bus_info_multiple_channels(): - text = "#!QProgram 1.0\n\nschema:\n element q:\n drive info=IQ+single\nbody:\n" + text = HEADER + "\n\nschema:\n element q:\n drive info=IQ+single\nbody:\n" with pytest.raises(ParseError, match="multiple channel tokens"): loads(text) def test_loads_rejects_bus_info_duplicate_flag(): - text = "#!QProgram 1.0\n\nschema:\n element q:\n drive info=IQ+acquires+acquires\nbody:\n" + text = HEADER + "\n\nschema:\n element q:\n drive info=IQ+acquires+acquires\nbody:\n" with pytest.raises(ParseError, match="duplicate flag"): loads(text) def test_loads_rejects_bus_info_no_channel(): - text = "#!QProgram 1.0\n\nschema:\n element q:\n drive info=acquires\nbody:\n" + text = HEADER + "\n\nschema:\n element q:\n drive info=acquires\nbody:\n" with pytest.raises(ParseError, match="must specify a channel"): loads(text) def test_loads_rejects_duplicate_bus_kind(): - text = "#!QProgram 1.0\n\nschema:\n element q:\n drive info=IQ\n drive info=single\nbody:\n" + text = HEADER + "\n\nschema:\n element q:\n drive info=IQ\n drive info=single\nbody:\n" with pytest.raises(ParseError, match="duplicate bus"): loads(text) def test_loads_rejects_invalid_bus_line(): - text = "#!QProgram 1.0\n\nschema:\n element q:\n not a bus line\nbody:\n" + text = HEADER + "\n\nschema:\n element q:\n not a bus line\nbody:\n" with pytest.raises(ParseError, match="invalid bus declaration"): loads(text) @@ -492,13 +483,13 @@ def test_loads_rejects_invalid_bus_line(): def test_loads_variable_bare(): - text = "#!QProgram 1.0\n\nbody:\n var freq\n" + text = HEADER + "\n\nbody:\n var freq\n" p = loads(text) assert p.variables[0].id == "freq" def test_loads_variable_with_metadata(): - text = '#!QProgram 1.0\n\nbody:\n var freq label="L" units="Hz"\n' + text = HEADER + '\n\nbody:\n var freq label="L" units="Hz"\n' p = loads(text) v = p.variables[0] assert v.label == "L" @@ -506,50 +497,50 @@ def test_loads_variable_with_metadata(): def test_loads_variable_invalid_id_format(): - text = "#!QProgram 1.0\n\nbody:\n var 1bad\n" + text = HEADER + "\n\nbody:\n var 1bad\n" with pytest.raises(ParseError, match="must match"): loads(text) def test_loads_variable_reserved_id(): - text = "#!QProgram 1.0\n\nbody:\n var if\n" + text = HEADER + "\n\nbody:\n var if\n" with pytest.raises((ParseError, Exception)): loads(text) def test_loads_variable_unquoted_attr_value(): - text = "#!QProgram 1.0\n\nbody:\n var x label=foo\n" + text = HEADER + "\n\nbody:\n var x label=foo\n" with pytest.raises(ParseError): loads(text) def test_loads_variable_unknown_attr(): - text = '#!QProgram 1.0\n\nbody:\n var x foo="bar"\n' + text = HEADER + '\n\nbody:\n var x foo="bar"\n' with pytest.raises(ParseError, match="unknown variable attribute"): loads(text) def test_loads_variable_duplicate_attr(): - text = '#!QProgram 1.0\n\nbody:\n var x label="a" label="b"\n' + text = HEADER + '\n\nbody:\n var x label="a" label="b"\n' with pytest.raises(ParseError, match="duplicate variable attribute"): loads(text) def test_loads_variable_unexpected_token(): - text = '#!QProgram 1.0\n\nbody:\n var x label="a" garbage\n' + text = HEADER + '\n\nbody:\n var x label="a" garbage\n' with pytest.raises(ParseError, match="unexpected token"): loads(text) def test_loads_variable_bare_var_raises(): """``var`` alone (no id) is a malformed declaration, not a silent no-op.""" - text = "#!QProgram 1.0\n\nbody:\n var\n" + text = HEADER + "\n\nbody:\n var\n" with pytest.raises(ParseError, match="`var` declaration must have the form"): loads(text) def test_loads_variable_duplicate_id(): - text = "#!QProgram 1.0\n\nbody:\n var x\n var x\n" + text = HEADER + "\n\nbody:\n var x\n var x\n" with pytest.raises((ParseError, Exception)): loads(text) @@ -560,13 +551,13 @@ def test_loads_variable_duplicate_id(): def test_loads_play_with_string_alias(): - text = '#!QProgram 1.0\n\nbody:\n play "drive" "pi"\n' + text = HEADER + '\n\nbody:\n play "drive" "pi"\n' p = loads(text) assert dumps(p) == text def test_loads_play_with_inline_waveform(): - text = '#!QProgram 1.0\n\nbody:\n play "drive" Square(amplitude=0.5, duration=100)\n' + text = HEADER + '\n\nbody:\n play "drive" Square(amplitude=0.5, duration=100)\n' p = loads(text) op = p.body.elements[0] assert isinstance(op.waveform, Square) @@ -574,7 +565,7 @@ def test_loads_play_with_inline_waveform(): def test_loads_measure_name_kwarg(): """The canonical writer form: the measurement name travels as ``name=``.""" - text = '#!QProgram 1.0\n\nbody:\n measure "readout" "r" "w" name="m0"\n' + text = HEADER + '\n\nbody:\n measure "readout" "r" "w" name="m0"\n' p = loads(text) op = p.body.elements[0] assert op.fields == ("iq",) @@ -583,7 +574,7 @@ def test_loads_measure_name_kwarg(): def test_loads_measure_positional_handle_name(): """The measurement name is also accepted as a bare 4th positional token.""" - text = '#!QProgram 1.0\n\nbody:\n measure "readout" "r" "w" "m0"\n' + text = HEADER + '\n\nbody:\n measure "readout" "r" "w" "m0"\n' p = loads(text) op = p.body.elements[0] assert op.name == "m0" @@ -591,14 +582,14 @@ def test_loads_measure_positional_handle_name(): def test_loads_measure_without_name_auto_allocates(): """Hand-written files may omit the name; the parser allocates like the builder.""" - text = '#!QProgram 1.0\n\nbody:\n measure "readout" "r" "w"\n measure "readout" "r" "w"\n' + text = HEADER + '\n\nbody:\n measure "readout" "r" "w"\n measure "readout" "r" "w"\n' p = loads(text) names = [op.name for op in p.body.elements] assert names == ["m0", "m1"] def test_loads_measure_with_fields_kwarg(): - text = '#!QProgram 1.0\n\nbody:\n measure "readout" "r" "w" name="m0" fields=["iq", "raw"]\n' + text = HEADER + '\n\nbody:\n measure "readout" "r" "w" name="m0" fields=["iq", "raw"]\n' p = loads(text) op = p.body.elements[0] assert op.fields == ("iq", "raw") @@ -606,84 +597,84 @@ def test_loads_measure_with_fields_kwarg(): def test_loads_measure_returns_kwarg_rejected_with_hint(): """``returns=`` is rejected loudly, and the error says what to write instead.""" - text = '#!QProgram 1.0\n\nbody:\n measure "readout" "r" "w" name="m0" returns="iq,raw"\n' + text = HEADER + '\n\nbody:\n measure "readout" "r" "w" name="m0" returns="iq,raw"\n' with pytest.raises(ParseError, match=r"`returns=` was replaced by `fields=`"): loads(text) def test_loads_measure_fields_canonicalized_on_load(): """A hand-written file in non-canonical order loads to the canonical tuple.""" - text = '#!QProgram 1.0\n\nbody:\n measure "readout" "r" "w" name="m0" fields=["raw", "state", "iq"]\n' + text = HEADER + '\n\nbody:\n measure "readout" "r" "w" name="m0" fields=["raw", "state", "iq"]\n' assert loads(text).body.elements[0].fields == ("state", "iq", "raw") def test_loads_measure_unknown_field_raises(): - text = '#!QProgram 1.0\n\nbody:\n measure "readout" "r" "w" name="m0" fields=["nope"]\n' + text = HEADER + '\n\nbody:\n measure "readout" "r" "w" name="m0" fields=["nope"]\n' with pytest.raises(ParseError, match="unknown measurement field"): loads(text) def test_loads_measure_non_string_name_raises(): - text = '#!QProgram 1.0\n\nbody:\n measure "readout" "r" "w" name=42\n' + text = HEADER + '\n\nbody:\n measure "readout" "r" "w" name=42\n' with pytest.raises(ParseError, match="quoted string"): loads(text) def test_loads_wait_with_int(): - text = '#!QProgram 1.0\n\nbody:\n wait "bus" 100\n' + text = HEADER + '\n\nbody:\n wait "bus" 100\n' p = loads(text) assert p.body.elements[0].duration == 100 def test_loads_wait_with_variable_ref(): - text = '#!QProgram 1.0\n\nbody:\n var t\n wait "bus" t\n' + text = HEADER + '\n\nbody:\n var t\n wait "bus" t\n' p = loads(text) op = p.body.elements[0] assert op.duration is p.variables[0] def test_loads_sync_no_args(): - text = "#!QProgram 1.0\n\nbody:\n sync\n" + text = HEADER + "\n\nbody:\n sync\n" p = loads(text) op = p.body.elements[0] assert op.targets is None def test_loads_sync_with_buses(): - text = '#!QProgram 1.0\n\nbody:\n sync "a" "b"\n' + text = HEADER + '\n\nbody:\n sync "a" "b"\n' p = loads(text) op = p.body.elements[0] assert op.targets == ["a", "b"] def test_loads_set_frequency(): - text = '#!QProgram 1.0\n\nbody:\n set_frequency "bus" 5000000000.0\n' + text = HEADER + '\n\nbody:\n set_frequency "bus" 5000000000.0\n' p = loads(text) op = p.body.elements[0] assert op.frequency == 5e9 def test_loads_set_phase_with_var(): - text = '#!QProgram 1.0\n\nbody:\n var phi\n set_phase "bus" phi\n' + text = HEADER + '\n\nbody:\n var phi\n set_phase "bus" phi\n' p = loads(text) op = p.body.elements[0] assert op.phase is p.variables[0] def test_loads_reset_phase(): - text = '#!QProgram 1.0\n\nbody:\n reset_phase "bus"\n' + text = HEADER + '\n\nbody:\n reset_phase "bus"\n' p = loads(text) assert p.body.elements[0].bus == "bus" def test_loads_set_gain(): - text = '#!QProgram 1.0\n\nbody:\n set_gain "bus" 0.5\n' + text = HEADER + '\n\nbody:\n set_gain "bus" 0.5\n' p = loads(text) assert p.body.elements[0].gain == 0.5 def test_loads_set_offset_one_path(): - text = '#!QProgram 1.0\n\nbody:\n set_offset "bus" 0.1\n' + text = HEADER + '\n\nbody:\n set_offset "bus" 0.1\n' p = loads(text) op = p.body.elements[0] assert op.offset_path0 == 0.1 @@ -691,21 +682,21 @@ def test_loads_set_offset_one_path(): def test_loads_set_offset_two_paths_kwarg_form(): - text = '#!QProgram 1.0\n\nbody:\n set_offset "bus" 0.1 offset_path1=0.2\n' + text = HEADER + '\n\nbody:\n set_offset "bus" 0.1 offset_path1=0.2\n' p = loads(text) op = p.body.elements[0] assert op.offset_path1 == 0.2 def test_loads_set_parameter(): - text = '#!QProgram 1.0\n\nbody:\n set_parameter "cluster" "param" 5000000000.0\n' + text = HEADER + '\n\nbody:\n set_parameter "cluster" "param" 5000000000.0\n' p = loads(text) op = p.body.elements[0] assert op.bus == "cluster" def test_loads_get_parameter_arrow(): - text = '#!QProgram 1.0\n\nbody:\n get_parameter "cluster" "param" -> result\n' + text = HEADER + '\n\nbody:\n get_parameter "cluster" "param" -> result\n' p = loads(text) op = p.body.elements[0] assert op.bus == "cluster" @@ -713,13 +704,13 @@ def test_loads_get_parameter_arrow(): def test_loads_get_parameter_arrow_missing_var_raises(): - text = '#!QProgram 1.0\n\nbody:\n get_parameter "cluster" "param"\n' + text = HEADER + '\n\nbody:\n get_parameter "cluster" "param"\n' with pytest.raises(ParseError, match="-> "): loads(text) def test_loads_get_parameter_missing_bus_raises(): - text = "#!QProgram 1.0\n\nbody:\n get_parameter -> result\n" + text = HEADER + "\n\nbody:\n get_parameter -> result\n" with pytest.raises(ParseError, match="bus and parameter"): loads(text) @@ -729,27 +720,27 @@ def test_loads_unknown_operation_raises(): Silently skipping the line would load a different program than the file describes. """ - text = '#!QProgram 1.0\n\nbody:\n unknown_op "bus" 42\n' + text = HEADER + '\n\nbody:\n unknown_op "bus" 42\n' with pytest.raises(ParseError, match="unknown operation 'unknown_op'"): loads(text) def test_loads_unknown_vendor_operation_raises_with_hint(): """A dotted op whose vendor namespace isn't registered names the missing extension.""" - text = '#!QProgram 1.0\n\nbody:\n ghostvendor.acquire "bus" "w"\n' + text = HEADER + '\n\nbody:\n ghostvendor.acquire "bus" "w"\n' with pytest.raises(ParseError, match="Import the 'ghostvendor' extension"): loads(text) def test_loads_unknown_block_keyword_raises(): - text = '#!QProgram 1.0\n\nbody:\n repeat 5:\n play "bus" "wf"\n' + text = HEADER + '\n\nbody:\n repeat 5:\n play "bus" "wf"\n' with pytest.raises(ParseError, match="unknown block keyword 'repeat'"): loads(text) def test_loads_excess_positional_tokens_raise(): """Spec-style unparenthesized arithmetic must error, not silently drop tokens.""" - text = '#!QProgram 1.0\n\nbody:\n var t\n wait "bus" 100 - t\n' + text = HEADER + '\n\nbody:\n var t\n wait "bus" 100 - t\n' with pytest.raises(ParseError, match="parenthesize"): loads(text) @@ -760,7 +751,7 @@ def test_loads_excess_positional_tokens_raise(): def test_loads_average_block(): - text = '#!QProgram 1.0\n\nbody:\n average 1000:\n play "bus" "wf"\n' + text = HEADER + '\n\nbody:\n average 1000:\n play "bus" "wf"\n' p = loads(text) avg = p.body.elements[0] assert avg.shots == 1000 @@ -768,19 +759,19 @@ def test_loads_average_block(): def test_loads_average_invalid_shots_raises(): - text = "#!QProgram 1.0\n\nbody:\n average abc:\n" + text = HEADER + "\n\nbody:\n average abc:\n" with pytest.raises(ParseError, match="invalid shots"): loads(text) def test_loads_average_missing_shots_raises(): - text = "#!QProgram 1.0\n\nbody:\n average:\n" + text = HEADER + "\n\nbody:\n average:\n" with pytest.raises(ParseError, match="requires a shot count"): loads(text) def test_loads_for_range_two_args(): - text = '#!QProgram 1.0\n\nbody:\n var x\n for x in Range(start=0, stop=10):\n wait "bus" 100\n' + text = HEADER + '\n\nbody:\n var x\n for x in Range(start=0, stop=10):\n wait "bus" 100\n' p = loads(text) sw = p.body.elements[0] assert sw.source.start == 0 @@ -789,19 +780,19 @@ def test_loads_for_range_two_args(): def test_loads_for_range_three_args(): - text = '#!QProgram 1.0\n\nbody:\n var x\n for x in Range(start=0.0, stop=1.0, step=0.1):\n wait "bus" 100\n' + text = HEADER + '\n\nbody:\n var x\n for x in Range(start=0.0, stop=1.0, step=0.1):\n wait "bus" 100\n' p = loads(text) assert p.body.elements[0].source.step == 0.1 def test_loads_for_range_missing_argument_raises(): - text = '#!QProgram 1.0\n\nbody:\n var x\n for x in Range(1):\n wait "bus" 100\n' + text = HEADER + '\n\nbody:\n var x\n for x in Range(1):\n wait "bus" 100\n' with pytest.raises(ParseError, match="cannot construct sweep source Range"): loads(text) def test_loads_for_values_list(): - text = '#!QProgram 1.0\n\nbody:\n var x\n for x in [0.0, 0.5, 1.0]:\n wait "bus" 100\n' + text = HEADER + '\n\nbody:\n var x\n for x in [0.0, 0.5, 1.0]:\n wait "bus" 100\n' p = loads(text) sw = p.body.elements[0] assert isinstance(sw.source, Values) @@ -809,26 +800,26 @@ def test_loads_for_values_list(): def test_loads_for_invalid_header_raises(): - text = '#!QProgram 1.0\n\nbody:\n for in Range(start=0, stop=1):\n wait "bus" 100\n' + text = HEADER + '\n\nbody:\n for in Range(start=0, stop=1):\n wait "bus" 100\n' with pytest.raises(ParseError): loads(text) def test_loads_for_unknown_source_raises_and_lists_the_registered_ones(): - text = '#!QProgram 1.0\n\nbody:\n var x\n for x in bogus(0,1):\n wait "bus" 100\n' + text = HEADER + '\n\nbody:\n var x\n for x in bogus(0,1):\n wait "bus" 100\n' with pytest.raises(ParseError, match=r"unknown sweep source \'bogus\'; registered sources are"): loads(text) def test_loads_for_unknown_source_form_raises(): - text = '#!QProgram 1.0\n\nbody:\n var x\n for x in something:\n wait "bus" 100\n' + text = HEADER + '\n\nbody:\n var x\n for x in something:\n wait "bus" 100\n' with pytest.raises(ParseError, match="unknown sweep source"): loads(text) def test_loads_parallel_loops(): text = ( - "#!QProgram 1.0\n\n" + HEADER + "\n\n" "body:\n" " var x\n" " var y\n" @@ -841,14 +832,17 @@ def test_loads_parallel_loops(): def test_loads_block_scope(): - text = '#!QProgram 1.0\n\nbody:\n block:\n wait "bus" 100\n' + text = HEADER + '\n\nbody:\n block:\n wait "bus" 100\n' p = loads(text) block = p.body.elements[0] assert len(block.elements) == 1 def test_loads_nested_blocks(): - text = '#!QProgram 1.0\n\nbody:\n var x\n average 100:\n for x in Range(start=0.0, stop=1.0, step=0.1):\n wait "bus" 100\n' + text = ( + HEADER + + '\n\nbody:\n var x\n average 100:\n for x in Range(start=0.0, stop=1.0, step=0.1):\n wait "bus" 100\n' + ) p = loads(text) assert len(p.body.elements) == 1 @@ -859,21 +853,21 @@ def test_loads_nested_blocks(): def test_loads_binary_arithmetic(): - text = '#!QProgram 1.0\n\nbody:\n var x\n set_frequency "bus" (x + 5)\n' + text = HEADER + '\n\nbody:\n var x\n set_frequency "bus" (x + 5)\n' p = loads(text) op = p.body.elements[0] assert isinstance(op.frequency, BinaryOp) def test_loads_unary_neg(): - text = '#!QProgram 1.0\n\nbody:\n var x\n set_phase "bus" (-x)\n' + text = HEADER + '\n\nbody:\n var x\n set_phase "bus" (-x)\n' p = loads(text) op = p.body.elements[0] assert isinstance(op.phase, UnaryOp) def test_loads_comparison(): - text = '#!QProgram 1.0\n\nbody:\n var x\n set_offset "bus" where((x < 5), x, 0)\n' + text = HEADER + '\n\nbody:\n var x\n set_offset "bus" where((x < 5), x, 0)\n' p = loads(text) op = p.body.elements[0] assert isinstance(op.offset_path0, Where) @@ -881,7 +875,7 @@ def test_loads_comparison(): def test_loads_logical_and(): - text = '#!QProgram 1.0\n\nbody:\n var x\n var y\n set_offset "bus" where(((x == 1) and (y == 1)), 1, 0)\n' + text = HEADER + '\n\nbody:\n var x\n var y\n set_offset "bus" where(((x == 1) and (y == 1)), 1, 0)\n' p = loads(text) op = p.body.elements[0] cond = op.offset_path0.condition @@ -889,14 +883,14 @@ def test_loads_logical_and(): def test_loads_logical_not(): - text = '#!QProgram 1.0\n\nbody:\n var x\n set_offset "bus" where((not (x == 1)), 1, 0)\n' + text = HEADER + '\n\nbody:\n var x\n set_offset "bus" where((not (x == 1)), 1, 0)\n' p = loads(text) op = p.body.elements[0] assert isinstance(op.offset_path0.condition, LogicalNot) def test_loads_math_func(): - text = '#!QProgram 1.0\n\nbody:\n var x\n set_frequency "bus" sin(x)\n' + text = HEADER + '\n\nbody:\n var x\n set_frequency "bus" sin(x)\n' p = loads(text) op = p.body.elements[0] assert isinstance(op.frequency, MathFunc) @@ -904,32 +898,32 @@ def test_loads_math_func(): def test_loads_where(): - text = '#!QProgram 1.0\n\nbody:\n var x\n set_offset "bus" where((x < 5), 1, 0)\n' + text = HEADER + '\n\nbody:\n var x\n set_offset "bus" where((x < 5), 1, 0)\n' p = loads(text) op = p.body.elements[0] assert isinstance(op.offset_path0, Where) def test_loads_where_wrong_arity_raises(): - text = '#!QProgram 1.0\n\nbody:\n var x\n set_offset "bus" where(x, 1)\n' + text = HEADER + '\n\nbody:\n var x\n set_offset "bus" where(x, 1)\n' with pytest.raises(ParseError, match="3 arguments"): loads(text) def test_loads_empty_paren_expression_raises(): - text = '#!QProgram 1.0\n\nbody:\n set_offset "bus" ()\n' + text = HEADER + '\n\nbody:\n set_offset "bus" ()\n' with pytest.raises(ParseError, match="empty expression"): loads(text) def test_loads_paren_expression_unrecognized_raises(): - text = '#!QProgram 1.0\n\nbody:\n var x\n set_offset "bus" (x bogus 5)\n' + text = HEADER + '\n\nbody:\n var x\n set_offset "bus" (x bogus 5)\n' with pytest.raises(ParseError, match="unknown operator"): loads(text) def test_loads_paren_expression_single_token_not_unary_raises(): - text = '#!QProgram 1.0\n\nbody:\n var x\n set_offset "bus" (x)\n' + text = HEADER + '\n\nbody:\n var x\n set_offset "bus" (x)\n' # ``(x)`` is one token without a binary op and not a leading sign. with pytest.raises(ParseError, match="could not parse"): loads(text) @@ -941,33 +935,33 @@ def test_loads_paren_expression_single_token_not_unary_raises(): def test_parse_context_parse_value_empty_raises(): - p = _Parser("#!QProgram 1.0\nbody:\n") + p = _Parser(HEADER + "\nbody:\n") p._parse_header() with pytest.raises(ParseError, match="empty argument token"): p.parse_value("") def test_parse_context_parse_value_quoted_string(): - p = _Parser("#!QProgram 1.0\nbody:\n") + p = _Parser(HEADER + "\nbody:\n") p._parse_header() assert p.parse_value('"hello"') == "hello" def test_parse_context_parse_value_true(): - p = _Parser("#!QProgram 1.0\nbody:\n") + p = _Parser(HEADER + "\nbody:\n") p._parse_header() assert p.parse_value("true") is True def test_parse_context_get_or_declare_variable_creates_new(): - p = _Parser("#!QProgram 1.0\nbody:\n") + p = _Parser(HEADER + "\nbody:\n") p._parse_header() v = p.get_or_declare_variable("auto") assert v.id == "auto" def test_parse_context_get_or_declare_variable_reuses(): - p = _Parser("#!QProgram 1.0\nbody:\n") + p = _Parser(HEADER + "\nbody:\n") p._parse_header() v1 = p.get_or_declare_variable("x") v2 = p.get_or_declare_variable("x") @@ -975,7 +969,7 @@ def test_parse_context_get_or_declare_variable_reuses(): def test_parse_context_declared_variable(): - p = _Parser("#!QProgram 1.0\nbody:\n") + p = _Parser(HEADER + "\nbody:\n") p._parse_header() assert p.declared_variable("ghost") is None p.get_or_declare_variable("ghost") @@ -983,7 +977,7 @@ def test_parse_context_declared_variable(): def test_parse_context_line_num(): - p = _Parser("#!QProgram 1.0\nbody:\n") + p = _Parser(HEADER + "\nbody:\n") assert p.line_num == 1 @@ -1005,13 +999,13 @@ def test_load_from_file(tmp_path, rabi_program): def test_loads_handles_inline_comments(): - text = '#!QProgram 1.0\n\nbody:\n var x # a comment\n set_frequency "bus" 5e9 # another\n' + text = HEADER + '\n\nbody:\n var x # a comment\n set_frequency "bus" 5e9 # another\n' p = loads(text) assert len(p.variables) == 1 def test_loads_handles_blank_lines(): - text = "#!QProgram 1.0\n\n\nbody:\n\n var x\n\n" + text = HEADER + "\n\n\nbody:\n\n var x\n\n" p = loads(text) assert len(p.variables) == 1 @@ -1023,7 +1017,7 @@ def test_loads_handles_blank_lines(): def test_loads_resolves_bus_path_against_schema(): text = ( - "#!QProgram 1.0\n\n" + HEADER + "\n\n" "schema:\n" " element q:\n" " drive info=IQ\n" @@ -1042,7 +1036,7 @@ def test_loads_resolves_bus_path_against_schema(): def test_loads_bus_path_tuple_index(): - text = "#!QProgram 1.0\n\nschema:\n element c:\n flux info=single\n\nbody:\n set_offset c[0,1].flux 0.5\n" + text = HEADER + "\n\nschema:\n element c:\n flux info=single\n\nbody:\n set_offset c[0,1].flux 0.5\n" p = loads(text) op = p.body.elements[0] @@ -1051,7 +1045,7 @@ def test_loads_bus_path_tuple_index(): def test_loads_invalid_bus_path_raises(): - text = '#!QProgram 1.0\n\nschema:\n element q:\n drive info=IQ\n\nbody:\n play q[0].nonexistent "wf"\n' + text = HEADER + '\n\nschema:\n element q:\n drive info=IQ\n\nbody:\n play q[0].nonexistent "wf"\n' with pytest.raises(ParseError, match="does not resolve"): loads(text) @@ -1064,7 +1058,7 @@ def test_loads_quoted_path_like_bus_stays_string(): only bare ``element[index].kind`` tokens are. """ text = ( - "#!QProgram 1.0\n\n" + HEADER + "\n\n" "schema:\n" " element q:\n" " drive info=IQ\n" @@ -1107,33 +1101,33 @@ def test_tokenize_bracket_inside_quotes_does_not_nest(): def test_parse_value_dict_literal(): - p = _Parser("#!QProgram 1.0\nbody:\n") + p = _Parser(HEADER + "\nbody:\n") p._parse_header() assert p.parse_value('{"a": 1.0, "b": {"c": null}}') == {"a": 1.0, "b": {"c": None}} def test_parse_value_dict_unquoted_key_raises(): - p = _Parser("#!QProgram 1.0\nbody:\n") + p = _Parser(HEADER + "\nbody:\n") p._parse_header() with pytest.raises(ParseError, match="quoted strings"): p.parse_value("{a: 1.0}") def test_parse_value_dict_missing_colon_raises(): - p = _Parser("#!QProgram 1.0\nbody:\n") + p = _Parser(HEADER + "\nbody:\n") p._parse_header() with pytest.raises(ParseError, match="invalid dict entry"): p.parse_value('{"a" 1.0}') def test_parse_value_list_is_plain_list(): - p = _Parser("#!QProgram 1.0\nbody:\n") + p = _Parser(HEADER + "\nbody:\n") p._parse_header() assert p.parse_value("[1, 2, 3]") == [1, 2, 3] def test_parse_value_null(): - p = _Parser("#!QProgram 1.0\nbody:\n") + p = _Parser(HEADER + "\nbody:\n") p._parse_header() assert p.parse_value("null") is None diff --git a/tests/test_paths.py b/tests/test_paths.py index 6d96fbb..e440aa9 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -16,6 +16,7 @@ from __future__ import annotations import pytest +from _header import HEADER import qprogram as qp from qprogram import QProgram, format_path, node_path, resolve_path @@ -177,7 +178,7 @@ def test_source_map_empty_for_python_built_programs(): def test_source_map_cleared_by_expand(): - text = '#!QProgram 1.0\n\nfragment f1(bus):\n sync\n\nbody:\n f1("drive")\n' + text = HEADER + '\n\nfragment f1(bus):\n sync\n\nbody:\n f1("drive")\n' p = qp.loads(text) assert p.source_map # the call statement is mapped assert p.expand().source_map == {} diff --git a/tests/test_specs.py b/tests/test_specs.py index cd8a05f..3cc1067 100644 --- a/tests/test_specs.py +++ b/tests/test_specs.py @@ -18,6 +18,7 @@ from typing import TYPE_CHECKING, cast import pytest +from _header import HEADER from qprogram import ( MeasurementHandle, @@ -55,7 +56,7 @@ def _writer() -> _Writer: def _parser(body: str = "") -> _Parser: - text = f"#!QProgram 1.0\n\nbody:\n{body}" + text = HEADER + f"\n\nbody:\n{body}" p = _Parser(text) p._parse_header() return p diff --git a/tests/test_vendor_discovery.py b/tests/test_vendor_discovery.py index a61d24a..ef29778 100644 --- a/tests/test_vendor_discovery.py +++ b/tests/test_vendor_discovery.py @@ -27,6 +27,7 @@ import _dummy_vendor import pytest +from _header import HEADER import qprogram as qp from qprogram.serialization import registry @@ -60,7 +61,7 @@ def dummy_inactive() -> Iterator[None]: def _require_doc(vendor: str, version: str = "0.0", body: str = "") -> str: - return f"#!QProgram 1.0\n\nrequire {vendor} {version}\n\nbody:\n{body}" + return HEADER + f"\n\nrequire {vendor} {version}\n\nbody:\n{body}" # --------------------------------------------------------------------------- diff --git a/tests/test_waveform_library.py b/tests/test_waveform_library.py index c54ced3..c457a9b 100644 --- a/tests/test_waveform_library.py +++ b/tests/test_waveform_library.py @@ -19,10 +19,13 @@ from __future__ import annotations import pytest +from _header import WFL_HEADER from test_round_trip import UNICODE_LINE_BREAKS +import qprogram from qprogram import BusSchema, ParseError, QProgram, ValidationError, WaveformLibrary from qprogram.errors import SerializationError +from qprogram.waveform_library import WAVEFORM_LIBRARY_FORMAT_VERSION from qprogram.waveforms import Gaussian, IQDrag, IQPair, Square @@ -83,6 +86,11 @@ def test_dumps_is_byte_stable_round_trip(): assert WaveformLibrary.loads(text).dumps() == text +def test_format_version_follows_the_library_version(): + """The header version is the library version truncated to ``major.minor``.""" + assert ".".join(qprogram.__version__.split(".")[:2]) == WAVEFORM_LIBRARY_FORMAT_VERSION + + def test_round_trip_preserves_every_tier(): schema = BusSchema.transmon_coupled() q, c = schema.q, schema.c @@ -107,13 +115,7 @@ def test_save_and_load_file(tmp_path): def test_blank_lines_and_comments_are_ignored(): - text = ( - "#!WaveformLibrary 1.0\n" - "\n" - "# a comment\n" - '"pi" q[0].drive = IQDrag(amplitude=0.5, duration=40, sigma=8, beta=0.1)\n' - "\n" - ) + text = WFL_HEADER + '\n\n# a comment\n"pi" q[0].drive = IQDrag(amplitude=0.5, duration=40, sigma=8, beta=0.1)\n\n' library = WaveformLibrary.loads(text) schema = BusSchema.transmon() assert library.get(schema.q[0].drive, "pi").amplitude == 0.5 @@ -142,22 +144,22 @@ def test_incompatible_major_version_raises(): def test_unknown_waveform_raises(): with pytest.raises(ParseError, match="Unknown waveform or sweep source type"): - WaveformLibrary.loads('#!WaveformLibrary 1.0\n"x" = Bogus(1, 2)\n') + WaveformLibrary.loads(WFL_HEADER + '\n"x" = Bogus(1, 2)\n') def test_unquoted_name_raises(): with pytest.raises(ParseError, match="quoted waveform name"): - WaveformLibrary.loads("#!WaveformLibrary 1.0\nx = Gaussian(0.5, 40, 8)\n") + WaveformLibrary.loads(WFL_HEADER + "\nx = Gaussian(0.5, 40, 8)\n") def test_missing_equals_raises(): with pytest.raises(ParseError, match="must contain '='"): - WaveformLibrary.loads('#!WaveformLibrary 1.0\n"x" Gaussian(0.5, 40, 8)\n') + WaveformLibrary.loads(WFL_HEADER + '\n"x" Gaussian(0.5, 40, 8)\n') def test_bad_coordinate_raises(): with pytest.raises(ParseError, match="invalid entry coordinate"): - WaveformLibrary.loads('#!WaveformLibrary 1.0\n"x" not_a_coord = Gaussian(0.5, 40, 8)\n') + WaveformLibrary.loads(WFL_HEADER + '\n"x" not_a_coord = Gaussian(0.5, 40, 8)\n') def test_non_concrete_waveform_rejected_on_dump(): diff --git a/tests/test_writer.py b/tests/test_writer.py index 935b4b1..83bcd7e 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -19,7 +19,9 @@ import numpy as np import pytest +from _header import HEADER +import qprogram from qprogram import ( BusNaming, BusSchema, @@ -37,6 +39,7 @@ from qprogram.operations import Play from qprogram.operations.operation import Operation from qprogram.serialization import registry +from qprogram.serialization._format import FORMAT_VERSION from qprogram.serialization.registry import register_vendor_block, register_vendor_version from qprogram.serialization.writer import _escape_str, _major_minor, _Writer from qprogram.sweeps import Range, Values @@ -86,7 +89,12 @@ def test_escape_str(raw, expected): def test_dumps_starts_with_format_header(): text = dumps(QProgram()) - assert text.startswith("#!QProgram 1.0\n") + assert text.startswith(HEADER + "\n") + + +def test_format_version_follows_the_library_version(): + """The header version is the library version truncated to ``major.minor``.""" + assert ".".join(qprogram.__version__.split(".")[:2]) == FORMAT_VERSION def test_dumps_includes_body_section(): diff --git a/uv.lock b/uv.lock index 3fe0c12..e8e4167 100644 --- a/uv.lock +++ b/uv.lock @@ -1333,7 +1333,7 @@ wheels = [ [[package]] name = "qprogram" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, From 5ee7287933842c75ee2729b99a4ec06b1063f79b Mon Sep 17 00:00:00 2001 From: Vyron Vasileiadis Date: Tue, 8 Sep 2026 00:45:52 +0300 Subject: [PATCH 2/2] Cover the version truncation instead of pragma-ing it out The fallback for a source tree with no installed metadata was excluded from coverage by a trailing pragma. It is tested now, along with the truncation itself, so the pragma goes. --- src/qprogram/_version.py | 2 +- tests/test_coverage_gaps.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/qprogram/_version.py b/src/qprogram/_version.py index 09878cf..44b8f6f 100644 --- a/src/qprogram/_version.py +++ b/src/qprogram/_version.py @@ -33,7 +33,7 @@ def library_major_minor() -> str: """ try: release = version("qprogram") - except PackageNotFoundError: # pragma: no cover - source tree without installed metadata + except PackageNotFoundError: return "0.0" major, _, rest = release.partition(".") minor = rest.partition(".")[0] diff --git a/tests/test_coverage_gaps.py b/tests/test_coverage_gaps.py index 2c0677f..efe03b8 100644 --- a/tests/test_coverage_gaps.py +++ b/tests/test_coverage_gaps.py @@ -20,6 +20,7 @@ from __future__ import annotations +from importlib.metadata import PackageNotFoundError from typing import cast import numpy as np @@ -28,6 +29,7 @@ import qprogram as qp from qprogram import BusSchema, ParseError, Variable, serialization +from qprogram._version import library_major_minor from qprogram.blocks import Block, Parallel, Sweep from qprogram.buses import BusRef from qprogram.operations import Wait @@ -582,3 +584,23 @@ def test_parse_value_with_bus_path_token_returns_string(): parser = _Parser(HEADER + "\nbody:\n") parser._parse_header() assert parser.parse_value("q[0].drive") == "q[0].drive" + + +@pytest.mark.parametrize( + ("release", "expected"), + [("0.2.0", "0.2"), ("0.2.1.dev3", "0.2"), ("10.11", "10.11"), ("1", "1.0")], +) +def test_library_major_minor_truncates_the_release(monkeypatch, release, expected): + """The header version is the release's first two components, padded when it carries only one.""" + monkeypatch.setattr("qprogram._version.version", lambda _name: release) + assert library_major_minor() == expected + + +def test_library_major_minor_without_installed_metadata(monkeypatch): + """A source tree the package is not installed into has no version to read.""" + + def _missing(name: str) -> str: + raise PackageNotFoundError(name) + + monkeypatch.setattr("qprogram._version.version", _missing) + assert library_major_minor() == "0.0"