diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2f6b5c..0b36a1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,24 +12,37 @@ permissions: jobs: lint: + name: Lint (rustfmt, clippy, ruff) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: - components: clippy - - name: Run lint checks - run: make check + components: clippy, rustfmt + - name: Rust format check + run: cargo fmt --check + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install Ruff + run: pip install ruff + - name: Ruff lint + run: ruff check tests + - name: Ruff format check + run: ruff format --check tests test: + name: Test (py${{ matrix.python-version }} on ${{ matrix.os }}) needs: lint runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] - python-version: ["3.10", "3.11", "3.12", "3.13"] + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -37,27 +50,17 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - - name: Install Poetry - uses: snok/install-poetry@v1 - with: - version: latest - virtualenvs-in-project: true - - name: Cache Poetry virtualenv - uses: actions/cache@v4 - with: - path: .venv - key: poetry-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} - restore-keys: | - poetry-${{ runner.os }}-py${{ matrix.python-version }}- - - name: Install dependencies - run: poetry install - - name: Build with maturin - run: make build + - name: Install build/test tooling + run: pip install "maturin>=1.0,<2.0" pytest + - name: Build abi3 wheel + run: maturin build --release --out dist + - name: Install wheel + run: pip install --no-index --find-links dist ormar-utils - name: Run tests - run: make test + run: pytest tests/ -v build: - name: Build wheels + name: Build wheels (${{ matrix.os }}-${{ matrix.target }}) needs: test if: startsWith(github.ref, 'refs/tags/v') runs-on: ${{ matrix.os }} @@ -79,11 +82,13 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" + # abi3-py310 produces a single stable-ABI wheel per platform that works on + # CPython 3.10+ (including future releases), so no per-interpreter matrix. - name: Build wheels uses: PyO3/maturin-action@v1 with: target: ${{ matrix.target }} - args: --release --out dist -i python3.10 python3.11 python3.12 python3.13 + args: --release --out dist manylinux: auto - name: Upload wheels uses: actions/upload-artifact@v4 @@ -91,9 +96,27 @@ jobs: name: wheels-${{ matrix.os }}-${{ matrix.target }} path: dist + sdist: + name: Build sdist + needs: test + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build sdist + uses: PyO3/maturin-action@v1 + with: + command: sdist + args: --out dist + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + publish: name: Publish to PyPI - needs: build + needs: [build, sdist] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest environment: pypi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9ec26a5..9b7339b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,29 +1,25 @@ repos: - - repo: https://github.com/rust-lang/rust-clippy - rev: v1.77.0 - hooks: - - id: clippy - name: clippy - entry: cargo clippy -- -D warnings - language: system - types: [rust] - pass_filenames: false - stages: [commit] - - repo: local hooks: - id: cargo-fmt name: cargo fmt - entry: cargo fmt + entry: cargo fmt --check language: system types: [rust] pass_filenames: false - stages: [commit] - - id: cargo-check - name: cargo check - entry: cargo check + - id: clippy + name: clippy + entry: cargo clippy --all-targets -- -D warnings language: system types: [rust] pass_filenames: false - stages: [commit] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.16 + hooks: + - id: ruff + args: [--fix] + files: ^tests/ + - id: ruff-format + files: ^tests/ diff --git a/Cargo.lock b/Cargo.lock index a3b7614..64b33e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,7 +92,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "ormar_rust_utils" -version = "0.1.1" +version = "0.2.0" dependencies = [ "base64", "indexmap", diff --git a/Cargo.toml b/Cargo.toml index d8da7c3..2483908 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ormar_rust_utils" -version = "0.1.1" +version = "0.2.0" edition = "2021" description = "Rust-accelerated utility functions for the ormar ORM" license = "MIT" @@ -10,7 +10,10 @@ name = "ormar_rust_utils" crate-type = ["cdylib"] [dependencies] -pyo3 = { version = "0.23.5", features = ["extension-module"] } +# abi3-py310 builds a single stable-ABI wheel that works on CPython 3.10 and +# every newer version (3.14, 3.15, ...) without a per-version rebuild, so a new +# Python release never leaves ormar without an installable wheel. +pyo3 = { version = "0.23.5", features = ["extension-module", "abi3-py310"] } base64 = "0.22" serde_json = "1.0" indexmap = "2.0" diff --git a/Makefile b/Makefile index 9ea68bd..46252e2 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,18 @@ -.PHONY: fmt check test build clean +.PHONY: fmt fmt-check check lint test build clean fmt: cargo fmt + poetry run ruff format tests + +fmt-check: + cargo fmt --check + poetry run ruff format --check tests check: - cargo clippy -- -D warnings + cargo clippy --all-targets -- -D warnings + +lint: fmt-check check + poetry run ruff check tests test: poetry run pytest tests/ -v @@ -20,8 +28,10 @@ clean: help: @echo "Available targets:" - @echo " fmt - Format code with rustfmt" + @echo " fmt - Format Rust (rustfmt) and Python (ruff) code" + @echo " fmt-check - Check formatting without modifying files" @echo " check - Run clippy linter with strict warnings" + @echo " lint - Run all format checks, clippy and ruff lint" @echo " test - Run tests with pytest" @echo " build - Build the extension module with maturin" @echo " clean - Clean build artifacts" diff --git a/README.md b/README.md index 4e31a60..b699bfb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ormar-utils -Rust-accelerated utility functions for the [ormar](https://github.com/collerek/ormar) async ORM. +Rust-accelerated utility functions for the [ormar](https://github.com/ormar-orm/ormar) async ORM. This package provides optional Rust implementations of performance-critical operations used internally by ormar. When installed, ormar automatically uses these faster implementations. @@ -21,6 +21,10 @@ pip install ormar[rust] - Python >= 3.10 - A Rust toolchain (for building from source) +Wheels are built against the CPython stable ABI (`abi3`, Python 3.10+), so a +single wheel per platform works on current and future Python releases without a +per-version rebuild. + ## API Reference All functions are exposed from the `ormar_rust_utils` module: @@ -40,9 +44,8 @@ All functions are exposed from the `ormar_rust_utils` module: ### Collections - `UniqueList(initial=None)` - A list that prevents duplicates using hash-based O(1) lookups -### Row Processing -- `extract_prefixed_columns(column_mappings, selected_columns, row, column_prefix, item)` - Extract prefixed columns from a database row -- `prepare_model_to_save(new_kwargs, aliases_map, fields_to_keep)` - Consolidate column alias translation and field filtering +### Alias Utilities +- `build_reverse_alias_map(field_alias_map)` - Build a cached alias -> field_name lookup (with identity entries) from a field_name -> alias mapping ### Merge Infrastructure - `group_by_pk(pks)` - Group items by PK hash, preserving insertion order diff --git a/pyproject.toml b/pyproject.toml index e894d05..3c59b1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "ormar-utils" -version = "0.1.1" +version = "0.2.0" description = "Rust-accelerated utility functions for the ormar ORM" readme = "README.md" license = { text = "MIT" } @@ -22,20 +22,22 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3 :: Only", "License :: OSI Approved :: MIT License", "Topic :: Database", ] [project.urls] -Homepage = "https://github.com/collerek/ormar-utils" -Repository = "https://github.com/collerek/ormar-utils" +Homepage = "https://github.com/ormar-orm/ormar-utils" +Repository = "https://github.com/ormar-orm/ormar-utils" [tool.maturin] features = ["pyo3/extension-module"] [tool.poetry] name = "ormar-utils" -version = "0.1.1" +version = "0.2.0" description = "Rust-accelerated utility functions for the ormar ORM" authors = ["Radosław Drążkiewicz "] package-mode = false @@ -47,6 +49,10 @@ python = "^3.10" pytest = "^7.0" maturin = "^1.0" pre-commit = "^3.0" +ruff = "^0.15" [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.ruff] +target-version = "py310" diff --git a/src/alias_utils.rs b/src/alias_utils.rs index 635f9ae..52833e1 100644 --- a/src/alias_utils.rs +++ b/src/alias_utils.rs @@ -1,6 +1,5 @@ use pyo3::prelude::*; use pyo3::types::PyDict; -use std::collections::HashMap; /// Build a reverse mapping from alias -> field_name given a forward mapping /// of field_name -> alias. If a field has no alias (alias == field_name), @@ -25,52 +24,3 @@ pub fn build_reverse_alias_map<'py>( } Ok(result) } - -/// Translate dict keys from field names to their database aliases. -/// Takes the dict to translate and a field_name->alias mapping. -/// Returns a new dict with aliased keys. -#[pyfunction] -pub fn translate_columns_to_aliases<'py>( - py: Python<'py>, - new_kwargs: &Bound<'py, PyDict>, - field_to_alias: &Bound<'py, PyDict>, -) -> PyResult> { - let result = PyDict::new(py); - // Pre-extract the mapping into a Rust HashMap for fast lookup - let mut alias_map: HashMap = HashMap::new(); - for (k, v) in field_to_alias.iter() { - let key: String = k.extract()?; - let val: String = v.extract()?; - alias_map.insert(key, val); - } - - for (key, value) in new_kwargs.iter() { - let field_name: String = key.extract()?; - if let Some(alias) = alias_map.get(&field_name) { - result.set_item(alias, value)?; - } else { - result.set_item(key, value)?; - } - } - Ok(result) -} - -/// Translate dict keys from database aliases to field names. -/// Takes the dict to translate and an alias->field_name mapping. -/// Returns a new dict with field name keys. -#[pyfunction] -pub fn translate_aliases_to_columns<'py>( - py: Python<'py>, - new_kwargs: &Bound<'py, PyDict>, - alias_to_field: &Bound<'py, PyDict>, -) -> PyResult> { - let result = PyDict::new(py); - for (key, value) in new_kwargs.iter() { - if let Some(field_name) = alias_to_field.get_item(&key)? { - result.set_item(field_name, value)?; - } else { - result.set_item(key, value)?; - } - } - Ok(result) -} diff --git a/src/extract_columns.rs b/src/extract_columns.rs deleted file mode 100644 index 5682755..0000000 --- a/src/extract_columns.rs +++ /dev/null @@ -1,27 +0,0 @@ -use pyo3::prelude::*; -use pyo3::types::PyDict; -use std::collections::HashSet; - -/// Extract prefixed columns from a database row into a model dict. -#[pyfunction] -pub fn extract_prefixed_columns( - _py: Python<'_>, - column_mappings: Vec<(String, String)>, - selected_columns: HashSet, - row: &Bound<'_, PyAny>, - column_prefix: String, - item: &Bound<'_, PyDict>, -) -> PyResult { - for (col_name, alias) in &column_mappings { - if item.contains(alias.as_str())? { - continue; - } - if !selected_columns.contains(alias.as_str()) { - continue; - } - let prefixed = format!("{}{}", column_prefix, col_name); - let value = row.get_item(&prefixed)?; - item.set_item(alias.as_str(), value)?; - } - Ok(item.as_any().clone().unbind()) -} diff --git a/src/lib.rs b/src/lib.rs index f975adc..6aa8560 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,9 @@ mod alias_utils; -mod extract_columns; mod group_related; mod hash_item; mod merge_instances; mod merge_items; mod parsers; -mod prepare_save; mod translate_list; mod unique_list; @@ -21,12 +19,8 @@ fn ormar_rust_utils(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(translate_list::translate_list_to_dict, m)?)?; m.add_function(wrap_pyfunction!(group_related::group_related_list, m)?)?; m.add_class::()?; - m.add_function(wrap_pyfunction!(extract_columns::extract_prefixed_columns, m)?)?; - m.add_function(wrap_pyfunction!(prepare_save::prepare_model_to_save, m)?)?; m.add_function(wrap_pyfunction!(merge_instances::group_by_pk, m)?)?; m.add_function(wrap_pyfunction!(merge_items::plan_merge_items_lists, m)?)?; m.add_function(wrap_pyfunction!(alias_utils::build_reverse_alias_map, m)?)?; - m.add_function(wrap_pyfunction!(alias_utils::translate_columns_to_aliases, m)?)?; - m.add_function(wrap_pyfunction!(alias_utils::translate_aliases_to_columns, m)?)?; Ok(()) } diff --git a/src/parsers.rs b/src/parsers.rs index cae4e26..e112e6a 100644 --- a/src/parsers.rs +++ b/src/parsers.rs @@ -1,7 +1,7 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use base64::Engine; use pyo3::prelude::*; -use pyo3::types::{PyBytes, PyString, PyDict}; +use pyo3::types::{PyBytes, PyDict, PyString}; /// Encode bytes to string representation. /// If represent_as_string is true, uses base64 encoding. @@ -94,9 +94,7 @@ pub fn encode_json(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult()? == "orjson"; diff --git a/src/prepare_save.rs b/src/prepare_save.rs deleted file mode 100644 index 9f0eabf..0000000 --- a/src/prepare_save.rs +++ /dev/null @@ -1,27 +0,0 @@ -use pyo3::prelude::*; -use pyo3::types::PyDict; -use std::collections::{HashMap, HashSet}; - -/// Consolidates column alias translation and field filtering into a single pass. -#[pyfunction] -pub fn prepare_model_to_save( - py: Python<'_>, - new_kwargs: &Bound<'_, PyDict>, - aliases_map: HashMap, - fields_to_keep: HashSet, -) -> PyResult { - let result = PyDict::new(py); - - for (key, value) in new_kwargs.iter() { - let key_str: String = key.extract()?; - - if !fields_to_keep.contains(&key_str) { - continue; - } - - let alias = aliases_map.get(&key_str).unwrap_or(&key_str); - result.set_item(alias.as_str(), value)?; - } - - Ok(result.into()) -} diff --git a/src/translate_list.rs b/src/translate_list.rs index 6892c51..4ef5031 100644 --- a/src/translate_list.rs +++ b/src/translate_list.rs @@ -24,9 +24,7 @@ pub fn translate_list_to_dict( default_obj.clone_ref(py) } else { let copy_mod = py.import("copy")?; - copy_mod - .call_method1("deepcopy", (&default_obj,))? - .unbind() + copy_mod.call_method1("deepcopy", (&default_obj,))?.unbind() }; for (ind, part) in parts.iter().enumerate() { diff --git a/tests/test_all.py b/tests/test_all.py index a67bdee..2260bc0 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -175,39 +175,6 @@ def test_unique_list_initial(): assert len(ul) == 3 -def test_extract_prefixed_columns(): - """Test extracting prefixed columns from a row-like dict.""" - - class FakeRow: - def __init__(self, data): - self._data = data - - def __getitem__(self, key): - return self._data[key] - - column_mappings = [("name", "name"), ("age", "age")] - selected_columns = {"name", "age"} - row = FakeRow({"pfx_name": "Alice", "pfx_age": 30}) - item = {} - result = ormar_rust_utils.extract_prefixed_columns( - column_mappings, selected_columns, row, "pfx_", item - ) - assert result["name"] == "Alice" - assert result["age"] == 30 - - -def test_prepare_model_to_save(): - """Test preparing model dict for saving.""" - new_kwargs = {"name": "Alice", "age": 30, "extra": "ignored"} - aliases_map = {"name": "user_name"} - fields_to_keep = {"name", "age"} - result = ormar_rust_utils.prepare_model_to_save( - new_kwargs, aliases_map, fields_to_keep - ) - assert result == {"user_name": "Alice", "age": 30} - assert "extra" not in result - - def test_group_by_pk(): """Test grouping by primary key.""" pks = [1, 2, 1, 3, 2] @@ -264,55 +231,3 @@ def test_build_reverse_alias_map_empty(): """Test reverse alias map with empty input.""" result = ormar_rust_utils.build_reverse_alias_map({}) assert len(result) == 0 - - -def test_translate_columns_to_aliases_basic(): - """Test translating field names to aliases.""" - new_kwargs = {"name": "Alice", "age": 30} - field_to_alias = {"name": "user_name", "age": "user_age"} - result = ormar_rust_utils.translate_columns_to_aliases(new_kwargs, field_to_alias) - assert result["user_name"] == "Alice" - assert result["user_age"] == 30 - assert "name" not in result - assert "age" not in result - - -def test_translate_columns_to_aliases_missing_alias(): - """Test that keys without an alias mapping are kept as-is.""" - new_kwargs = {"name": "Alice", "extra": "value"} - field_to_alias = {"name": "user_name"} - result = ormar_rust_utils.translate_columns_to_aliases(new_kwargs, field_to_alias) - assert result["user_name"] == "Alice" - assert result["extra"] == "value" - - -def test_translate_columns_to_aliases_empty(): - """Test translating with empty inputs.""" - result = ormar_rust_utils.translate_columns_to_aliases({}, {}) - assert len(result) == 0 - - -def test_translate_aliases_to_columns_basic(): - """Test translating aliases back to field names.""" - new_kwargs = {"user_name": "Alice", "user_age": 30} - alias_to_field = {"user_name": "name", "user_age": "age"} - result = ormar_rust_utils.translate_aliases_to_columns(new_kwargs, alias_to_field) - assert result["name"] == "Alice" - assert result["age"] == 30 - assert "user_name" not in result - assert "user_age" not in result - - -def test_translate_aliases_to_columns_missing_mapping(): - """Test that keys without a mapping are kept as-is.""" - new_kwargs = {"user_name": "Alice", "extra": "value"} - alias_to_field = {"user_name": "name"} - result = ormar_rust_utils.translate_aliases_to_columns(new_kwargs, alias_to_field) - assert result["name"] == "Alice" - assert result["extra"] == "value" - - -def test_translate_aliases_to_columns_empty(): - """Test translating with empty inputs.""" - result = ormar_rust_utils.translate_aliases_to_columns({}, {}) - assert len(result) == 0