├── .github └── workflows │ └── python.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── Cargo.lock ├── Cargo.toml ├── LICENSE.txt ├── Makefile ├── README.md ├── bench.ipynb ├── pyproject.toml ├── python └── routrie │ ├── __init__.py │ ├── _routrie.pyi │ └── py.typed ├── requirements-bench.txt ├── requirements-dev.txt ├── src └── lib.rs └── test_routrie.py /.github/workflows/python.yaml: -------------------------------------------------------------------------------- 1 | name: Test & Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | PACKAGE_NAME: routrie 15 | PYTHON_VERSION: "3.7" # to build abi3 wheels 16 | 17 | jobs: 18 | macos: 19 | runs-on: macos-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | - uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ env.PYTHON_VERSION }} 25 | architecture: x64 26 | - name: Install Rust toolchain 27 | uses: actions-rs/toolchain@v1 28 | with: 29 | toolchain: stable 30 | profile: minimal 31 | default: true 32 | - name: Build wheels - x86_64 33 | uses: messense/maturin-action@v1 34 | with: 35 | target: x86_64 36 | args: --release --out dist --sdist 37 | maturin-version: "v0.13.3" 38 | - name: Install built wheel - x86_64 39 | run: | 40 | pip install dist/${{ env.PACKAGE_NAME }}-*.whl --force-reinstall 41 | pip install pytest 42 | pytest -v 43 | - name: Build wheels - universal2 44 | uses: messense/maturin-action@v1 45 | with: 46 | args: --release --universal2 --out dist 47 | maturin-version: "v0.13.3" 48 | - name: Install built wheel - universal2 49 | run: | 50 | pip install dist/${{ env.PACKAGE_NAME }}-*universal2.whl --force-reinstall 51 | pip install pytest 52 | pytest -v 53 | - name: Upload wheels 54 | uses: actions/upload-artifact@v2 55 | with: 56 | name: wheels 57 | path: dist 58 | 59 | windows: 60 | runs-on: windows-latest 61 | strategy: 62 | matrix: 63 | target: [x64, x86] 64 | steps: 65 | - uses: actions/checkout@v3 66 | - uses: actions/setup-python@v4 67 | with: 68 | python-version: ${{ env.PYTHON_VERSION }} 69 | architecture: ${{ matrix.target }} 70 | - name: Install Rust toolchain 71 | uses: actions-rs/toolchain@v1 72 | with: 73 | toolchain: stable 74 | profile: minimal 75 | default: true 76 | - name: Build wheels 77 | uses: messense/maturin-action@v1 78 | with: 79 | target: ${{ matrix.target }} 80 | args: --release --out dist 81 | maturin-version: "v0.13.3" 82 | - name: Install built wheel 83 | shell: bash 84 | run: | 85 | python -m pip install dist/${{ env.PACKAGE_NAME }}-*.whl --force-reinstall 86 | pip install pytest 87 | python -m pytest -v 88 | - name: Upload wheels 89 | uses: actions/upload-artifact@v2 90 | with: 91 | name: wheels 92 | path: dist 93 | 94 | linux: 95 | runs-on: ubuntu-latest 96 | strategy: 97 | matrix: 98 | target: [x86_64, i686] 99 | steps: 100 | - uses: actions/checkout@v3 101 | - uses: actions/setup-python@v4 102 | with: 103 | python-version: ${{ env.PYTHON_VERSION }} 104 | architecture: x64 105 | - name: Build wheels 106 | uses: messense/maturin-action@v1 107 | with: 108 | target: ${{ matrix.target }} 109 | manylinux: auto 110 | args: --release --out dist 111 | maturin-version: "v0.13.3" 112 | - name: Install built wheel 113 | if: matrix.target == 'x86_64' 114 | run: | 115 | pip install dist/${{ env.PACKAGE_NAME }}-*.whl --force-reinstall 116 | pip install pytest 117 | pytest -v 118 | - name: Upload wheels 119 | uses: actions/upload-artifact@v2 120 | with: 121 | name: wheels 122 | path: dist 123 | 124 | linux-cross: 125 | runs-on: ubuntu-latest 126 | strategy: 127 | matrix: 128 | target: [aarch64, armv7, s390x, ppc64le, ppc64] 129 | steps: 130 | - uses: actions/checkout@v3 131 | - uses: actions/setup-python@v4 132 | with: 133 | python-version: ${{ env.PYTHON_VERSION }} 134 | - name: Build wheels 135 | uses: messense/maturin-action@v1 136 | with: 137 | target: ${{ matrix.target }} 138 | manylinux: auto 139 | args: --release --out dist 140 | maturin-version: "v0.13.3" 141 | - uses: uraimo/run-on-arch-action@v2.0.5 142 | if: matrix.target != 'ppc64' 143 | name: Install built wheel 144 | with: 145 | arch: ${{ matrix.target }} 146 | distro: ubuntu20.04 147 | githubToken: ${{ github.token }} 148 | install: | 149 | apt-get update 150 | apt-get install -y --no-install-recommends python3 python3-pip 151 | pip3 install -U pip 152 | run: | 153 | pip3 install ${{ env.PACKAGE_NAME }} --no-index --find-links dist/ --force-reinstall 154 | pip install pytest 155 | pytest -v 156 | - name: Upload wheels 157 | uses: actions/upload-artifact@v2 158 | with: 159 | name: wheels 160 | path: dist 161 | 162 | musllinux: 163 | runs-on: ubuntu-latest 164 | strategy: 165 | matrix: 166 | target: 167 | - x86_64-unknown-linux-musl 168 | - i686-unknown-linux-musl 169 | steps: 170 | - uses: actions/checkout@v3 171 | - uses: actions/setup-python@v4 172 | with: 173 | python-version: ${{ env.PYTHON_VERSION }} 174 | architecture: x64 175 | - name: Build wheels 176 | uses: messense/maturin-action@v1 177 | with: 178 | target: ${{ matrix.target }} 179 | manylinux: musllinux_1_2 180 | args: --release --out dist 181 | maturin-version: "v0.13.3" 182 | - name: Install built wheel 183 | if: matrix.target == 'x86_64-unknown-linux-musl' 184 | uses: addnab/docker-run-action@v3 185 | with: 186 | image: alpine:latest 187 | options: -v ${{ github.workspace }}:/io -w /io 188 | run: | 189 | apk add py3-pip 190 | pip3 install -U pip pytest 191 | pip3 install ${{ env.PACKAGE_NAME }} --no-index --find-links /io/dist/ --force-reinstall 192 | python3 -m pytest 193 | - name: Upload wheels 194 | uses: actions/upload-artifact@v2 195 | with: 196 | name: wheels 197 | path: dist 198 | 199 | musllinux-cross: 200 | runs-on: ubuntu-latest 201 | strategy: 202 | matrix: 203 | platform: 204 | - target: aarch64-unknown-linux-musl 205 | arch: aarch64 206 | - target: armv7-unknown-linux-musleabihf 207 | arch: armv7 208 | steps: 209 | - uses: actions/checkout@v3 210 | - uses: actions/setup-python@v4 211 | with: 212 | python-version: ${{ env.PYTHON_VERSION }} 213 | - name: Build wheels 214 | uses: messense/maturin-action@v1 215 | with: 216 | target: ${{ matrix.platform.target }} 217 | manylinux: musllinux_1_2 218 | args: --release --out dist 219 | maturin-version: "v0.13.3" 220 | - uses: uraimo/run-on-arch-action@master 221 | name: Install built wheel 222 | with: 223 | arch: ${{ matrix.platform.arch }} 224 | distro: alpine_latest 225 | githubToken: ${{ github.token }} 226 | install: | 227 | apk add py3-pip 228 | pip3 install -U pip pytest 229 | run: | 230 | pip3 install ${{ env.PACKAGE_NAME }} --no-index --find-links dist/ --force-reinstall 231 | python3 -m pytest 232 | - name: Upload wheels 233 | uses: actions/upload-artifact@v2 234 | with: 235 | name: wheels 236 | path: dist 237 | 238 | pypy: 239 | runs-on: ${{ matrix.os }} 240 | strategy: 241 | matrix: 242 | os: [ubuntu-latest, macos-latest] 243 | target: [x86_64, aarch64] 244 | python-version: 245 | - '3.7' 246 | - '3.8' 247 | - '3.9' 248 | exclude: 249 | - os: macos-latest 250 | target: aarch64 251 | steps: 252 | - uses: actions/checkout@v3 253 | - uses: actions/setup-python@v4 254 | with: 255 | python-version: pypy${{ matrix.python-version }} 256 | - name: Build wheels 257 | uses: messense/maturin-action@v1 258 | with: 259 | maturin-version: "v0.13.3" 260 | target: ${{ matrix.target }} 261 | manylinux: auto 262 | args: --release --out dist -i pypy${{ matrix.python-version }} 263 | - name: Install built wheel 264 | if: matrix.target == 'x86_64' 265 | run: | 266 | pip install dist/${{ env.PACKAGE_NAME }}-*.whl --force-reinstall 267 | pip install pytest 268 | pytest -v 269 | - name: Upload wheels 270 | uses: actions/upload-artifact@v2 271 | with: 272 | name: wheels 273 | path: dist 274 | 275 | lint: 276 | runs-on: ubuntu-latest 277 | strategy: 278 | matrix: 279 | # Lint on earliest and latest 280 | python: ["3.7", "3.x"] 281 | steps: 282 | - uses: actions/checkout@v2 283 | - name: Set up Python 284 | uses: actions/setup-python@v2 285 | with: 286 | python-version: "3.x" 287 | - name: Install Rust toolchain 288 | uses: actions-rs/toolchain@v1 289 | with: 290 | toolchain: stable 291 | profile: minimal 292 | default: true 293 | - name: Lint 294 | run: | 295 | make lint 296 | release: 297 | name: Release 298 | runs-on: ubuntu-latest 299 | needs: 300 | - lint 301 | - macos 302 | - windows 303 | - linux 304 | - linux-cross 305 | - musllinux 306 | - musllinux-cross 307 | - pypy 308 | if: ${{ github.ref == 'refs/heads/main' }} 309 | steps: 310 | - uses: actions/download-artifact@v2 311 | with: 312 | name: wheels 313 | - uses: actions/setup-python@v2 314 | - name: Publish to PyPi 315 | env: 316 | TWINE_USERNAME: __token__ 317 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 318 | run: | 319 | pip install --upgrade twine 320 | twine upload --skip-existing * 321 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | !/.gitignore 4 | !/src 5 | !/Cargo.toml 6 | !/Cargo.lock 7 | !/pyproject.toml 8 | !/python 9 | !/test_routrie.py 10 | !/bench.ipynb 11 | !/requirements-dev.txt 12 | !/requirements-bench.txt 13 | !/.pre-commit-config.yaml 14 | !/Makefile 15 | !/README.md 16 | !/.github 17 | !/LICENSE.txt 18 | 19 | __pycache__ 20 | *.so -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | files: ^python/.*|^tests/.*|^src/.* 2 | repos: 3 | - repo: https://github.com/ambv/black 4 | rev: 22.3.0 5 | hooks: 6 | - id: black 7 | - repo: local 8 | hooks: 9 | - id: cargo-fmt 10 | name: cargo-fmt 11 | entry: cargo fmt 12 | language: system 13 | types: [rust] 14 | pass_filenames: false 15 | - id: cargo-clippy 16 | name: cargo-clippy 17 | entry: cargo clippy 18 | language: system 19 | types: [rust] 20 | pass_filenames: false 21 | - repo: https://gitlab.com/pycqa/flake8 22 | rev: 3.9.2 23 | hooks: 24 | - id: flake8 25 | args: ["--max-line-length=88"] 26 | - repo: https://github.com/pre-commit/mirrors-mypy 27 | rev: v0.961 28 | hooks: 29 | - id: mypy 30 | - repo: https://github.com/pre-commit/pre-commit-hooks 31 | rev: v4.3.0 32 | hooks: 33 | - id: end-of-file-fixer 34 | - id: trailing-whitespace 35 | - repo: https://github.com/pycqa/isort 36 | rev: 5.10.1 37 | hooks: 38 | - id: isort 39 | name: isort (python) 40 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "autocfg" 7 | version = "1.1.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 10 | 11 | [[package]] 12 | name = "bitflags" 13 | version = "1.3.2" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 16 | 17 | [[package]] 18 | name = "cfg-if" 19 | version = "1.0.0" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 22 | 23 | [[package]] 24 | name = "indoc" 25 | version = "1.0.6" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "05a0bd019339e5d968b37855180087b7b9d512c5046fbd244cf8c95687927d6e" 28 | 29 | [[package]] 30 | name = "instant" 31 | version = "0.1.12" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" 34 | dependencies = [ 35 | "cfg-if", 36 | ] 37 | 38 | [[package]] 39 | name = "libc" 40 | version = "0.2.112" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "1b03d17f364a3a042d5e5d46b053bbbf82c92c9430c592dd4c064dc6ee997125" 43 | 44 | [[package]] 45 | name = "lock_api" 46 | version = "0.4.5" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "712a4d093c9976e24e7dbca41db895dabcbac38eb5f4045393d17a95bdfb1109" 49 | dependencies = [ 50 | "scopeguard", 51 | ] 52 | 53 | [[package]] 54 | name = "memoffset" 55 | version = "0.6.5" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" 58 | dependencies = [ 59 | "autocfg", 60 | ] 61 | 62 | [[package]] 63 | name = "once_cell" 64 | version = "1.9.0" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "da32515d9f6e6e489d7bc9d84c71b060db7247dc035bbe44eac88cf87486d8d5" 67 | 68 | [[package]] 69 | name = "parking_lot" 70 | version = "0.11.2" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" 73 | dependencies = [ 74 | "instant", 75 | "lock_api", 76 | "parking_lot_core", 77 | ] 78 | 79 | [[package]] 80 | name = "parking_lot_core" 81 | version = "0.8.5" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216" 84 | dependencies = [ 85 | "cfg-if", 86 | "instant", 87 | "libc", 88 | "redox_syscall", 89 | "smallvec", 90 | "winapi", 91 | ] 92 | 93 | [[package]] 94 | name = "path-tree" 95 | version = "0.5.1" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "2bc2e1b256a8a54c8231f0905e6be4ed7611de3354c2112003725b4de173dbe8" 98 | dependencies = [ 99 | "smallvec", 100 | ] 101 | 102 | [[package]] 103 | name = "proc-macro2" 104 | version = "1.0.36" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" 107 | dependencies = [ 108 | "unicode-xid", 109 | ] 110 | 111 | [[package]] 112 | name = "pyo3" 113 | version = "0.17.1" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "12f72538a0230791398a0986a6518ebd88abc3fded89007b506ed072acc831e1" 116 | dependencies = [ 117 | "cfg-if", 118 | "indoc", 119 | "libc", 120 | "memoffset", 121 | "parking_lot", 122 | "pyo3-build-config", 123 | "pyo3-ffi", 124 | "pyo3-macros", 125 | "unindent", 126 | ] 127 | 128 | [[package]] 129 | name = "pyo3-build-config" 130 | version = "0.17.1" 131 | source = "registry+https://github.com/rust-lang/crates.io-index" 132 | checksum = "fc4cf18c20f4f09995f3554e6bcf9b09bd5e4d6b67c562fdfaafa644526ba479" 133 | dependencies = [ 134 | "once_cell", 135 | "target-lexicon", 136 | ] 137 | 138 | [[package]] 139 | name = "pyo3-ffi" 140 | version = "0.17.1" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "a41877f28d8ebd600b6aa21a17b40c3b0fc4dfe73a27b6e81ab3d895e401b0e9" 143 | dependencies = [ 144 | "libc", 145 | "pyo3-build-config", 146 | ] 147 | 148 | [[package]] 149 | name = "pyo3-macros" 150 | version = "0.17.1" 151 | source = "registry+https://github.com/rust-lang/crates.io-index" 152 | checksum = "2e81c8d4bcc2f216dc1b665412df35e46d12ee8d3d046b381aad05f1fcf30547" 153 | dependencies = [ 154 | "proc-macro2", 155 | "pyo3-macros-backend", 156 | "quote", 157 | "syn", 158 | ] 159 | 160 | [[package]] 161 | name = "pyo3-macros-backend" 162 | version = "0.17.1" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "85752a767ee19399a78272cc2ab625cd7d373b2e112b4b13db28de71fa892784" 165 | dependencies = [ 166 | "proc-macro2", 167 | "quote", 168 | "syn", 169 | ] 170 | 171 | [[package]] 172 | name = "quote" 173 | version = "1.0.14" 174 | source = "registry+https://github.com/rust-lang/crates.io-index" 175 | checksum = "47aa80447ce4daf1717500037052af176af5d38cc3e571d9ec1c7353fc10c87d" 176 | dependencies = [ 177 | "proc-macro2", 178 | ] 179 | 180 | [[package]] 181 | name = "redox_syscall" 182 | version = "0.2.10" 183 | source = "registry+https://github.com/rust-lang/crates.io-index" 184 | checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" 185 | dependencies = [ 186 | "bitflags", 187 | ] 188 | 189 | [[package]] 190 | name = "routrie" 191 | version = "0.8.0" 192 | dependencies = [ 193 | "path-tree", 194 | "pyo3", 195 | ] 196 | 197 | [[package]] 198 | name = "scopeguard" 199 | version = "1.1.0" 200 | source = "registry+https://github.com/rust-lang/crates.io-index" 201 | checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" 202 | 203 | [[package]] 204 | name = "smallvec" 205 | version = "1.9.0" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1" 208 | 209 | [[package]] 210 | name = "syn" 211 | version = "1.0.85" 212 | source = "registry+https://github.com/rust-lang/crates.io-index" 213 | checksum = "a684ac3dcd8913827e18cd09a68384ee66c1de24157e3c556c9ab16d85695fb7" 214 | dependencies = [ 215 | "proc-macro2", 216 | "quote", 217 | "unicode-xid", 218 | ] 219 | 220 | [[package]] 221 | name = "target-lexicon" 222 | version = "0.12.4" 223 | source = "registry+https://github.com/rust-lang/crates.io-index" 224 | checksum = "c02424087780c9b71cc96799eaeddff35af2bc513278cda5c99fc1f5d026d3c1" 225 | 226 | [[package]] 227 | name = "unicode-xid" 228 | version = "0.2.2" 229 | source = "registry+https://github.com/rust-lang/crates.io-index" 230 | checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" 231 | 232 | [[package]] 233 | name = "unindent" 234 | version = "0.1.7" 235 | source = "registry+https://github.com/rust-lang/crates.io-index" 236 | checksum = "f14ee04d9415b52b3aeab06258a3f07093182b88ba0f9b8d203f211a7a7d41c7" 237 | 238 | [[package]] 239 | name = "winapi" 240 | version = "0.3.9" 241 | source = "registry+https://github.com/rust-lang/crates.io-index" 242 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 243 | dependencies = [ 244 | "winapi-i686-pc-windows-gnu", 245 | "winapi-x86_64-pc-windows-gnu", 246 | ] 247 | 248 | [[package]] 249 | name = "winapi-i686-pc-windows-gnu" 250 | version = "0.4.0" 251 | source = "registry+https://github.com/rust-lang/crates.io-index" 252 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 253 | 254 | [[package]] 255 | name = "winapi-x86_64-pc-windows-gnu" 256 | version = "0.4.0" 257 | source = "registry+https://github.com/rust-lang/crates.io-index" 258 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 259 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "routrie" 3 | version = "0.8.0" 4 | edition = "2021" 5 | description = "Rust port of the Python stdlib routrie modules" 6 | readme = "README.md" 7 | license-file = "LICENSE.txt" 8 | 9 | [lib] 10 | name = "routrie" 11 | crate-type = ["cdylib"] 12 | 13 | [dependencies.pyo3] 14 | version = "^0.17.0" 15 | features = ["extension-module", "abi3-py37"] 16 | 17 | [dependencies] 18 | path-tree = "0.5.1" 19 | 20 | [package.metadata.maturin] 21 | python-source = "python" 22 | description-content-type = "text/markdown; charset=UTF-8; variant=GFM" 23 | name = "routrie._routrie" 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021 Adrian Garcia Badaracco 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PHONY: init build test 2 | 3 | .init: 4 | rm -rf .venv 5 | python -m venv .venv 6 | ./.venv/bin/pip install -r requirements-dev.txt -r requirements-bench.txt 7 | ./.venv/bin/pre-commit install 8 | touch .init 9 | 10 | .clean: 11 | rm -rf .init 12 | 13 | init: .clean .init 14 | 15 | build-develop: .init 16 | . ./.venv/bin/activate && maturin develop --release --strip 17 | 18 | test: build-develop 19 | ./.venv/bin/python test_routrie.py 20 | 21 | lint: build-develop 22 | ./.venv/bin/pre-commit run --all-files 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # routrie 2 | 3 | ![CI](https://github.com/adriangb/routrie/actions/workflows/python.yaml/badge.svg) 4 | 5 | A Python wrapper for Rust's `path-tree` router ([path-tree repo], [path-tree crate]). 6 | 7 | This is a blazingly fast HTTP URL router with support for matching path parameters and catch-all URLs. 8 | 9 | Usage: 10 | 11 | ```python 12 | from routrie import Router, Param 13 | 14 | # the generic parameter is the value being stored 15 | # normally this will be an endpoint / route instance 16 | router = Router( 17 | { 18 | "/users": 1, 19 | "/users/:id": 2, 20 | "/user/repo/*any": 3, 21 | } 22 | ) 23 | 24 | matched = router.find("/foo-bar-baz") 25 | assert matched is None 26 | 27 | matched = router.find("/users/routrie") 28 | assert matched is not None 29 | value, params = matched 30 | assert value == 2 31 | assert params[0].name == "id" 32 | assert params[0].value == "routrie" 33 | 34 | matched = router.find("/users") 35 | assert matched is not None 36 | value, params = matched 37 | assert value == 1 38 | assert params == [] 39 | 40 | matched = router.find("/users/repos/) 41 | assert matched is not None 42 | value, params = matched 43 | assert value == 3 44 | assert params == [] 45 | 46 | matched = router.find("/users/repos/something) 47 | assert matched is not None 48 | value, params = matched 49 | assert value == 3 50 | assert params[0].name = "any" 51 | assert params[0].value = "something" 52 | ``` 53 | 54 | ## Contributing 55 | 56 | 1. Clone the repo. 57 | 1. Run `make init` 58 | 1. Run `make test` 59 | 1. Make your changes 60 | 1. Push and open a pull request 61 | 1. Wait for CI to run. 62 | 63 | If your pull request gets approved and merged, it will automatically be relased to PyPi (every commit to `main` is released). 64 | 65 | [path-tree repo]: https://github.com/viz-rs/path-tree 66 | [path-tree crate]: https://crates.io/crates/path-tree/0.1.8/dependencies 67 | -------------------------------------------------------------------------------- /bench.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 11, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "from typing import Any, Dict, List\n", 10 | "\n", 11 | "from routrie import Router as RoutrieRouter\n", 12 | "from http_router import Router as HTTPRouter\n", 13 | "from starlette.routing import Route, Router as StarletteRouter" 14 | ] 15 | }, 16 | { 17 | "cell_type": "code", 18 | "execution_count": 12, 19 | "metadata": {}, 20 | "outputs": [], 21 | "source": [ 22 | "routes: Dict[str, Any] = {}\n", 23 | "\n", 24 | "async def endpoint(*args: Any) -> Any:\n", 25 | " ...\n", 26 | "\n", 27 | "# From https://github.com/klen/py-frameworks-bench\n", 28 | "for n in range(5):\n", 29 | " routes[f\"/route-{n}\"] = endpoint\n", 30 | " routes[f\"/route-dyn-{n}/{{part}}\"] = endpoint" 31 | ] 32 | }, 33 | { 34 | "cell_type": "code", 35 | "execution_count": 13, 36 | "metadata": {}, 37 | "outputs": [], 38 | "source": [ 39 | "paths_to_match: List[str] = []\n", 40 | "for n in range(1_000):\n", 41 | " paths_to_match.append(\"/route-0\")\n", 42 | " paths_to_match.append(\"/route-1\")\n", 43 | " paths_to_match.append(\"/route-2\")\n", 44 | " paths_to_match.append(\"/route-3\")\n", 45 | " paths_to_match.append(\"/route-4\")\n", 46 | " paths_to_match.append(f\"/route-dyn-0/foo-{n}\")\n", 47 | " paths_to_match.append(f\"/route-dyn-1/foo-{n}\")\n", 48 | " paths_to_match.append(f\"/route-dyn-2/foo-{n}\")\n", 49 | " paths_to_match.append(f\"/route-dyn-3/foo-{n}\")\n", 50 | " paths_to_match.append(f\"/route-dyn-4/foo-{n}\")" 51 | ] 52 | }, 53 | { 54 | "cell_type": "code", 55 | "execution_count": 14, 56 | "metadata": {}, 57 | "outputs": [], 58 | "source": [ 59 | "routrie_router = RoutrieRouter({path.replace(\"{part}\", \":part\"): val for path, val in routes.items()})" 60 | ] 61 | }, 62 | { 63 | "cell_type": "code", 64 | "execution_count": 15, 65 | "metadata": {}, 66 | "outputs": [ 67 | { 68 | "name": "stdout", 69 | "output_type": "stream", 70 | "text": [ 71 | "3.26 ms ± 37.1 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)\n" 72 | ] 73 | } 74 | ], 75 | "source": [ 76 | "%%timeit\n", 77 | "for path in paths_to_match:\n", 78 | " routrie_router.find(path)" 79 | ] 80 | }, 81 | { 82 | "cell_type": "code", 83 | "execution_count": 16, 84 | "metadata": {}, 85 | "outputs": [], 86 | "source": [ 87 | "http_router = HTTPRouter()\n", 88 | "for path, value in routes.items():\n", 89 | " http_router.route(path)(value)" 90 | ] 91 | }, 92 | { 93 | "cell_type": "code", 94 | "execution_count": 17, 95 | "metadata": {}, 96 | "outputs": [ 97 | { 98 | "name": "stdout", 99 | "output_type": "stream", 100 | "text": [ 101 | "7.93 ms ± 38.5 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)\n" 102 | ] 103 | } 104 | ], 105 | "source": [ 106 | "%%timeit\n", 107 | "for path in paths_to_match:\n", 108 | " http_router(path)" 109 | ] 110 | }, 111 | { 112 | "cell_type": "code", 113 | "execution_count": 18, 114 | "metadata": {}, 115 | "outputs": [], 116 | "source": [ 117 | "starlette_router = StarletteRouter(\n", 118 | " routes=[\n", 119 | " Route(path, endpoint)\n", 120 | " for path, endpoint in routes.items()\n", 121 | " ]\n", 122 | ")\n", 123 | "\n", 124 | "scopes_to_match = [\n", 125 | " {\n", 126 | " \"type\": \"http\",\n", 127 | " \"method\": \"GET\",\n", 128 | " \"path\": path\n", 129 | " }\n", 130 | " for path in paths_to_match\n", 131 | "]" 132 | ] 133 | }, 134 | { 135 | "cell_type": "code", 136 | "execution_count": 19, 137 | "metadata": {}, 138 | "outputs": [ 139 | { 140 | "name": "stdout", 141 | "output_type": "stream", 142 | "text": [ 143 | "26.1 ms ± 98.1 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)\n" 144 | ] 145 | } 146 | ], 147 | "source": [ 148 | "%%timeit\n", 149 | "# simulate what Starlette does internally\n", 150 | "for scope in scopes_to_match:\n", 151 | " for route in starlette_router.routes:\n", 152 | " match, _ = route.matches(scope)\n", 153 | " if match == match.FULL:\n", 154 | " break" 155 | ] 156 | }, 157 | { 158 | "cell_type": "markdown", 159 | "metadata": {}, 160 | "source": [ 161 | "Benchmark concurrency, we want to know if we're blocking the GIL or not" 162 | ] 163 | }, 164 | { 165 | "cell_type": "code", 166 | "execution_count": 20, 167 | "metadata": {}, 168 | "outputs": [ 169 | { 170 | "name": "stdout", 171 | "output_type": "stream", 172 | "text": [ 173 | "Threads: 0.0002532005310058594\n", 174 | "Sequential: 6.413459777832031e-05\n" 175 | ] 176 | } 177 | ], 178 | "source": [ 179 | "from concurrent.futures import ThreadPoolExecutor, wait\n", 180 | "from time import time\n", 181 | "\n", 182 | "# make a really large routing tree so that we spend a good chunk of time in Rust\n", 183 | "total_routes = 100_000\n", 184 | "routes = {\n", 185 | " f\"/:part1_{n}\" + f\"/foo/bar/baz\" * 1_000 + f\"/:part2_{n}\": n\n", 186 | " for n in range(total_routes)\n", 187 | "}\n", 188 | "router = RoutrieRouter(routes)\n", 189 | "\n", 190 | "path = f\"/part1_{total_routes-1}\" + f\"/foo/bar/baz\" * 1_000 + f\"/part2_{total_routes-1}\"\n", 191 | "\n", 192 | "def match() -> float:\n", 193 | " start = time()\n", 194 | " router.find(path)\n", 195 | " return start\n", 196 | "\n", 197 | "start = time()\n", 198 | "match()\n", 199 | "match()\n", 200 | "end = time()\n", 201 | "elapsed_sequential = end - start\n", 202 | "\n", 203 | "with ThreadPoolExecutor(max_workers=2) as exec:\n", 204 | " futures = (\n", 205 | " exec.submit(match),\n", 206 | " exec.submit(match),\n", 207 | " )\n", 208 | " wait(futures)\n", 209 | " end = time()\n", 210 | " start = min(f.result() for f in futures)\n", 211 | "elapsed_threads = end - start\n", 212 | "\n", 213 | "print(f\"Threads: {elapsed_threads}\")\n", 214 | "print(f\"Sequential: {elapsed_sequential}\")" 215 | ] 216 | } 217 | ], 218 | "metadata": { 219 | "kernelspec": { 220 | "display_name": "Python 3.10.3 ('.venv': venv)", 221 | "language": "python", 222 | "name": "python3" 223 | }, 224 | "language_info": { 225 | "codemirror_mode": { 226 | "name": "ipython", 227 | "version": 3 228 | }, 229 | "file_extension": ".py", 230 | "mimetype": "text/x-python", 231 | "name": "python", 232 | "nbconvert_exporter": "python", 233 | "pygments_lexer": "ipython3", 234 | "version": "3.10.3" 235 | }, 236 | "orig_nbformat": 4, 237 | "vscode": { 238 | "interpreter": { 239 | "hash": "d92ecb0bd55e1fa7be24a76e68e435da4939331ac004d74954b9f85a61214695" 240 | } 241 | } 242 | }, 243 | "nbformat": 4, 244 | "nbformat_minor": 2 245 | } 246 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | 2 | [project] 3 | name = "routrie" 4 | description = "Python wrapper for https://github.com/viz-rs/path-tree" 5 | authors = [ 6 | {name = "Adrian Garcia Badaracco"} 7 | ] 8 | license = { text = "MIT" } 9 | classifiers=[ 10 | "Development Status :: 3 - Alpha", 11 | "Intended Audience :: Developers", 12 | "License :: OSI Approved :: MIT License", 13 | "Topic :: Software Development", 14 | "Topic :: Software Development :: Libraries", 15 | "Topic :: Software Development :: Libraries :: Python Modules", 16 | ] 17 | requires-python = ">=3.7" 18 | 19 | [project.urls] 20 | homepage = "https://github.com/adriangb/routrie" 21 | documentation = "https://github.com/adriangb/routrie/README.md" 22 | repository = "https://github.com/adriangb/routrie" 23 | 24 | [build-system] 25 | requires = ["maturin>=0.13.0"] 26 | build-backend = "maturin" 27 | 28 | [tool.maturin] 29 | sdist-include = ["Cargo.lock"] 30 | strip = true 31 | 32 | [tool.isort] 33 | profile = "black" 34 | -------------------------------------------------------------------------------- /python/routrie/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Generic, Mapping, Optional, Sequence, Tuple, Type, TypeVar 4 | 5 | from routrie._routrie import Router as _Router 6 | 7 | T = TypeVar("T") 8 | 9 | Route = Tuple[str, T] 10 | Params = Sequence[Tuple[str, str]] 11 | Match = Tuple[T, Params] 12 | 13 | 14 | class Router(Generic[T]): 15 | __slots__ = ("_router", "_routes") 16 | 17 | _router: _Router[T] 18 | _routes: Mapping[str, T] 19 | 20 | def __init__(self, routes: Mapping[str, T]) -> None: 21 | self._router = _Router() 22 | self._routes = dict(routes) 23 | for path, value in routes.items(): 24 | self._router.insert(path, value) 25 | 26 | def find(self, path: str) -> Optional[Match[T]]: 27 | return self._router.find(path) 28 | 29 | def __reduce__( 30 | self, 31 | ) -> Tuple[Type[Router[T]], Tuple[Mapping[str, T]]]: 32 | return (self.__class__, (self._routes,)) 33 | -------------------------------------------------------------------------------- /python/routrie/_routrie.pyi: -------------------------------------------------------------------------------- 1 | from typing import Generic, List, Tuple, TypeVar 2 | 3 | T = TypeVar("T") 4 | 5 | Params = List[Tuple[str, str]] 6 | 7 | class Router(Generic[T]): 8 | def insert(self, __path: str, __value: T) -> None: ... 9 | def find(self, __path: str) -> Tuple[T, Params]: ... 10 | -------------------------------------------------------------------------------- /python/routrie/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adriangb/routrie/8e6a3862b790b42b80af1efdd5e9c11d30ff2af1/python/routrie/py.typed -------------------------------------------------------------------------------- /requirements-bench.txt: -------------------------------------------------------------------------------- 1 | http-router==2.6.5 2 | starlette==0.20.3 -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest==7.1.2 2 | maturin==0.13.3 3 | pre-commit==2.19.0 4 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use path_tree::PathTree; 2 | use pyo3::prelude::*; 3 | 4 | #[pyclass(module = "routrie._routrie")] 5 | struct Router { 6 | router: PathTree>, 7 | // path-tree dropped support for empty values 8 | // so we implement it here as a special case 9 | empty: Option>, 10 | } 11 | 12 | type MatchedRoute<'a> = (&'a Py, Vec<(&'a str, &'a str)>); 13 | 14 | #[pymethods] 15 | impl Router { 16 | #[new] 17 | fn new() -> Self { 18 | Router { 19 | router: PathTree::new(), 20 | empty: None, 21 | } 22 | } 23 | fn insert(&mut self, path: &str, data: &PyAny, py: Python) { 24 | match path.is_empty() { 25 | true => self.empty = Some(data.into()), 26 | false => { 27 | self.router.insert(path, data.into_py(py)); 28 | } 29 | } 30 | } 31 | fn find<'a>(&'a self, path: &'a str) -> Option> { 32 | match path.is_empty() { 33 | true => self.empty.as_ref().map(|v| (v, vec![])), 34 | false => match self.router.find(path) { 35 | None => None, 36 | Some(path) => Some((path.value, path.params())), 37 | }, 38 | } 39 | } 40 | } 41 | 42 | #[pymodule] 43 | fn _routrie(_py: Python, m: &PyModule) -> PyResult<()> { 44 | m.add_class::()?; 45 | Ok(()) 46 | } 47 | -------------------------------------------------------------------------------- /test_routrie.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | 3 | import pytest 4 | 5 | from routrie import Router 6 | 7 | 8 | def test_routing() -> None: 9 | router = Router( 10 | routes={ 11 | "/": 0, 12 | "/users": 1, 13 | "/users/:id": 2, 14 | "/users/:id/:org": 3, 15 | "/users/:user_id/repos": 4, 16 | "/users/:user_id/repos/:id": 5, 17 | "/users/:user_id/repos/:id/*any": 6, 18 | "/:username": 7, 19 | "/:any*": 8, 20 | "/about": 9, 21 | "/about/": 10, 22 | "/about/us": 11, 23 | "/users/repos/*any": 12, 24 | } 25 | ) 26 | 27 | # Matched "/" 28 | node = router.find("/") 29 | assert node is not None 30 | match, params = node 31 | assert match == 0 32 | assert params == [] 33 | 34 | # Matched "/:username" 35 | node = router.find("/username") 36 | assert node is not None 37 | match, params = node 38 | assert match == 7 39 | assert params == [("username", "username")] 40 | 41 | # Matched "/*any" 42 | node = router.find("/user/s") 43 | assert node is not None 44 | match, params = node 45 | assert match == 8 46 | assert params == [("any", "user/s")] 47 | 48 | 49 | def test_no_match() -> None: 50 | router = Router(routes={"/": 0}) 51 | 52 | # No match 53 | node = router.find("/noway-jose") 54 | assert node is None 55 | 56 | 57 | def test_empty_path() -> None: 58 | router = Router(routes={"/": 0, "": 1,}) 59 | 60 | node = router.find("/") 61 | assert node is not None 62 | match, params = node 63 | assert match == 0 64 | assert params == [] 65 | 66 | node = router.find("") 67 | assert node is not None 68 | match, params = node 69 | assert match == 1 70 | assert params == [] 71 | 72 | 73 | def test_serialization() -> None: 74 | router = Router({"/": 0}) 75 | 76 | router: Router[int] = pickle.loads(pickle.dumps(router)) 77 | 78 | # No match 79 | node = router.find("/noway-jose") 80 | assert node is None 81 | # Match 82 | node = router.find("/") 83 | assert node is not None 84 | match, params = node 85 | assert match == 0 86 | assert params == [] 87 | 88 | 89 | def test_duplicate_route() -> None: 90 | # only the last one is preserved 91 | router = Router( 92 | routes=dict( 93 | [ 94 | ("/:foo", 0), 95 | ("/:bar", 1), 96 | ] 97 | ) 98 | ) 99 | 100 | node = router.find("/baz") 101 | assert node is not None 102 | match, params = node 103 | assert match == 0 104 | assert params == [("foo", "baz")] 105 | 106 | 107 | if __name__ == "__main__": 108 | pytest.main() 109 | --------------------------------------------------------------------------------