Repository: developmentseed/cql2-rs (language: python)

Issue:
Expose to Python



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4ad7907..235b0fb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,6 +6,10 @@ on:
       - main
   pull_request:
 
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
 jobs:
   lint-test:
     name: Lint and Test
diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
new file mode 100644
index 0000000..83f46f7
--- /dev/null
+++ b/.github/workflows/python.yml
@@ -0,0 +1,203 @@
+name: Python
+
+on:
+  push:
+    branches:
+      - main
+    tags:
+      - '*'
+    paths:
+      - python/**
+  pull_request:
+    paths:
+      - python/**
+  workflow_dispatch:
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+permissions:
+  contents: read
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    defaults:
+      run:
+        working-directory: python
+    steps:
+      - uses: actions/checkout@v4
+      - uses: astral-sh/setup-uv@v3
+        with:
+          enable-cache: true
+      - uses: actions/setup-python@v5
+        with:
+          python-version-file: python/.python-version
+      - name: Install
+        run: uv sync --dev
+      - name: Check
+        run: uv run mypy . && uv run ruff check && uv run ruff format --check
+      - name: Test
+        run: uv run pytest 
+  linux:
+    runs-on: ${{ matrix.platform.runner }}
+    strategy:
+      matrix:
+        platform:
+          - runner: ubuntu-latest
+            target: x86_64
+          - runner: ubuntu-latest
+            target: x86
+          - runner: ubuntu-latest
+            target: aarch64
+          - runner: ubuntu-latest
+            target: armv7
+          - runner: ubuntu-latest
+            target: s390x
+          - runner: ubuntu-latest
+            target: ppc64le
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: 3.x
+      - name: Build wheels
+        uses: PyO3/maturin-action@v1
+        with:
+          target: ${{ matrix.platform.target }}
+          args: --release --out dist --find-interpreter -m python/Cargo.toml
+          sccache: 'true'
+          manylinux: auto
+      - name: Upload wheels
+        uses: actions/upload-artifact@v4
+        with:
+          name: wheels-linux-${{ matrix.platform.target }}
+          path: dist
+
+  musllinux:
+    runs-on: ${{ matrix.platform.runner }}
+    strategy:
+      matrix:
+        platform:
+          - runner: ubuntu-latest
+            target: x86_64
+          - runner: ubuntu-latest
+            target: x86
+          - runner: ubuntu-latest
+            target: aarch64
+          - runner: ubuntu-latest
+            target: armv7
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: 3.x
+      - name: Build wheels
+        uses: PyO3/maturin-action@v1
+        with:
+          target: ${{ matrix.platform.target }}
+          args: --release --out dist --find-interpreter -m python/Cargo.toml
+          sccache: 'true'
+          manylinux: musllinux_1_2
+      - name: Upload wheels
+        uses: actions/upload-artifact@v4
+        with:
+          name: wheels-musllinux-${{ matrix.platform.target }}
+          path: dist
+
+  windows:
+    runs-on: ${{ matrix.platform.runner }}
+    strategy:
+      matrix:
+        platform:
+          - runner: windows-latest
+            target: x64
+          - runner: windows-latest
+            target: x86
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: 3.x
+          architecture: ${{ matrix.platform.target }}
+      - name: Build wheels
+        uses: PyO3/maturin-action@v1
+        with:
+          target: ${{ matrix.platform.target }}
+          args: --release --out dist --find-interpreter -m python/Cargo.toml
+          sccache: 'true'
+      - name: Upload wheels
+        uses: actions/upload-artifact@v4
+        with:
+          name: wheels-windows-${{ matrix.platform.target }}
+          path: dist
+
+  macos:
+    runs-on: ${{ matrix.platform.runner }}
+    strategy:
+      matrix:
+        platform:
+          - runner: macos-12
+            target: x86_64
+          - runner: macos-14
+            target: aarch64
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: 3.x
+      - name: Build wheels
+        uses: PyO3/maturin-action@v1
+        with:
+          target: ${{ matrix.platform.target }}
+          args: --release --out dist --find-interpreter -m python/Cargo.toml
+          sccache: 'true'
+      - name: Upload wheels
+        uses: actions/upload-artifact@v4
+        with:
+          name: wheels-macos-${{ matrix.platform.target }}
+          path: dist
+
+  sdist:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - name: Build sdist
+        uses: PyO3/maturin-action@v1
+        with:
+          command: sdist
+          args: --out dist -m python/Cargo.toml
+          maturin-version: "v1.6.0" # pinned below 1.7 until https://github.com/PyO3/maturin/issues/2244 is resolved
+      - name: Upload sdist
+        uses: actions/upload-artifact@v4
+        with:
+          name: wheels-sdist
+          path: dist
+
+  release:
+    name: Release
+    runs-on: ubuntu-latest
+    if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }}
+    needs: [linux, musllinux, windows, macos, sdist]
+    permissions:
+      # Use to sign the release artifacts
+      id-token: write
+      # Used to upload release artifacts
+      contents: write
+      # Used to generate artifact attestation
+      attestations: write
+    steps:
+      - uses: actions/download-artifact@v4
+      - name: Generate artifact attestation
+        uses: actions/attest-build-provenance@v1
+        with:
+          subject-path: 'wheels-*/*'
+      - name: Publish to PyPI
+        if: "startsWith(github.ref, 'refs/tags/')"
+        uses: PyO3/maturin-action@v1
+        env:
+          MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
+        with:
+          command: upload
+          args: --non-interactive --skip-existing wheels-*/*
diff --git a/Cargo.lock b/Cargo.lock
index 0ce4b6c..0fee0bf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -234,6 +234,15 @@ dependencies = [
  "serde_json",
 ]
 
+[[package]]
+name = "cql2-python"
+version = "0.1.0"
+dependencies = [
+ "cql2",
+ "pyo3",
+ "pythonize",
+]
+
 [[package]]
 name = "crypto-common"
 version = "0.1.6"
@@ -470,6 +479,12 @@ dependencies = [
  "hashbrown",
 ]
 
+[[package]]
+name = "indoc"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5"
+
 [[package]]
 name = "is_terminal_polyfill"
 version = "1.70.1"
@@ -512,6 +527,15 @@ version = "2.7.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
 
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
 [[package]]
 name = "num-traits"
 version = "0.2.19"
@@ -591,6 +615,12 @@ version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
 
+[[package]]
+name = "portable-atomic"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2"
+
 [[package]]
 name = "proc-macro-crate"
 version = "3.2.0"
@@ -609,6 +639,79 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "pyo3"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15ee168e30649f7f234c3d49ef5a7a6cbf5134289bc46c29ff3155fa3221c225"
+dependencies = [
+ "cfg-if",
+ "indoc",
+ "libc",
+ "memoffset",
+ "once_cell",
+ "portable-atomic",
+ "pyo3-build-config",
+ "pyo3-ffi",
+ "pyo3-macros",
+ "unindent",
+]
+
+[[package]]
+name = "pyo3-build-config"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e61cef80755fe9e46bb8a0b8f20752ca7676dcc07a5277d8b7768c6172e529b3"
+dependencies = [
+ "once_cell",
+ "target-lexicon",
+]
+
+[[package]]
+name = "pyo3-ffi"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67ce096073ec5405f5ee2b8b31f03a68e02aa10d5d4f565eca04acc41931fa1c"
+dependencies = [
+ "libc",
+ "pyo3-build-config",
+]
+
+[[package]]
+name = "pyo3-macros"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2440c6d12bc8f3ae39f1e775266fa5122fd0c8891ce7520fa6048e683ad3de28"
+dependencies = [
+ "proc-macro2",
+ "pyo3-macros-backend",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pyo3-macros-backend"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1be962f0e06da8f8465729ea2cb71a416d2257dff56cbe40a70d3e62a93ae5d1"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "pyo3-build-config",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pythonize"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90fcf491425978bd889015d5430f6473d91bdfa2097262f1e731aadcf6c2113e"
+dependencies = [
+ "pyo3",
+ "serde",
+]
+
 [[package]]
 name = "quote"
 version = "1.0.37"
@@ -774,6 +877,12 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
 [[package]]
 name = "thiserror"
 version = "1.0.64"
@@ -859,6 +968,12 @@ dependencies = [
  "tinyvec",
 ]
 
+[[package]]
+name = "unindent"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce"
+
 [[package]]
 name = "url"
 version = "2.5.2"
diff --git a/Cargo.toml b/Cargo.toml
index 24d8a39..09ed25e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,4 +30,4 @@ rstest = "0.23"
 
 [workspace]
 default-members = [".", "cli"]
-members = ["cli"]
+members = ["cli", "python"]
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 8744f18..cfdf41f 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -99,7 +99,7 @@ fn main() {
             std::process::exit(1)
         }
     }
-    let output_format = cli.output_format.unwrap_or_else(|| match input_format {
+    let output_format = cli.output_format.unwrap_or(match input_format {
         InputFormat::Json => OutputFormat::Json,
         InputFormat::Text => OutputFormat::Text,
     });
diff --git a/tests/fixtures/json/clause6_01.json b/fixtures/json/clause6_01.json
similarity index 100%
rename from tests/fixtures/json/clause6_01.json
rename to fixtures/json/clause6_01.json
diff --git a/tests/fixtures/json/clause6_02a.json b/fixtures/json/clause6_02a.json
similarity index 100%
rename from tests/fixtures/json/clause6_02a.json
rename to fixtures/json/clause6_02a.json
diff --git a/tests/fixtures/json/clause6_02b.json b/fixtures/json/clause6_02b.json
similarity index 100%
rename from tests/fixtures/json/clause6_02b.json
rename to fixtures/json/clause6_02b.json
diff --git a/tests/fixtures/json/clause6_02c.json b/fixtures/json/clause6_02c.json
similarity index 100%
rename from tests/fixtures/json/clause6_02c.json
rename to fixtures/json/clause6_02c.json
diff --git a/tests/fixtures/json/clause6_02d.json b/fixtures/json/clause6_02d.json
similarity index 100%
rename from tests/fixtures/json/clause6_02d.json
rename to fixtures/json/clause6_02d.json
diff --git a/tests/fixtures/json/clause6_03.json b/fixtures/json/clause6_03.json
similarity index 100%
rename from tests/fixtures/json/clause6_03.json
rename to fixtures/json/clause6_03.json
diff --git a/tests/fixtures/json/clause7_01.json b/fixtures/json/clause7_01.json
similarity index 100%
rename from tests/fixtures/json/clause7_01.json
rename to fixtures/json/clause7_01.json
diff --git a/tests/fixtures/json/clause7_02.json b/fixtures/json/clause7_02.json
similarity index 100%
rename from tests/fixtures/json/clause7_02.json
rename to fixtures/json/clause7_02.json
diff --git a/tests/fixtures/json/clause7_03a.json b/fixtures/json/clause7_03a.json
similarity index 100%
rename from tests/fixtures/json/clause7_03a.json
rename to fixtures/json/clause7_03a.json
diff --git a/tests/fixtures/json/clause7_03b.json b/fixtures/json/clause7_03b.json
similarity index 100%
rename from tests/fixtures/json/clause7_03b.json
rename to fixtures/json/clause7_03b.json
diff --git a/tests/fixtures/json/clause7_04.json b/fixtures/json/clause7_04.json
similarity index 100%
rename from tests/fixtures/json/clause7_04.json
rename to fixtures/json/clause7_04.json
diff --git a/tests/fixtures/json/clause7_05.json b/fixtures/json/clause7_05.json
similarity index 100%
rename from tests/fixtures/json/clause7_05.json
rename to fixtures/json/clause7_05.json
diff --git a/tests/fixtures/json/clause7_07.json b/fixtures/json/clause7_07.json
similarity index 100%
rename from tests/fixtures/json/clause7_07.json
rename to fixtures/json/clause7_07.json
diff --git a/tests/fixtures/json/clause7_10.json b/fixtures/json/clause7_10.json
similarity index 100%
rename from tests/fixtures/json/clause7_10.json
rename to fixtures/json/clause7_10.json
diff --git a/tests/fixtures/json/clause7_12.json b/fixtures/json/clause7_12.json
similarity index 100%
rename from tests/fixtures/json/clause7_12.json
rename to fixtures/json/clause7_12.json
diff --git a/tests/fixtures/json/clause7_13.json b/fixtures/json/clause7_13.json
similarity index 100%
rename from tests/fixtures/json/clause7_13.json
rename to fixtures/json/clause7_13.json
diff --git a/tests/fixtures/json/clause7_15.json b/fixtures/json/clause7_15.json
similarity index 100%
rename from tests/fixtures/json/clause7_15.json
rename to fixtures/json/clause7_15.json
diff --git a/tests/fixtures/json/clause7_16.json b/fixtures/json/clause7_16.json
similarity index 100%
rename from tests/fixtures/json/clause7_16.json
rename to fixtures/json/clause7_16.json
diff --git a/tests/fixtures/json/clause7_17.json b/fixtures/json/clause7_17.json
similarity index 100%
rename from tests/fixtures/json/clause7_17.json
rename to fixtures/json/clause7_17.json
diff --git a/tests/fixtures/json/clause7_18.json b/fixtures/json/clause7_18.json
similarity index 100%
rename from tests/fixtures/json/clause7_18.json
rename to fixtures/json/clause7_18.json
diff --git a/tests/fixtures/json/clause7_19.json b/fixtures/json/clause7_19.json
similarity index 100%
rename from tests/fixtures/json/clause7_19.json
rename to fixtures/json/clause7_19.json
diff --git a/tests/fixtures/json/example01.json b/fixtures/json/example01.json
similarity index 100%
rename from tests/fixtures/json/example01.json
rename to fixtures/json/example01.json
diff --git a/tests/fixtures/json/example02.json b/fixtures/json/example02.json
similarity index 100%
rename from tests/fixtures/json/example02.json
rename to fixtures/json/example02.json
diff --git a/tests/fixtures/json/example03.json b/fixtures/json/example03.json
similarity index 100%
rename from tests/fixtures/json/example03.json
rename to fixtures/json/example03.json
diff --git a/tests/fixtures/json/example04.json b/fixtures/json/example04.json
similarity index 100%
rename from tests/fixtures/json/example04.json
rename to fixtures/json/example04.json
diff --git a/tests/fixtures/json/example05a.json b/fixtures/json/example05a.json
similarity index 100%
rename from tests/fixtures/json/example05a.json
rename to fixtures/json/example05a.json
diff --git a/tests/fixtures/json/example05b.json b/fixtures/json/example05b.json
similarity index 100%
rename from tests/fixtures/json/example05b.json
rename to fixtures/json/example05b.json
diff --git a/tests/fixtures/json/example06a.json b/fixtures/json/example06a.json
similarity index 100%
rename from tests/fixtures/json/example06a.json
rename to fixtures/json/example06a.json
diff --git a/tests/fixtures/json/example06b.json b/fixtures/json/example06b.json
similarity index 100%
rename from tests/fixtures/json/example06b.json
rename to fixtures/json/example06b.json
diff --git a/tests/fixtures/json/example07.json b/fixtures/json/example07.json
similarity index 100%
rename from tests/fixtures/json/example07.json
rename to fixtures/json/example07.json
diff --git a/tests/fixtures/json/example08.json b/fixtures/json/example08.json
similarity index 100%
rename from tests/fixtures/json/example08.json
rename to fixtures/json/example08.json
diff --git a/tests/fixtures/json/example09.json b/fixtures/json/example09.json
similarity index 100%
rename from tests/fixtures/json/example09.json
rename to fixtures/json/example09.json
diff --git a/tests/fixtures/json/example10.json b/fixtures/json/example10.json
similarity index 100%
rename from tests/fixtures/json/example10.json
rename to fixtures/json/example10.json
diff --git a/tests/fixtures/json/example11.json b/fixtures/json/example11.json
similarity index 100%
rename from tests/fixtures/json/example11.json
rename to fixtures/json/example11.json
diff --git a/tests/fixtures/json/example12.json b/fixtures/json/example12.json
similarity index 100%
rename from tests/fixtures/json/example12.json
rename to fixtures/json/example12.json
diff --git a/tests/fixtures/json/example13.json b/fixtures/json/example13.json
similarity index 100%
rename from tests/fixtures/json/example13.json
rename to fixtures/json/example13.json
diff --git a/tests/fixtures/json/example14.json b/fixtures/json/example14.json
similarity index 100%
rename from tests/fixtures/json/example14.json
rename to fixtures/json/example14.json
diff --git a/tests/fixtures/json/example15.json b/fixtures/json/example15.json
similarity index 100%
rename from tests/fixtures/json/example15.json
rename to fixtures/json/example15.json
diff --git a/tests/fixtures/json/example16.json b/fixtures/json/example16.json
similarity index 100%
rename from tests/fixtures/json/example16.json
rename to fixtures/json/example16.json
diff --git a/tests/fixtures/json/example17.json b/fixtures/json/example17.json
similarity index 100%
rename from tests/fixtures/json/example17.json
rename to fixtures/json/example17.json
diff --git a/tests/fixtures/json/example18.json b/fixtures/json/example18.json
similarity index 100%
rename from tests/fixtures/json/example18.json
rename to fixtures/json/example18.json
diff --git a/tests/fixtures/json/example19.json b/fixtures/json/example19.json
similarity index 100%
rename from tests/fixtures/json/example19.json
rename to fixtures/json/example19.json
diff --git a/tests/fixtures/json/example20.json b/fixtures/json/example20.json
similarity index 100%
rename from tests/fixtures/json/example20.json
rename to fixtures/json/example20.json
diff --git a/tests/fixtures/json/example21.json b/fixtures/json/example21.json
similarity index 100%
rename from tests/fixtures/json/example21.json
rename to fixtures/json/example21.json
diff --git a/tests/fixtures/json/example22.json b/fixtures/json/example22.json
similarity index 100%
rename from tests/fixtures/json/example22.json
rename to fixtures/json/example22.json
diff --git a/tests/fixtures/json/example23.json b/fixtures/json/example23.json
similarity index 100%
rename from tests/fixtures/json/example23.json
rename to fixtures/json/example23.json
diff --git a/tests/fixtures/json/example24.json b/fixtures/json/example24.json
similarity index 100%
rename from tests/fixtures/json/example24.json
rename to fixtures/json/example24.json
diff --git a/tests/fixtures/json/example25.json b/fixtures/json/example25.json
similarity index 100%
rename from tests/fixtures/json/example25.json
rename to fixtures/json/example25.json
diff --git a/tests/fixtures/json/example26.json b/fixtures/json/example26.json
similarity index 100%
rename from tests/fixtures/json/example26.json
rename to fixtures/json/example26.json
diff --git a/tests/fixtures/json/example27.json b/fixtures/json/example27.json
similarity index 100%
rename from tests/fixtures/json/example27.json
rename to fixtures/json/example27.json
diff --git a/tests/fixtures/json/example28.json b/fixtures/json/example28.json
similarity index 100%
rename from tests/fixtures/json/example28.json
rename to fixtures/json/example28.json
diff --git a/tests/fixtures/json/example29.json b/fixtures/json/example29.json
similarity index 100%
rename from tests/fixtures/json/example29.json
rename to fixtures/json/example29.json
diff --git a/tests/fixtures/json/example30.json b/fixtures/json/example30.json
similarity index 100%
rename from tests/fixtures/json/example30.json
rename to fixtures/json/example30.json
diff --git a/tests/fixtures/json/example31.json b/fixtures/json/example31.json
similarity index 100%
rename from tests/fixtures/json/example31.json
rename to fixtures/json/example31.json
diff --git a/tests/fixtures/json/example32.json b/fixtures/json/example32.json
similarity index 100%
rename from tests/fixtures/json/example32.json
rename to fixtures/json/example32.json
diff --git a/tests/fixtures/json/example33.json b/fixtures/json/example33.json
similarity index 100%
rename from tests/fixtures/json/example33.json
rename to fixtures/json/example33.json
diff --git a/tests/fixtures/json/example34.json b/fixtures/json/example34.json
similarity index 100%
rename from tests/fixtures/json/example34.json
rename to fixtures/json/example34.json
diff --git a/tests/fixtures/json/example35.json b/fixtures/json/example35.json
similarity index 100%
rename from tests/fixtures/json/example35.json
rename to fixtures/json/example35.json
diff --git a/tests/fixtures/json/example36.json b/fixtures/json/example36.json
similarity index 100%
rename from tests/fixtures/json/example36.json
rename to fixtures/json/example36.json
diff --git a/tests/fixtures/json/example37.json b/fixtures/json/example37.json
similarity index 100%
rename from tests/fixtures/json/example37.json
rename to fixtures/json/example37.json
diff --git a/tests/fixtures/json/example38.json b/fixtures/json/example38.json
similarity index 100%
rename from tests/fixtures/json/example38.json
rename to fixtures/json/example38.json
diff --git a/tests/fixtures/json/example39.json b/fixtures/json/example39.json
similarity index 100%
rename from tests/fixtures/json/example39.json
rename to fixtures/json/example39.json
diff --git a/tests/fixtures/json/example40.json b/fixtures/json/example40.json
similarity index 100%
rename from tests/fixtures/json/example40.json
rename to fixtures/json/example40.json
diff --git a/tests/fixtures/json/example41.json b/fixtures/json/example41.json
similarity index 100%
rename from tests/fixtures/json/example41.json
rename to fixtures/json/example41.json
diff --git a/tests/fixtures/json/example42.json b/fixtures/json/example42.json
similarity index 100%
rename from tests/fixtures/json/example42.json
rename to fixtures/json/example42.json
diff --git a/tests/fixtures/json/example43.json b/fixtures/json/example43.json
similarity index 100%
rename from tests/fixtures/json/example43.json
rename to fixtures/json/example43.json
diff --git a/tests/fixtures/json/example44.json b/fixtures/json/example44.json
similarity index 100%
rename from tests/fixtures/json/example44.json
rename to fixtures/json/example44.json
diff --git a/tests/fixtures/json/example45.json b/fixtures/json/example45.json
similarity index 100%
rename from tests/fixtures/json/example45.json
rename to fixtures/json/example45.json
diff --git a/tests/fixtures/json/example46.json b/fixtures/json/example46.json
similarity index 100%
rename from tests/fixtures/json/example46.json
rename to fixtures/json/example46.json
diff --git a/tests/fixtures/json/example47.json b/fixtures/json/example47.json
similarity index 100%
rename from tests/fixtures/json/example47.json
rename to fixtures/json/example47.json
diff --git a/tests/fixtures/json/example48.json b/fixtures/json/example48.json
similarity index 100%
rename from tests/fixtures/json/example48.json
rename to fixtures/json/example48.json
diff --git a/tests/fixtures/json/example49.json b/fixtures/json/example49.json
similarity index 100%
rename from tests/fixtures/json/example49.json
rename to fixtures/json/example49.json
diff --git a/tests/fixtures/json/example50.json b/fixtures/json/example50.json
similarity index 100%
rename from tests/fixtures/json/example50.json
rename to fixtures/json/example50.json
diff --git a/tests/fixtures/json/example51.json b/fixtures/json/example51.json
similarity index 100%
rename from tests/fixtures/json/example51.json
rename to fixtures/json/example51.json
diff --git a/tests/fixtures/json/example52.json b/fixtures/json/example52.json
similarity index 100%
rename from tests/fixtures/json/example52.json
rename to fixtures/json/example52.json
diff --git a/tests/fixtures/json/example53.json b/fixtures/json/example53.json
similarity index 100%
rename from tests/fixtures/json/example53.json
rename to fixtures/json/example53.json
diff --git a/tests/fixtures/json/example54.json b/fixtures/json/example54.json
similarity index 100%
rename from tests/fixtures/json/example54.json
rename to fixtures/json/example54.json
diff --git a/tests/fixtures/json/example55.json b/fixtures/json/example55.json
similarity index 100%
rename from tests/fixtures/json/example55.json
rename to fixtures/json/example55.json
diff --git a/tests/fixtures/json/example56.json b/fixtures/json/example56.json
similarity index 100%
rename from tests/fixtures/json/example56.json
rename to fixtures/json/example56.json
diff --git a/tests/fixtures/json/example57.json b/fixtures/json/example57.json
similarity index 100%
rename from tests/fixtures/json/example57.json
rename to fixtures/json/example57.json
diff --git a/tests/fixtures/json/example58.json b/fixtures/json/example58.json
similarity index 100%
rename from tests/fixtures/json/example58.json
rename to fixtures/json/example58.json
diff --git a/tests/fixtures/json/example59.json b/fixtures/json/example59.json
similarity index 100%
rename from tests/fixtures/json/example59.json
rename to fixtures/json/example59.json
diff --git a/tests/fixtures/json/example60.json b/fixtures/json/example60.json
similarity index 100%
rename from tests/fixtures/json/example60.json
rename to fixtures/json/example60.json
diff --git a/tests/fixtures/json/example61.json b/fixtures/json/example61.json
similarity index 100%
rename from tests/fixtures/json/example61.json
rename to fixtures/json/example61.json
diff --git a/tests/fixtures/json/example62.json b/fixtures/json/example62.json
similarity index 100%
rename from tests/fixtures/json/example62.json
rename to fixtures/json/example62.json
diff --git a/tests/fixtures/json/example63.json b/fixtures/json/example63.json
similarity index 100%
rename from tests/fixtures/json/example63.json
rename to fixtures/json/example63.json
diff --git a/tests/fixtures/json/example64.json b/fixtures/json/example64.json
similarity index 100%
rename from tests/fixtures/json/example64.json
rename to fixtures/json/example64.json
diff --git a/tests/fixtures/json/example65.json b/fixtures/json/example65.json
similarity index 100%
rename from tests/fixtures/json/example65.json
rename to fixtures/json/example65.json
diff --git a/tests/fixtures/json/example66.json b/fixtures/json/example66.json
similarity index 100%
rename from tests/fixtures/json/example66.json
rename to fixtures/json/example66.json
diff --git a/tests/fixtures/json/example67.json b/fixtures/json/example67.json
similarity index 100%
rename from tests/fixtures/json/example67.json
rename to fixtures/json/example67.json
diff --git a/tests/fixtures/json/example68.json b/fixtures/json/example68.json
similarity index 100%
rename from tests/fixtures/json/example68.json
rename to fixtures/json/example68.json
diff --git a/tests/fixtures/json/example69.json b/fixtures/json/example69.json
similarity index 100%
rename from tests/fixtures/json/example69.json
rename to fixtures/json/example69.json
diff --git a/tests/fixtures/json/example70.json b/fixtures/json/example70.json
similarity index 100%
rename from tests/fixtures/json/example70.json
rename to fixtures/json/example70.json
diff --git a/tests/fixtures/json/example71.json b/fixtures/json/example71.json
similarity index 100%
rename from tests/fixtures/json/example71.json
rename to fixtures/json/example71.json
diff --git a/tests/fixtures/json/example72.json b/fixtures/json/example72.json
similarity index 100%
rename from tests/fixtures/json/example72.json
rename to fixtures/json/example72.json
diff --git a/tests/fixtures/json/example73.json b/fixtures/json/example73.json
similarity index 100%
rename from tests/fixtures/json/example73.json
rename to fixtures/json/example73.json
diff --git a/tests/fixtures/json/example74.json b/fixtures/json/example74.json
similarity index 100%
rename from tests/fixtures/json/example74.json
rename to fixtures/json/example74.json
diff --git a/tests/fixtures/json/example75.json b/fixtures/json/example75.json
similarity index 100%
rename from tests/fixtures/json/example75.json
rename to fixtures/json/example75.json
diff --git a/tests/fixtures/json/example76.json b/fixtures/json/example76.json
similarity index 100%
rename from tests/fixtures/json/example76.json
rename to fixtures/json/example76.json
diff --git a/tests/fixtures/json/example77.json b/fixtures/json/example77.json
similarity index 100%
rename from tests/fixtures/json/example77.json
rename to fixtures/json/example77.json
diff --git a/tests/fixtures/json/example78.json b/fixtures/json/example78.json
similarity index 100%
rename from tests/fixtures/json/example78.json
rename to fixtures/json/example78.json
diff --git a/tests/fixtures/json/example79.json b/fixtures/json/example79.json
similarity index 100%
rename from tests/fixtures/json/example79.json
rename to fixtures/json/example79.json
diff --git a/tests/fixtures/json/example80.json b/fixtures/json/example80.json
similarity index 100%
rename from tests/fixtures/json/example80.json
rename to fixtures/json/example80.json
diff --git a/tests/fixtures/json/example81.json b/fixtures/json/example81.json
similarity index 100%
rename from tests/fixtures/json/example81.json
rename to fixtures/json/example81.json
diff --git a/tests/fixtures/json/example82.json b/fixtures/json/example82.json
similarity index 100%
rename from tests/fixtures/json/example82.json
rename to fixtures/json/example82.json
diff --git a/tests/fixtures/json/example83.json b/fixtures/json/example83.json
similarity index 100%
rename from tests/fixtures/json/example83.json
rename to fixtures/json/example83.json
diff --git a/tests/fixtures/json/example84.json b/fixtures/json/example84.json
similarity index 100%
rename from tests/fixtures/json/example84.json
rename to fixtures/json/example84.json
diff --git a/tests/fixtures/json/example85.json b/fixtures/json/example85.json
similarity index 100%
rename from tests/fixtures/json/example85.json
rename to fixtures/json/example85.json
diff --git a/tests/fixtures/json/example86.json b/fixtures/json/example86.json
similarity index 100%
rename from tests/fixtures/json/example86.json
rename to fixtures/json/example86.json
diff --git a/tests/fixtures/json/validate.sh b/fixtures/json/validate.sh
similarity index 100%
rename from tests/fixtures/json/validate.sh
rename to fixtures/json/validate.sh
diff --git a/tests/fixtures/text/clause6_01.txt b/fixtures/text/clause6_01.txt
similarity index 100%
rename from tests/fixtures/text/clause6_01.txt
rename to fixtures/text/clause6_01.txt
diff --git a/tests/fixtures/text/clause6_02a.txt b/fixtures/text/clause6_02a.txt
similarity index 100%
rename from tests/fixtures/text/clause6_02a.txt
rename to fixtures/text/clause6_02a.txt
diff --git a/tests/fixtures/text/clause6_02b.txt b/fixtures/text/clause6_02b.txt
similarity index 100%
rename from tests/fixtures/text/clause6_02b.txt
rename to fixtures/text/clause6_02b.txt
diff --git a/tests/fixtures/text/clause6_02c.txt b/fixtures/text/clause6_02c.txt
similarity index 100%
rename from tests/fixtures/text/clause6_02c.txt
rename to fixtures/text/clause6_02c.txt
diff --git a/tests/fixtures/text/clause6_02d.txt b/fixtures/text/clause6_02d.txt
similarity index 100%
rename from tests/fixtures/text/clause6_02d.txt
rename to fixtures/text/clause6_02d.txt
diff --git a/tests/fixtures/text/clause6_03.txt b/fixtures/text/clause6_03.txt
similarity index 100%
rename from tests/fixtures/text/clause6_03.txt
rename to fixtures/text/clause6_03.txt
diff --git a/tests/fixtures/text/clause7_01.txt b/fixtures/text/clause7_01.txt
similarity index 100%
rename from tests/fixtures/text/clause7_01.txt
rename to fixtures/text/clause7_01.txt
diff --git a/tests/fixtures/text/clause7_02.txt b/fixtures/text/clause7_02.txt
similarity index 100%
rename from tests/fixtures/text/clause7_02.txt
rename to fixtures/text/clause7_02.txt
diff --git a/tests/fixtures/text/clause7_03a.txt b/fixtures/text/clause7_03a.txt
similarity index 100%
rename from tests/fixtures/text/clause7_03a.txt
rename to fixtures/text/clause7_03a.txt
diff --git a/tests/fixtures/text/clause7_03b.txt b/fixtures/text/clause7_03b.txt
similarity index 100%
rename from tests/fixtures/text/clause7_03b.txt
rename to fixtures/text/clause7_03b.txt
diff --git a/tests/fixtures/text/clause7_04.txt b/fixtures/text/clause7_04.txt
similarity index 100%
rename from tests/fixtures/text/clause7_04.txt
rename to fixtures/text/clause7_04.txt
diff --git a/tests/fixtures/text/clause7_05.txt b/fixtures/text/clause7_05.txt
similarity index 100%
rename from tests/fixtures/text/clause7_05.txt
rename to fixtures/text/clause7_05.txt
diff --git a/tests/fixtures/text/clause7_07.txt b/fixtures/text/clause7_07.txt
similarity index 100%
rename from tests/fixtures/text/clause7_07.txt
rename to fixtures/text/clause7_07.txt
diff --git a/tests/fixtures/text/clause7_10.txt b/fixtures/text/clause7_10.txt
similarity index 100%
rename from tests/fixtures/text/clause7_10.txt
rename to fixtures/text/clause7_10.txt
diff --git a/tests/fixtures/text/clause7_12.txt b/fixtures/text/clause7_12.txt
similarity index 100%
rename from tests/fixtures/text/clause7_12.txt
rename to fixtures/text/clause7_12.txt
diff --git a/tests/fixtures/text/clause7_13.txt b/fixtures/text/clause7_13.txt
similarity index 100%
rename from tests/fixtures/text/clause7_13.txt
rename to fixtures/text/clause7_13.txt
diff --git a/tests/fixtures/text/clause7_15.txt b/fixtures/text/clause7_15.txt
similarity index 100%
rename from tests/fixtures/text/clause7_15.txt
rename to fixtures/text/clause7_15.txt
diff --git a/tests/fixtures/text/clause7_16.txt b/fixtures/text/clause7_16.txt
similarity index 100%
rename from tests/fixtures/text/clause7_16.txt
rename to fixtures/text/clause7_16.txt
diff --git a/tests/fixtures/text/clause7_17.txt b/fixtures/text/clause7_17.txt
similarity index 100%
rename from tests/fixtures/text/clause7_17.txt
rename to fixtures/text/clause7_17.txt
diff --git a/tests/fixtures/text/clause7_18.txt b/fixtures/text/clause7_18.txt
similarity index 100%
rename from tests/fixtures/text/clause7_18.txt
rename to fixtures/text/clause7_18.txt
diff --git a/tests/fixtures/text/clause7_19.txt b/fixtures/text/clause7_19.txt
similarity index 100%
rename from tests/fixtures/text/clause7_19.txt
rename to fixtures/text/clause7_19.txt
diff --git a/tests/fixtures/text/example01.txt b/fixtures/text/example01.txt
similarity index 100%
rename from tests/fixtures/text/example01.txt
rename to fixtures/text/example01.txt
diff --git a/tests/fixtures/text/example02.txt b/fixtures/text/example02.txt
similarity index 100%
rename from tests/fixtures/text/example02.txt
rename to fixtures/text/example02.txt
diff --git a/tests/fixtures/text/example03.txt b/fixtures/text/example03.txt
similarity index 100%
rename from tests/fixtures/text/example03.txt
rename to fixtures/text/example03.txt
diff --git a/tests/fixtures/text/example04.txt b/fixtures/text/example04.txt
similarity index 100%
rename from tests/fixtures/text/example04.txt
rename to fixtures/text/example04.txt
diff --git a/tests/fixtures/text/example05a.txt b/fixtures/text/example05a.txt
similarity index 100%
rename from tests/fixtures/text/example05a.txt
rename to fixtures/text/example05a.txt
diff --git a/tests/fixtures/text/example05b.txt b/fixtures/text/example05b.txt
similarity index 100%
rename from tests/fixtures/text/example05b.txt
rename to fixtures/text/example05b.txt
diff --git a/tests/fixtures/text/example06a.txt b/fixtures/text/example06a.txt
similarity index 100%
rename from tests/fixtures/text/example06a.txt
rename to fixtures/text/example06a.txt
diff --git a/tests/fixtures/text/example06b.txt b/fixtures/text/example06b.txt
similarity index 100%
rename from tests/fixtures/text/example06b.txt
rename to fixtures/text/example06b.txt
diff --git a/tests/fixtures/text/example07.txt b/fixtures/text/example07.txt
similarity index 100%
rename from tests/fixtures/text/example07.txt
rename to fixtures/text/example07.txt
diff --git a/tests/fixtures/text/example08.txt b/fixtures/text/example08.txt
similarity index 100%
rename from tests/fixtures/text/example08.txt
rename to fixtures/text/example08.txt
diff --git a/tests/fixtures/text/example09.txt b/fixtures/text/example09.txt
similarity index 100%
rename from tests/fixtures/text/example09.txt
rename to fixtures/text/example09.txt
diff --git a/tests/fixtures/text/example10.txt b/fixtures/text/example10.txt
similarity index 100%
rename from tests/fixtures/text/example10.txt
rename to fixtures/text/example10.txt
diff --git a/tests/fixtures/text/example11.txt b/fixtures/text/example11.txt
similarity index 100%
rename from tests/fixtures/text/example11.txt
rename to fixtures/text/example11.txt
diff --git a/tests/fixtures/text/example12.txt b/fixtures/text/example12.txt
similarity index 100%
rename from tests/fixtures/text/example12.txt
rename to fixtures/text/example12.txt
diff --git a/tests/fixtures/text/example13.txt b/fixtures/text/example13.txt
similarity index 100%
rename from tests/fixtures/text/example13.txt
rename to fixtures/text/example13.txt
diff --git a/tests/fixtures/text/example14.txt b/fixtures/text/example14.txt
similarity index 100%
rename from tests/fixtures/text/example14.txt
rename to fixtures/text/example14.txt
diff --git a/tests/fixtures/text/example15.txt b/fixtures/text/example15.txt
similarity index 100%
rename from tests/fixtures/text/example15.txt
rename to fixtures/text/example15.txt
diff --git a/tests/fixtures/text/example16.txt b/fixtures/text/example16.txt
similarity index 100%
rename from tests/fixtures/text/example16.txt
rename to fixtures/text/example16.txt
diff --git a/tests/fixtures/text/example17.txt b/fixtures/text/example17.txt
similarity index 100%
rename from tests/fixtures/text/example17.txt
rename to fixtures/text/example17.txt
diff --git a/tests/fixtures/text/example18.txt b/fixtures/text/example18.txt
similarity index 100%
rename from tests/fixtures/text/example18.txt
rename to fixtures/text/example18.txt
diff --git a/tests/fixtures/text/example19.txt b/fixtures/text/example19.txt
similarity index 100%
rename from tests/fixtures/text/example19.txt
rename to fixtures/text/example19.txt
diff --git a/tests/fixtures/text/example20.txt b/fixtures/text/example20.txt
similarity index 100%
rename from tests/fixtures/text/example20.txt
rename to fixtures/text/example20.txt
diff --git a/tests/fixtures/text/example21.txt b/fixtures/text/example21.txt
similarity index 100%
rename from tests/fixtures/text/example21.txt
rename to fixtures/text/example21.txt
diff --git a/tests/fixtures/text/example22.txt b/fixtures/text/example22.txt
similarity index 100%
rename from tests/fixtures/text/example22.txt
rename to fixtures/text/example22.txt
diff --git a/tests/fixtures/text/example23.txt b/fixtures/text/example23.txt
similarity index 100%
rename from tests/fixtures/text/example23.txt
rename to fixtures/text/example23.txt
diff --git a/tests/fixtures/text/example24.txt b/fixtures/text/example24.txt
similarity index 100%
rename from tests/fixtures/text/example24.txt
rename to fixtures/text/example24.txt
diff --git a/tests/fixtures/text/example25.txt b/fixtures/text/example25.txt
similarity index 100%
rename from tests/fixtures/text/example25.txt
rename to fixtures/text/example25.txt
diff --git a/tests/fixtures/text/example26.txt b/fixtures/text/example26.txt
similarity index 100%
rename from tests/fixtures/text/example26.txt
rename to fixtures/text/example26.txt
diff --git a/tests/fixtures/text/example27.txt b/fixtures/text/example27.txt
similarity index 100%
rename from tests/fixtures/text/example27.txt
rename to fixtures/text/example27.txt
diff --git a/tests/fixtures/text/example28.txt b/fixtures/text/example28.txt
similarity index 100%
rename from tests/fixtures/text/example28.txt
rename to fixtures/text/example28.txt
diff --git a/tests/fixtures/text/example29.txt b/fixtures/text/example29.txt
similarity index 100%
rename from tests/fixtures/text/example29.txt
rename to fixtures/text/example29.txt
diff --git a/tests/fixtures/text/example30.txt b/fixtures/text/example30.txt
similarity index 100%
rename from tests/fixtures/text/example30.txt
rename to fixtures/text/example30.txt
diff --git a/tests/fixtures/text/example31.txt b/fixtures/text/example31.txt
similarity index 100%
rename from tests/fixtures/text/example31.txt
rename to fixtures/text/example31.txt
diff --git a/tests/fixtures/text/example32.txt b/fixtures/text/example32.txt
similarity index 100%
rename from tests/fixtures/text/example32.txt
rename to fixtures/text/example32.txt
diff --git a/tests/fixtures/text/example33.txt b/fixtures/text/example33.txt
similarity index 100%
rename from tests/fixtures/text/example33.txt
rename to fixtures/text/example33.txt
diff --git a/tests/fixtures/text/example34.txt b/fixtures/text/example34.txt
similarity index 100%
rename from tests/fixtures/text/example34.txt
rename to fixtures/text/example34.txt
diff --git a/tests/fixtures/text/example35.txt b/fixtures/text/example35.txt
similarity index 100%
rename from tests/fixtures/text/example35.txt
rename to fixtures/text/example35.txt
diff --git a/tests/fixtures/text/example36-alt01.txt b/fixtures/text/example36-alt01.txt
similarity index 100%
rename from tests/fixtures/text/example36-alt01.txt
rename to fixtures/text/example36-alt01.txt
diff --git a/tests/fixtures/text/example36.txt b/fixtures/text/example36.txt
similarity index 100%
rename from tests/fixtures/text/example36.txt
rename to fixtures/text/example36.txt
diff --git a/tests/fixtures/text/example37.txt b/fixtures/text/example37.txt
similarity index 100%
rename from tests/fixtures/text/example37.txt
rename to fixtures/text/example37.txt
diff --git a/tests/fixtures/text/example38-alt01.txt b/fixtures/text/example38-alt01.txt
similarity index 100%
rename from tests/fixtures/text/example38-alt01.txt
rename to fixtures/text/example38-alt01.txt
diff --git a/tests/fixtures/text/example38.txt b/fixtures/text/example38.txt
similarity index 100%
rename from tests/fixtures/text/example38.txt
rename to fixtures/text/example38.txt
diff --git a/tests/fixtures/text/example39.txt b/fixtures/text/example39.txt
similarity index 100%
rename from tests/fixtures/text/example39.txt
rename to fixtures/text/example39.txt
diff --git a/tests/fixtures/text/example40-alt01.txt b/fixtures/text/example40-alt01.txt
similarity index 100%
rename from tests/fixtures/text/example40-alt01.txt
rename to fixtures/text/example40-alt01.txt
diff --git a/tests/fixtures/text/example40.txt b/fixtures/text/example40.txt
similarity index 100%
rename from tests/fixtures/text/example40.txt
rename to fixtures/text/example40.txt
diff --git a/tests/fixtures/text/example41.txt b/fixtures/text/example41.txt
similarity index 100%
rename from tests/fixtures/text/example41.txt
rename to fixtures/text/example41.txt
diff --git a/tests/fixtures/text/example42-alt01.txt b/fixtures/text/example42-alt01.txt
similarity index 100%
rename from tests/fixtures/text/example42-alt01.txt
rename to fixtures/text/example42-alt01.txt
diff --git a/tests/fixtures/text/example42.txt b/fixtures/text/example42.txt
similarity index 100%
rename from tests/fixtures/text/example42.txt
rename to fixtures/text/example42.txt
diff --git a/tests/fixtures/text/example43-alt01.txt b/fixtures/text/example43-alt01.txt
similarity index 100%
rename from tests/fixtures/text/example43-alt01.txt
rename to fixtures/text/example43-alt01.txt
diff --git a/tests/fixtures/text/example43.txt b/fixtures/text/example43.txt
similarity index 100%
rename from tests/fixtures/text/example43.txt
rename to fixtures/text/example43.txt
diff --git a/tests/fixtures/text/example44-alt01.txt b/fixtures/text/example44-alt01.txt
similarity index 100%
rename from tests/fixtures/text/example44-alt01.txt
rename to fixtures/text/example44-alt01.txt
diff --git a/tests/fixtures/text/example44.txt b/fixtures/text/example44.txt
similarity index 100%
rename from tests/fixtures/text/example44.txt
rename to fixtures/text/example44.txt
diff --git a/tests/fixtures/text/example45.txt b/fixtures/text/example45.txt
similarity index 100%
rename from tests/fixtures/text/example45.txt
rename to fixtures/text/example45.txt
diff --git a/tests/fixtures/text/example46-alt01.txt b/fixtures/text/example46-alt01.txt
similarity index 100%
rename from tests/fixtures/text/example46-alt01.txt
rename to fixtures/text/example46-alt01.txt
diff --git a/tests/fixtures/text/example46.txt b/fixtures/text/example46.txt
similarity index 100%
rename from tests/fixtures/text/example46.txt
rename to fixtures/text/example46.txt
diff --git a/tests/fixtures/text/example47.txt b/fixtures/text/example47.txt
similarity index 100%
rename from tests/fixtures/text/example47.txt
rename to fixtures/text/example47.txt
diff --git a/tests/fixtures/text/example48.txt b/fixtures/text/example48.txt
similarity index 100%
rename from tests/fixtures/text/example48.txt
rename to fixtures/text/example48.txt
diff --git a/tests/fixtures/text/example49-alt01.txt b/fixtures/text/example49-alt01.txt
similarity index 100%
rename from tests/fixtures/text/example49-alt01.txt
rename to fixtures/text/example49-alt01.txt
diff --git a/tests/fixtures/text/example49.txt b/fixtures/text/example49.txt
similarity index 100%
rename from tests/fixtures/text/example49.txt
rename to fixtures/text/example49.txt
diff --git a/tests/fixtures/text/example50.txt b/fixtures/text/example50.txt
similarity index 100%
rename from tests/fixtures/text/example50.txt
rename to fixtures/text/example50.txt
diff --git a/tests/fixtures/text/example51.txt b/fixtures/text/example51.txt
similarity index 100%
rename from tests/fixtures/text/example51.txt
rename to fixtures/text/example51.txt
diff --git a/tests/fixtures/text/example52.txt b/fixtures/text/example52.txt
similarity index 100%
rename from tests/fixtures/text/example52.txt
rename to fixtures/text/example52.txt
diff --git a/tests/fixtures/text/example53.txt b/fixtures/text/example53.txt
similarity index 100%
rename from tests/fixtures/text/example53.txt
rename to fixtures/text/example53.txt
diff --git a/tests/fixtures/text/example54-alt01.txt b/fixtures/text/example54-alt01.txt
similarity index 100%
rename from tests/fixtures/text/example54-alt01.txt
rename to fixtures/text/example54-alt01.txt
diff --git a/tests/fixtures/text/example54.txt b/fixtures/text/example54.txt
similarity index 100%
rename from tests/fixtures/text/example54.txt
rename to fixtures/text/example54.txt
diff --git a/tests/fixtures/text/example55-alt01.txt b/fixtures/text/example55-alt01.txt
similarity index 100%
rename from tests/fixtures/text/example55-alt01.txt
rename to fixtures/text/example55-alt01.txt
diff --git a/tests/fixtures/text/example55.txt b/fixtures/text/example55.txt
similarity index 100%
rename from tests/fixtures/text/example55.txt
rename to fixtures/text/example55.txt
diff --git a/tests/fixtures/text/example56.txt b/fixtures/text/example56.txt
similarity index 100%
rename from tests/fixtures/text/example56.txt
rename to fixtures/text/example56.txt
diff --git a/tests/fixtures/text/example57.txt b/fixtures/text/example57.txt
similarity index 100%
rename from tests/fixtures/text/example57.txt
rename to fixtures/text/example57.txt
diff --git a/tests/fixtures/text/example58.txt b/fixtures/text/example58.txt
similarity index 100%
rename from tests/fixtures/text/example58.txt
rename to fixtures/text/example58.txt
diff --git a/tests/fixtures/text/example59.txt b/fixtures/text/example59.txt
similarity index 100%
rename from tests/fixtures/text/example59.txt
rename to fixtures/text/example59.txt
diff --git a/tests/fixtures/text/example60.txt b/fixtures/text/example60.txt
similarity index 100%
rename from tests/fixtures/text/example60.txt
rename to fixtures/text/example60.txt
diff --git a/tests/fixtures/text/example61.txt b/fixtures/text/example61.txt
similarity index 100%
rename from tests/fixtures/text/example61.txt
rename to fixtures/text/example61.txt
diff --git a/tests/fixtures/text/example62.txt b/fixtures/text/example62.txt
similarity index 100%
rename from tests/fixtures/text/example62.txt
rename to fixtures/text/example62.txt
diff --git a/tests/fixtures/text/example63.txt b/fixtures/text/example63.txt
similarity index 100%
rename from tests/fixtures/text/example63.txt
rename to fixtures/text/example63.txt
diff --git a/tests/fixtures/text/example64.txt b/fixtures/text/example64.txt
similarity index 100%
rename from tests/fixtures/text/example64.txt
rename to fixtures/text/example64.txt
diff --git a/tests/fixtures/text/example65.txt b/fixtures/text/example65.txt
similarity index 100%
rename from tests/fixtures/text/example65.txt
rename to fixtures/text/example65.txt
diff --git a/tests/fixtures/text/example66.txt b/fixtures/text/example66.txt
similarity index 100%
rename from tests/fixtures/text/example66.txt
rename to fixtures/text/example66.txt
diff --git a/tests/fixtures/text/example67.txt b/fixtures/text/example67.txt
similarity index 100%
rename from tests/fixtures/text/example67.txt
rename to fixtures/text/example67.txt
diff --git a/tests/fixtures/text/example68.txt b/fixtures/text/example68.txt
similarity index 100%
rename from tests/fixtures/text/example68.txt
rename to fixtures/text/example68.txt
diff --git a/tests/fixtures/text/example69.txt b/fixtures/text/example69.txt
similarity index 100%
rename from tests/fixtures/text/example69.txt
rename to fixtures/text/example69.txt
diff --git a/tests/fixtures/text/example70.txt b/fixtures/text/example70.txt
similarity index 100%
rename from tests/fixtures/text/example70.txt
rename to fixtures/text/example70.txt
diff --git a/tests/fixtures/text/example71.txt b/fixtures/text/example71.txt
similarity index 100%
rename from tests/fixtures/text/example71.txt
rename to fixtures/text/example71.txt
diff --git a/tests/fixtures/text/example72.txt b/fixtures/text/example72.txt
similarity index 100%
rename from tests/fixtures/text/example72.txt
rename to fixtures/text/example72.txt
diff --git a/tests/fixtures/text/example73.txt b/fixtures/text/example73.txt
similarity index 100%
rename from tests/fixtures/text/example73.txt
rename to fixtures/text/example73.txt
diff --git a/tests/fixtures/text/example74.txt b/fixtures/text/example74.txt
similarity index 100%
rename from tests/fixtures/text/example74.txt
rename to fixtures/text/example74.txt
diff --git a/tests/fixtures/text/example75.txt b/fixtures/text/example75.txt
similarity index 100%
rename from tests/fixtures/text/example75.txt
rename to fixtures/text/example75.txt
diff --git a/tests/fixtures/text/example76.txt b/fixtures/text/example76.txt
similarity index 100%
rename from tests/fixtures/text/example76.txt
rename to fixtures/text/example76.txt
diff --git a/tests/fixtures/text/example77.txt b/fixtures/text/example77.txt
similarity index 100%
rename from tests/fixtures/text/example77.txt
rename to fixtures/text/example77.txt
diff --git a/tests/fixtures/text/example78.txt b/fixtures/text/example78.txt
similarity index 100%
rename from tests/fixtures/text/example78.txt
rename to fixtures/text/example78.txt
diff --git a/tests/fixtures/text/example79.txt b/fixtures/text/example79.txt
similarity index 100%
rename from tests/fixtures/text/example79.txt
rename to fixtures/text/example79.txt
diff --git a/tests/fixtures/text/example80.txt b/fixtures/text/example80.txt
similarity index 100%
rename from tests/fixtures/text/example80.txt
rename to fixtures/text/example80.txt
diff --git a/tests/fixtures/text/example81.txt b/fixtures/text/example81.txt
similarity index 100%
rename from tests/fixtures/text/example81.txt
rename to fixtures/text/example81.txt
diff --git a/tests/fixtures/text/example82.txt b/fixtures/text/example82.txt
similarity index 100%
rename from tests/fixtures/text/example82.txt
rename to fixtures/text/example82.txt
diff --git a/tests/fixtures/text/example83.txt b/fixtures/text/example83.txt
similarity index 100%
rename from tests/fixtures/text/example83.txt
rename to fixtures/text/example83.txt
diff --git a/tests/fixtures/text/example84.txt b/fixtures/text/example84.txt
similarity index 100%
rename from tests/fixtures/text/example84.txt
rename to fixtures/text/example84.txt
diff --git a/tests/fixtures/text/example85-alt01.txt b/fixtures/text/example85-alt01.txt
similarity index 100%
rename from tests/fixtures/text/example85-alt01.txt
rename to fixtures/text/example85-alt01.txt
diff --git a/tests/fixtures/text/example85.txt b/fixtures/text/example85.txt
similarity index 100%
rename from tests/fixtures/text/example85.txt
rename to fixtures/text/example85.txt
diff --git a/tests/fixtures/text/example86.txt b/fixtures/text/example86.txt
similarity index 100%
rename from tests/fixtures/text/example86.txt
rename to fixtures/text/example86.txt
diff --git a/python/.gitignore b/python/.gitignore
new file mode 100644
index 0000000..93f457d
--- /dev/null
+++ b/python/.gitignore
@@ -0,0 +1,69 @@
+/target
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+.pytest_cache/
+*.py[cod]
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+.venv/
+env/
+bin/
+build/
+develop-eggs/
+dist/
+eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+include/
+man/
+venv/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+pip-selfcheck.json
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.cache
+nosetests.xml
+coverage.xml
+
+# Translations
+*.mo
+
+# Mr Developer
+.mr.developer.cfg
+.project
+.pydevproject
+
+# Rope
+.ropeproject
+
+# Django stuff:
+*.log
+*.pot
+
+.DS_Store
+
+# Sphinx documentation
+docs/_build/
+
+# PyCharm
+.idea/
+
+# VSCode
+.vscode/
diff --git a/python/.python-version b/python/.python-version
new file mode 100644
index 0000000..bd28b9c
--- /dev/null
+++ b/python/.python-version
@@ -0,0 +1,1 @@
+3.9
diff --git a/python/Cargo.toml b/python/Cargo.toml
new file mode 100644
index 0000000..0f6d970
--- /dev/null
+++ b/python/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "cql2-python"
+version = "0.1.0"
+authors = ["David Bitner <bitner@dbspatial.com>"]
+edition = "2021"
+description = "Parse, validate, and convert Common Query Language (CQL2) text and JSON"
+readme = "README.md"
+homepage = "https://github.com/developmentseed/cql2-rs"
+repository = "https://github.com/developmentseed/cql2-rs"
+license = "MIT"
+keywords = ["cql2"]
+
+[lib]
+name = "cql2"
+crate-type = ["cdylib"]
+
+[dependencies]
+cql2 = { path = ".." }
+pyo3 = { version = "0.22.0", features = ["extension-module"] }
+pythonize = "0.22.0"
diff --git a/python/README.md b/python/README.md
new file mode 100644
index 0000000..27f3933
--- /dev/null
+++ b/python/README.md
@@ -0,0 +1,35 @@
+# cql2
+
+Python library for parsing and converting [Common Query Language (CQL2)](https://www.ogc.org/standard/cql2/), with Rust under the hood.
+
+## Usage
+
+```shell
+pip install cql2
+```
+
+Then:
+
+```python
+expr = Expr("landsat:scene_id = 'LC82030282019133LGN00'")
+# or
+expr = Expr.from_path("fixtures/text/example01.txt")
+
+s = expr.to_text()
+d = expr.to_json()
+sql = expr.to_sql()
+print("SQL query:", sql.query)
+print("SQL params:", sql.params)
+```
+
+## Developing
+
+To install the package to your virtual environment and test:
+
+```shell
+maturin develop --uv -m python/Cargo.toml && pytest python
+```
+
+## More information
+
+This package is part of [cql2-rs](https://github.com/developmentseed/cql2-rs/), see that repo for license and contributing information.
diff --git a/python/cql2.pyi b/python/cql2.pyi
new file mode 100644
index 0000000..0beab85
--- /dev/null
+++ b/python/cql2.pyi
@@ -0,0 +1,14 @@
+from typing import Any
+from os import PathLike
+
+class SqlQuery:
+    query: str
+    params: list[str]
+
+class Expr:
+    @staticmethod
+    def from_path(path: PathLike) -> Expr: ...
+    def __init__(self, cql2: str | dict[str, Any]) -> None: ...
+    def to_json(self) -> dict[str, Any]: ...
+    def to_text(self) -> str: ...
+    def to_sql(self) -> SqlQuery: ...
diff --git a/python/pyproject.toml b/python/pyproject.toml
new file mode 100644
index 0000000..e0b5929
--- /dev/null
+++ b/python/pyproject.toml
@@ -0,0 +1,16 @@
+[build-system]
+requires = ["maturin>=1.7,<2.0"]
+build-backend = "maturin"
+
+[project]
+name = "cql2"
+requires-python = ">=3.9"
+classifiers = [
+    "Programming Language :: Rust",
+    "Programming Language :: Python :: Implementation :: CPython",
+    "Programming Language :: Python :: Implementation :: PyPy",
+]
+dynamic = ["version"]
+
+[tool.uv]
+dev-dependencies = ["mypy", "pytest", "ruff"]
diff --git a/python/src/lib.rs b/python/src/lib.rs
new file mode 100644
index 0000000..520e589
--- /dev/null
+++ b/python/src/lib.rs
@@ -0,0 +1,86 @@
+use pyo3::{
+    exceptions::{PyException, PyIOError, PyValueError},
+    prelude::*,
+    types::PyDict,
+};
+use std::path::PathBuf;
+
+/// A CQL2 expression.
+#[pyclass]
+struct Expr(::cql2::Expr);
+
+/// A SQL query
+#[pyclass]
+struct SqlQuery {
+    #[pyo3(get)]
+    query: String,
+
+    #[pyo3(get)]
+    params: Vec<String>,
+}
+
+#[pymethods]
+impl Expr {
+    /// Parses a CQL2 expression from a file path.
+    #[staticmethod]
+    fn from_path(path: PathBuf) -> PyResult<Expr> {
+        ::cql2::parse_file(path).map(Expr).map_err(to_py_error)
+    }
+
+    /// Parses a CQL2 expression.
+    #[new]
+    fn new(cql2: Bound<'_, PyAny>) -> PyResult<Self> {
+        if let Ok(s) = cql2.extract::<&str>() {
+            s.parse().map(Expr).map_err(to_py_error)
+        } else {
+            let expr: ::cql2::Expr = pythonize::depythonize(&cql2)?;
+            Ok(Expr(expr))
+        }
+    }
+
+    /// Converts this expression to a cql2-json dictionary.
+    fn to_json<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
+        pythonize::pythonize(py, &self.0).map_err(PyErr::from)
+    }
+
+    /// Converts this expression to cql2-text.
+    fn to_text<'py>(&self) -> PyResult<String> {
+        self.0.to_text().map_err(to_py_error)
+    }
+
+    /// Converts this expression to SQL query.
+    fn to_sql(&self) -> PyResult<SqlQuery> {
+        self.0.to_sql().map(SqlQuery::from).map_err(to_py_error)
+    }
+}
+
+impl From<::cql2::SqlQuery> for SqlQuery {
+    fn from(value: ::cql2::SqlQuery) -> Self {
+        SqlQuery {
+            query: value.query,
+            params: value.params,
+        }
+    }
+}
+
+fn to_py_error(error: ::cql2::Error) -> PyErr {
+    use ::cql2::Error::*;
+    match error {
+        InvalidCql2Text(..)
+        | InvalidNumberOfArguments { .. }
+        | MissingArgument(..)
+        | ParseBool(..)
+        | ParseFloat(..)
+        | ParseInt(..) => PyValueError::new_err(error.to_string()),
+        Io(err) => PyIOError::new_err(err.to_string()),
+        _ => PyException::new_err(error.to_string()),
+    }
+}
+
+/// A Python module implemented in Rust.
+#[pymodule]
+fn cql2(m: &Bound<'_, PyModule>) -> PyResult<()> {
+    m.add_class::<Expr>()?;
+    m.add_class::<SqlQuery>()?;
+    Ok(())
+}
diff --git a/python/uv.lock b/python/uv.lock
new file mode 100644
index 0000000..464da04
--- /dev/null
+++ b/python/uv.lock
@@ -0,0 +1,176 @@
+version = 1
+requires-python = ">=3.8"
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
+]
+
+[[package]]
+name = "cql2"
+version = "0.1.0"
+source = { editable = "." }
+
+[package.dev-dependencies]
+dev = [
+    { name = "mypy" },
+    { name = "pytest" },
+    { name = "ruff" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [
+    { name = "mypy" },
+    { name = "pytest" },
+    { name = "ruff" },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
+]
+
+[[package]]
+name = "mypy"
+version = "1.11.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "mypy-extensions" },
+    { name = "tomli", marker = "python_full_version < '3.11'" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5c/86/5d7cbc4974fd564550b80fbb8103c05501ea11aa7835edf3351d90095896/mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79", size = 3078806 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/78/cd/815368cd83c3a31873e5e55b317551500b12f2d1d7549720632f32630333/mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a", size = 10939401 },
+    { url = "https://files.pythonhosted.org/packages/f1/27/e18c93a195d2fad75eb96e1f1cbc431842c332e8eba2e2b77eaf7313c6b7/mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef", size = 10111697 },
+    { url = "https://files.pythonhosted.org/packages/dc/08/cdc1fc6d0d5a67d354741344cc4aa7d53f7128902ebcbe699ddd4f15a61c/mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383", size = 12500508 },
+    { url = "https://files.pythonhosted.org/packages/64/12/aad3af008c92c2d5d0720ea3b6674ba94a98cdb86888d389acdb5f218c30/mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8", size = 13020712 },
+    { url = "https://files.pythonhosted.org/packages/03/e6/a7d97cc124a565be5e9b7d5c2a6ebf082379ffba99646e4863ed5bbcb3c3/mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7", size = 9567319 },
+    { url = "https://files.pythonhosted.org/packages/e2/aa/cc56fb53ebe14c64f1fe91d32d838d6f4db948b9494e200d2f61b820b85d/mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385", size = 10859630 },
+    { url = "https://files.pythonhosted.org/packages/04/c8/b19a760fab491c22c51975cf74e3d253b8c8ce2be7afaa2490fbf95a8c59/mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca", size = 10037973 },
+    { url = "https://files.pythonhosted.org/packages/88/57/7e7e39f2619c8f74a22efb9a4c4eff32b09d3798335625a124436d121d89/mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104", size = 12416659 },
+    { url = "https://files.pythonhosted.org/packages/fc/a6/37f7544666b63a27e46c48f49caeee388bf3ce95f9c570eb5cfba5234405/mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4", size = 12897010 },
+    { url = "https://files.pythonhosted.org/packages/84/8b/459a513badc4d34acb31c736a0101c22d2bd0697b969796ad93294165cfb/mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6", size = 9562873 },
+    { url = "https://files.pythonhosted.org/packages/35/3a/ed7b12ecc3f6db2f664ccf85cb2e004d3e90bec928e9d7be6aa2f16b7cdf/mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318", size = 10990335 },
+    { url = "https://files.pythonhosted.org/packages/04/e4/1a9051e2ef10296d206519f1df13d2cc896aea39e8683302f89bf5792a59/mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36", size = 10007119 },
+    { url = "https://files.pythonhosted.org/packages/f3/3c/350a9da895f8a7e87ade0028b962be0252d152e0c2fbaafa6f0658b4d0d4/mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987", size = 12506856 },
+    { url = "https://files.pythonhosted.org/packages/b6/49/ee5adf6a49ff13f4202d949544d3d08abb0ea1f3e7f2a6d5b4c10ba0360a/mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca", size = 12952066 },
+    { url = "https://files.pythonhosted.org/packages/27/c0/b19d709a42b24004d720db37446a42abadf844d5c46a2c442e2a074d70d9/mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70", size = 9664000 },
+    { url = "https://files.pythonhosted.org/packages/42/ad/5a8567700410f8aa7c755b0ebd4cacff22468cbc5517588773d65075c0cb/mypy-1.11.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:37c7fa6121c1cdfcaac97ce3d3b5588e847aa79b580c1e922bb5d5d2902df19b", size = 10876550 },
+    { url = "https://files.pythonhosted.org/packages/1b/bc/9fc16ea7a27ceb93e123d300f1cfe27a6dd1eac9a8beea4f4d401e737e9d/mypy-1.11.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4a8a53bc3ffbd161b5b2a4fff2f0f1e23a33b0168f1c0778ec70e1a3d66deb86", size = 10068086 },
+    { url = "https://files.pythonhosted.org/packages/cd/8f/a1e460f1288405a13352dad16b24aba6dce4f850fc76510c540faa96eda3/mypy-1.11.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ff93107f01968ed834f4256bc1fc4475e2fecf6c661260066a985b52741ddce", size = 12459214 },
+    { url = "https://files.pythonhosted.org/packages/c7/74/746b31aef7cc7512dab8bdc2311ef88d63fadc1c453a09c8cab7e57e59bf/mypy-1.11.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:edb91dded4df17eae4537668b23f0ff6baf3707683734b6a818d5b9d0c0c31a1", size = 12962942 },
+    { url = "https://files.pythonhosted.org/packages/28/a4/7fae712240b640d75bb859294ad4776b9960b3216ccb7fa747f578e6c632/mypy-1.11.2-cp38-cp38-win_amd64.whl", hash = "sha256:ee23de8530d99b6db0573c4ef4bd8f39a2a6f9b60655bf7a1357e585a3486f2b", size = 9545616 },
+    { url = "https://files.pythonhosted.org/packages/16/64/bb5ed751487e2bea0dfaa6f640a7e3bb88083648f522e766d5ef4a76f578/mypy-1.11.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:801ca29f43d5acce85f8e999b1e431fb479cb02d0e11deb7d2abb56bdaf24fd6", size = 10937294 },
+    { url = "https://files.pythonhosted.org/packages/a9/a3/67a0069abed93c3bf3b0bebb8857e2979a02828a4a3fd82f107f8f1143e8/mypy-1.11.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af8d155170fcf87a2afb55b35dc1a0ac21df4431e7d96717621962e4b9192e70", size = 10107707 },
+    { url = "https://files.pythonhosted.org/packages/2f/4d/0379daf4258b454b1f9ed589a9dabd072c17f97496daea7b72fdacf7c248/mypy-1.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7821776e5c4286b6a13138cc935e2e9b6fde05e081bdebf5cdb2bb97c9df81d", size = 12498367 },
+    { url = "https://files.pythonhosted.org/packages/3b/dc/3976a988c280b3571b8eb6928882dc4b723a403b21735a6d8ae6ed20e82b/mypy-1.11.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:539c570477a96a4e6fb718b8d5c3e0c0eba1f485df13f86d2970c91f0673148d", size = 13018014 },
+    { url = "https://files.pythonhosted.org/packages/83/84/adffc7138fb970e7e2a167bd20b33bb78958370179853a4ebe9008139342/mypy-1.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:3f14cd3d386ac4d05c5a39a51b84387403dadbd936e17cb35882134d4f8f0d24", size = 9568056 },
+    { url = "https://files.pythonhosted.org/packages/42/3a/bdf730640ac523229dd6578e8a581795720a9321399de494374afc437ec5/mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12", size = 2619625 },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 },
+]
+
+[[package]]
+name = "packaging"
+version = "24.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
+]
+
+[[package]]
+name = "pytest"
+version = "8.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+    { name = "iniconfig" },
+    { name = "packaging" },
+    { name = "pluggy" },
+    { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 },
+]
+
+[[package]]
+name = "ruff"
+version = "0.6.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/0d/6148a48dab5662ca1d5a93b7c0d13c03abd3cc7e2f35db08410e47cef15d/ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2", size = 3095355 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/6e/8f/f7a0a0ef1818662efb32ed6df16078c95da7a0a3248d64c2410c1e27799f/ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd", size = 10440526 },
+    { url = "https://files.pythonhosted.org/packages/8b/69/b179a5faf936a9e2ab45bb412a668e4661eded964ccfa19d533f29463ef6/ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec", size = 10034612 },
+    { url = "https://files.pythonhosted.org/packages/c7/ef/fd1b4be979c579d191eeac37b5cfc0ec906de72c8bcd8595e2c81bb700c1/ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c", size = 9706197 },
+    { url = "https://files.pythonhosted.org/packages/29/61/b376d775deb5851cb48d893c568b511a6d3625ef2c129ad5698b64fb523c/ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e", size = 10751855 },
+    { url = "https://files.pythonhosted.org/packages/13/d7/def9e5f446d75b9a9c19b24231a3a658c075d79163b08582e56fa5dcfa38/ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577", size = 10200889 },
+    { url = "https://files.pythonhosted.org/packages/6c/d6/7f34160818bcb6e84ce293a5966cba368d9112ff0289b273fbb689046047/ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829", size = 11038678 },
+    { url = "https://files.pythonhosted.org/packages/13/34/a40ff8ae62fb1b26fb8e6fa7e64bc0e0a834b47317880de22edd6bfb54fb/ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5", size = 11808682 },
+    { url = "https://files.pythonhosted.org/packages/2e/6d/25a4386ae4009fc798bd10ba48c942d1b0b3e459b5403028f1214b6dd161/ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7", size = 11330446 },
+    { url = "https://files.pythonhosted.org/packages/f7/f6/bdf891a9200d692c94ebcd06ae5a2fa5894e522f2c66c2a12dd5d8cb2654/ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f", size = 12483048 },
+    { url = "https://files.pythonhosted.org/packages/a7/86/96f4252f41840e325b3fa6c48297e661abb9f564bd7dcc0572398c8daa42/ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa", size = 10936855 },
+    { url = "https://files.pythonhosted.org/packages/45/87/801a52d26c8dbf73424238e9908b9ceac430d903c8ef35eab1b44fcfa2bd/ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb", size = 10713007 },
+    { url = "https://files.pythonhosted.org/packages/be/27/6f7161d90320a389695e32b6ebdbfbedde28ccbf52451e4b723d7ce744ad/ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0", size = 10274594 },
+    { url = "https://files.pythonhosted.org/packages/00/52/dc311775e7b5f5b19831563cb1572ecce63e62681bccc609867711fae317/ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625", size = 10608024 },
+    { url = "https://files.pythonhosted.org/packages/98/b6/be0a1ddcbac65a30c985cf7224c4fce786ba2c51e7efeb5178fe410ed3cf/ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039", size = 10982085 },
+    { url = "https://files.pythonhosted.org/packages/bb/a4/c84bc13d0b573cf7bb7d17b16d6d29f84267c92d79b2f478d4ce322e8e72/ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d", size = 8522088 },
+    { url = "https://files.pythonhosted.org/packages/74/be/fc352bd8ca40daae8740b54c1c3e905a7efe470d420a268cd62150248c91/ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117", size = 9359275 },
+    { url = "https://files.pythonhosted.org/packages/3e/14/fd026bc74ded05e2351681545a5f626e78ef831f8edce064d61acd2e6ec7/ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93", size = 8679879 },
+]
+
+[[package]]
+name = "tomli"
+version = "2.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/35/b9/de2a5c0144d7d75a57ff355c0c24054f965b2dc3036456ae03a51ea6264b/tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed", size = 16096 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/cf/db/ce8eda256fa131af12e0a76d481711abe4681b6923c27efb9a255c9e4594/tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", size = 13237 },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
+]
diff --git a/src/lib.rs b/src/lib.rs
index 3549951..bfe5fd1 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -15,8 +15,11 @@ pub use {error::Error, expr::Expr, geometry::Geometry, parser::parse_text, valid
 /// A SQL query, broken into the query and parameters.
 #[derive(Debug, Serialize, Deserialize, Clone)]
 pub struct SqlQuery {
-    query: String,
-    params: Vec<String>,
+    /// The SQL query, with placeholders for parameters.
+    pub query: String,
+
+    /// The SQL parameters, as strings.
+    pub params: Vec<String>,
 }
 
 /// Parses a cql2-json string into a CQL2 expression.
@@ -24,7 +27,7 @@ pub struct SqlQuery {
 /// # Examples
 ///
 /// ```
-/// let s = include_str!("../tests/fixtures/json/example01.json");
+/// let s = include_str!("../fixtures/json/example01.json");
 /// let expr = cql2::parse_json(s);
 /// ```
 pub fn parse_json(s: &str) -> Result<Expr, serde_json::Error> {


Test patch:
diff --git a/python/tests/conftest.py b/python/tests/conftest.py
new file mode 100644
index 0000000..a7ce767
--- /dev/null
+++ b/python/tests/conftest.py
@@ -0,0 +1,21 @@
+import pytest
+import json
+from typing import Any
+from pathlib import Path
+
+
+@pytest.fixture
+def fixtures() -> Path:
+    return Path(__file__).parents[2] / "fixtures"
+
+
+@pytest.fixture
+def example01_text(fixtures: Path) -> str:
+    with open(fixtures / "text" / "example01.txt") as f:
+        return f.read()
+
+
+@pytest.fixture
+def example01_json(fixtures: Path) -> dict[str, Any]:
+    with open(fixtures / "json" / "example01.json") as f:
+        return json.load(f)
diff --git a/python/tests/test_expr.py b/python/tests/test_expr.py
new file mode 100644
index 0000000..7f4a073
--- /dev/null
+++ b/python/tests/test_expr.py
@@ -0,0 +1,28 @@
+from pathlib import Path
+
+from cql2 import Expr
+
+
+def test_from_path(fixtures: Path) -> None:
+    Expr.from_path(fixtures / "text" / "example01.txt")
+
+
+def test_init(example01_text: str) -> None:
+    Expr(example01_text)
+
+
+def test_to_json(example01_text: str) -> None:
+    Expr(example01_text).to_json() == {
+        "op": "=",
+        "args": [{"property": "landsat:scene_id"}, "LC82030282019133LGN00"],
+    }
+
+
+def test_to_text(example01_json: str) -> None:
+    Expr(example01_json).to_text() == "landsat:scene_id = 'LC82030282019133LGN00'"
+
+
+def test_to_sql(example01_text: str) -> None:
+    sql_query = Expr(example01_text).to_sql()
+    assert sql_query.query == '("landsat:scene_id" = $1)'
+    assert sql_query.params == ["LC82030282019133LGN00"]
diff --git a/tests/README.md b/tests/README.md
index d996e2b..8e00407 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -1,6 +1,6 @@
 # Tests
 
-The `fixtures` directory is copied directly from <https://github.com/opengeospatial/ogcapi-features/tree/cql2-1.0.0/cql2/standard/schema/examples>.
+The top level `examples` directory is copied directly from <https://github.com/opengeospatial/ogcapi-features/tree/cql2-1.0.0/cql2/standard/schema/examples>.
 
 ## Expected test output
 
diff --git a/tests/generate-expected b/tests/generate-expected
index 36f9bab..838451b 100755
--- a/tests/generate-expected
+++ b/tests/generate-expected
@@ -7,8 +7,9 @@ set -e
 cargo build
 
 tests=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)
+root=$(dirname "$tests")
 expected="$tests/expected"
-fixtures="$tests/fixtures"
+fixtures="$root/fixtures"
 
 for input_format in json text; do
     if [ $input_format = "json" ]; then
diff --git a/tests/ogc_tests.rs b/tests/ogc_tests.rs
index 804995d..525bba0 100644
--- a/tests/ogc_tests.rs
+++ b/tests/ogc_tests.rs
@@ -31,11 +31,11 @@ fn validate_path(path: impl AsRef<Path>) {
 }
 
 #[rstest]
-fn validate_text_fixtures(#[files("tests/fixtures/text/*.txt")] path: PathBuf) {
+fn validate_text_fixtures(#[files("fixtures/text/*.txt")] path: PathBuf) {
     validate_path(path);
 }
 
 #[rstest]
-fn validate_json_fixtures(#[files("tests/fixtures/json/*.json")] path: PathBuf) {
+fn validate_json_fixtures(#[files("fixtures/json/*.json")] path: PathBuf) {
     validate_path(path);
 }