diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2d70100 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,44 @@ +# EditorConfig is awesome: https://EditorConfig.org + +root = true + +[*] +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true +end_of_line = lf + +[*.py] +indent_style = space +indent_size = 4 + +[*.lark] +indent_style = space + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +[*.json] +indent_style = space +indent_size = 4 + +[*.toml] +indent_style = space +indent_size = 4 + +[*.md] +charset = latin1 +indent_style = space +indent_size = 2 +# 2 trailing spaces indicate line breaks. +trim_trailing_whitespace = false + +[*.{rst,txt}] +indent_style = space +indent_size = 4 + +[{*.ini,.bandit,.flake8}] +charset = latin1 +indent_style = space +indent_size = 4 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..d667282 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# see https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners + + +# all maintainers are default-reviewers of new pull requests. +* @jkowalleck \ No newline at end of file diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index dbaedd4..b413f04 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -10,30 +10,66 @@ name: Upload Python Package on: release: - types: [published] + types: [ published ] + +env: + PYTHON_VERSION_DEFAULT: "3.14" permissions: contents: read jobs: - deploy: - + quick-test: + name: QuickTest runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file + - name: Checkout + # see https://github.com/actions/checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Setup Python Environment + # see https://github.com/actions/setup-python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: ${{ env.PYTHON_VERSION_DEFAULT }} + architecture: 'x64' + - name: Validate Python Environment + shell: python + run: | + import sys + print('Python %s on %s in %s' % (sys.version, sys.platform, sys.getdefaultencoding())) + - name: Install env dependencies + run: python -m pip install --upgrade pip + - name: Install project + run: python -m pip install --user -e ".[testing]" + - name: Run Tests + run: python -m pytest tests + publish: + name: Publish + needs: + - quick-test + runs-on: ubuntu-latest + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + steps: + - name: Checkout + # see https://github.com/actions/checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Setup Python Environment + # see https://github.com/actions/setup-python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: ${{ env.PYTHON_VERSION_DEFAULT }} + architecture: 'x64' + - name: Install env dependencies + run: | + python -m pip install --upgrade pip + python -m pip install build + - name: Build package + run: python -m build + - name: Publish package + # see https://github.com/pypa/gh-action-pypi-publish + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 \ No newline at end of file diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..e0262eb --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,107 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + + +name: Python CI + +on: + workflow_dispatch: + pull_request: + push: + tags: [ 'v*' ] # run again on release tags to have tools mark them + branches: [ 'main', 'next' ] + schedule: + # schedule daily tests, since some dependencies are not intended to be pinned + # this means: at 23:42 every day + - cron: '42 23 * * *' + +env: + PYTHON_VERSION_DEFAULT: "3.14" + +permissions: + contents: read + +jobs: + static-code-analysis: + name: StaticCodingAnalysis (py${{ matrix.python-version}} ${{ matrix.toxenv-factors }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + include: + - python-version: '3.14' # latest + os: ubuntu-latest + - python-version: '3.9' # lowest + os: ubuntu-latest + steps: + - name: Checkout + # see https://github.com/actions/checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Setup Python Environment + # see https://github.com/actions/setup-python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + - name: Install project + run: pip install --user -e ".[testing]" + - name: Run Mypy + run: python -m mypy -v + build-and-test: + name: Test (${{ matrix.os }} py${{ matrix.python-version }} ${{ matrix.toxenv-factors }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + python-version: + - "3.14" # highest supported + - "3.13" + - "3.12" + - "3.11" + - "3.10" + - "3.9" # lowest supported + exclude: + - os: macos-latest # macos-latest is incompatible with some PY versions + python-version: "3.10" + - os: macos-latest # macos-latest is incompatible with some PY versions + python-version: "3.9" + steps: + - name: Disabled Git auto EOL CRLF transforms + run: | + git config --global core.autocrlf false + git config --global core.eol lf + - name: Checkout + # see https://github.com/actions/checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Setup Python Environment + # see https://github.com/actions/setup-python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: ${{ matrix.python-version }} + architecture: 'x64' + - name: Validate Python Environment + shell: python + run: | + import sys + print('Python %s on %s in %s' % (sys.version, sys.platform, sys.getdefaultencoding())) + - name: Install env dependencies + run: python -m pip install --upgrade pip + - name: Install project + run: pip install --user -e ".[testing]" + - name: Run Tests + run: python -m pytest tests \ No newline at end of file diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000..727ebb1 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,44 @@ +# Analyzes all GitHub Actions workflows for security issues using zizmor. +# docs: https://docs.zizmor.sh/ +name: Zizmor + +on: + push: + branches: [ 'master', 'main' ] + pull_request: + branches: [ '**' ] + workflow_dispatch: + schedule: + - cron: '0 0 * * 6' + +permissions: { } + +concurrency: + group: '${{ github.workflow }}-${{ github.ref }}' + cancel-in-progress: true + +jobs: + zizmor: + name: Zizmor + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout + # see https://github.com/actions/checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Run zizmor 🌈 + # see https://github.com/zizmorcore/zizmor-action + uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 + with: + # advanced-security: false => emit findings as workflow-command annotations (::error file=…) rather than + # uploading a SARIF report to GitHub's Security tab. + # Uploading SARIF requires `security-events: write` and GitHub Advanced Security (GHAS), + # both of which are unnecessary here and would violate the least-privilege policy. + # The two modes are mutually exclusive: advanced-security must be false for + # annotations to take effect. + advanced-security: false + annotations: true diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 0000000..13fb917 --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,34 @@ +[mypy] + +files = src/ + +show_error_codes = True +pretty = True + +warn_unreachable = True +allow_redefinition = False + +# ignore_missing_imports = False +# follow_imports = normal +# follow_imports_for_stubs = True + +### Strict mode ### +warn_unused_configs = True +disallow_subclassing_any = True +disallow_any_generics = True +disallow_untyped_calls = True +disallow_untyped_defs = True +disallow_incomplete_defs = True +check_untyped_defs = True +disallow_untyped_decorators = True +no_implicit_optional = True +warn_redundant_casts = True +warn_unused_ignores = True +warn_return_any = True +no_implicit_reexport = True + +[mypy-pytest.*] +ignore_missing_imports = True + +[mypy-tests.*] +disallow_untyped_decorators = False \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bb58846 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## Unreleased + + + +* Fixed + * Align RFC 3987 grammar handling with the specification (via [#10]) + * Fix RFC 3986-delegated IRI parsing edge cases (via [#10]) +* Changed + * Update grammar rules used by `is_valid_syntax(...)` and term-specific validators (via [#10]) +* Added + * Validate IPv6 compression (via [#10]) + * Assert static typing (via [#16]) +* Tests + * Add grammar regression tests (via [#10]) + +[#10]: https://github.com/jkowalleck/rfc3987-syntax2/pull/10 +[#16]: https://github.com/jkowalleck/rfc3987-syntax2/pull/16 + +## v1.1.1 - 2026-07-14 + +### Fork release `rfc3987-syntax2` +* Forked from [`willynilly/rfc3987-syntax`](https://github.com/willynilly/rfc3987-syntax). +* Published this project as an independently maintained package under the new distribution name `rfc3987-syntax2`. +* Preserved upstream functionality as the baseline for this initial fork release. + +### Notes for `rfc3987-syntax2` +* This is the first release of the fork. +* No intentional behavioral changes from upstream are introduced in this release unless explicitly listed above. + +## v1.1.0 - 2025-07-18 + +Original release: + +* Features + * Added add support for IRI-reference and absolute-IRI +* Bugs + * Fixed single quote sub-delimiter + +## v1.0.0 - 2025-05-18 + +Original release: diff --git a/CITATION.cff b/CITATION.cff deleted file mode 100644 index f062222..0000000 --- a/CITATION.cff +++ /dev/null @@ -1,71 +0,0 @@ -cff-version: 1.2.0 -title: rfc3987-syntax -message: >- - If you use this software, please cite it using the - metadata from this file. -type: software -authors: - - given-names: Will - family-names: Riley - email: wanderingwill@gmail.com - orcid: "https://orcid.org/0000-0003-1822-6756" - - given-names: Jan - family-names: Kowalleck -repository-code: >- - https://github.com/willynilly/rfc3987-syntax -abstract: >- - Helper functions to syntactically validate strings according to RFC 3987 -keywords: - - RFC 3987 - - RFC3987 - - validator - - syntax - - parser -license: MIT -version: "1.1.0" -date-released: "2025-07-18" -references: - - title: "abnf-to-regexp" - type: software - version: "1.1.3" - license: MIT - authors: - - given-names: Marko - family-names: Ristin - email: marko@ristin.ch - orcid: "" - - given-names: Oliver Steensen-Bech - family-names: Haagh - email: oliver@dmc.international - orcid: "" - - given-names: Sebastian - family-names: Heppner - email: s.heppner@iat.rwth-aachen.de - orcid: "" - repository-code: https://github.com/aas-core-works/abnf-to-regexp - - title: "lark" - type: software - version: 1.2.2 - license: MIT - authors: - - family-names: Shinan - given-names: Erez - email: erezshin@gmail.com - repository-code: https://github.com/lark-parser/lark - - title: "Internationalized Resource Identifiers (IRIs)" - authors: - - family-names: DΓΌrst - given-names: Martin - - family-names: Suignard - given-names: Michel - date-released: 2005-01-01 - doi: "10.17487/RFC3987" - url: "https://www.rfc-editor.org/info/rfc3987" - type: standard - - title: "ChatGPT" - authors: - - name: OpenAI - type: software - version: "GPT-4o" - url: "https://chat.openai.com/chat" - \ No newline at end of file diff --git a/LICENSE b/LICENSE index dbc72c3..6efa3e6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2025 Will Riley +Copyright (c) 2026 Jan Kowalleck β€” modifications and maintenance. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 2e4d2e2..395762f 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,30 @@ -# rfc3987-syntax +# rfc3987-syntax2 + +[![shield_pypi-version]][link_pypi] +[![shield_gh-workflow-test]][link_gh-workflow-test] +[![shield_license]][license_file] + +---- Helper functions to parse and validate the **syntax** of terms defined in **[RFC 3987](https://www.rfc-editor.org/info/rfc3987)** β€” the IETF standard for Internationalized Resource Identifiers (IRIs). +> [!NOTE] +> This is a maintained fork of . ## 🎯 Purpose -The goal of `rfc3987-syntax` is to provide a **lightweight, permissively licensed Python module** for validating that strings conform to the **ABNF grammar defined in RFC 3987**. These helpers are: +The goal of `rfc3987-syntax2` is to provide a **lightweight, permissively licensed Python module** for validating that strings conform to the **ABNF grammar defined in RFC 3987**. These helpers are: - βœ… Strictly aligned with the **syntax rules of RFC 3987** - βœ… Built using a **permissive MIT license** - βœ… Designed for both **open source and proprietary use** - βœ… Powered by [Lark](https://github.com/lark-parser/lark), a fast, EBNF-based parser -> 🧠 **Note:** This project focuses on **syntax validation only**. RFC 3987 specifies **additional semantic rules** (e.g., Unicode normalization, BiDi constraints, percent-encoding requirements) that must be enforced separately. - +> 🧠 **Note:** This project focuses on **syntax validation only**. RFC 3987 specifies **additional semantic rules** (e.g., Unicode normalization, BiDi constraints, percent-encoding requirements) that are intentionally out of scope. ## πŸ“„ License, Attribution, and Citation -**`rfc3987-syntax`** is licensed under the [MIT License](LICENSE), which allows reuse in both open source and commercial software. +**`rfc3987-syntax2`** is licensed under the [MIT License](LICENSE), which allows reuse in both open source and commercial software. This project: @@ -27,26 +34,22 @@ This project: > ⚠️ This project is **not affiliated with or endorsed by** the authors of RFC 3987 or the `rfc3987` Python package. -Please cite this software in accordance with the enclosed CITATION.cff file. - - ## ⚠️ Limitations -The grammar and parser enforce **only the ABNF syntax** defined in RFC 3987. The following are **not validated** and must be handled separately for full compliance: +The grammar and parser enforce **only the ABNF syntax** defined in RFC 3987 (and referenced RFC 3986 productions where applicable). The following are **not validated** and must be handled separately for full compliance: - βœ… Unicode **Normalization Form C (NFC)** - βœ… Bidirectional text (**BiDi**) constraints (RFC 3987 Β§4.1) - βœ… **Port number ranges** (must be 0–65535) -- βœ… Valid **IPv6 compression** (only one `::`, max segments) - βœ… Context-aware **percent-encoding** requirements +- βœ… Additional host/network semantics beyond ABNF syntax (for example, canonical representation expectations) ChatGPT 40 was used during the original development process. Errors may exist due to this assistance. Additional review, testing, and bug fixes by human experts is welcome. - ## πŸ“¦ Installation -```bash -pip install rfc3987-syntax +```sh +pip install rfc3987-syntax2 ``` ## πŸ›  Usage @@ -54,7 +57,7 @@ pip install rfc3987-syntax ### List all supported "terms" (i.e., non-terminals and terminals within ABNF production rules) used to validate the syntax of an IRI according to RFC 3987 ```python -from rfc3987_syntax import RFC3987_SYNTAX_TERMS +from rfc3987_syntax2 import RFC3987_SYNTAX_TERMS print("Supported terms:") for term in RFC3987_SYNTAX_TERMS: @@ -64,7 +67,7 @@ for term in RFC3987_SYNTAX_TERMS: ### Syntactically validate a string using the general-purpose validator ```python -from rfc3987_syntax import is_valid_syntax +from rfc3987_syntax2 import is_valid_syntax if is_valid_syntax(term='iri', value='http://github.com'): print("βœ“ Valid IRI syntax") @@ -79,15 +82,15 @@ if not is_valid_syntax(term='iri_reference', value='bob'): ### Alternatively, use term-specific helpers to validate RFC 3987 syntax. ```python -from rfc3987_syntax import is_valid_syntax_iri -from rfc3987_syntax import is_valid_syntax_iri_reference +from rfc3987_syntax2 import is_valid_syntax_iri +from rfc3987_syntax2 import is_valid_syntax_iri_reference if is_valid_syntax_iri('http://github.com'): print("βœ“ Valid IRI syntax") if not is_valid_syntax_iri('bob'): print("βœ— Invalid IRI syntax") - + if is_valid_syntax_iri_reference('bob'): print("βœ“ Valid IRI-reference syntax") ``` @@ -95,7 +98,7 @@ if is_valid_syntax_iri_reference('bob'): ### Get the Lark parse tree for a syntax validation (useful for additional semantic validation) ```python -from rfc3987_syntax import parse +from rfc3987_syntax2 import parse ptree: ParseTree = parse(term="iri", value="http://github.com") @@ -106,11 +109,11 @@ print(ptree) This grammar was derived from: -- **[RFC 3987 – Internationalized Resource Identifiers (IRIs)]** +- **RFC 3987 – Internationalized Resource Identifiers (IRIs)** β†’ Defines IRI syntax and extensions to URI (e.g. Unicode characters, `ucschar`) β†’ https://datatracker.ietf.org/doc/html/rfc3987 -- **[RFC 3986 – Uniform Resource Identifier (URI): Generic Syntax)]** +- **RFC 3986 – Uniform Resource Identifier (URI): Generic Syntax** β†’ Provides reusable components like `scheme`, `authority`, `ipv4address`, etc. β†’ https://datatracker.ietf.org/doc/html/rfc3986 @@ -118,33 +121,41 @@ This grammar was derived from: ### Rule-to-Source Mapping -| Rule/Component | Source | Notes | -|----------------------|------------|-------| -| `iri` | RFC 3987 | Top-level IRI rule | -| `iri_reference` | RFC 3987 | Top-level IRI Reference rule | -| `absolute_iri` | RFC 3987 | Top-level Absolute IRI rule | -| `scheme` | RFC 3986 | Referenced by RFC 3987 Β§2.2 | -| `ihier_part` | RFC 3987 | IRI-specific hierarchy | -| `irelative_ref` | RFC 3987 | IRI-specific relative ref | -| `irelative_part` | RFC 3987 | IRI-specific relative part | -| `iauthority` | RFC 3986 | Standard URI authority | -| `ipath_abempty` | RFC 3986 | Path format variant | -| `ipath_absolute` | RFC 3986 | Absolute path | -| `ipath_noscheme` | RFC 3986 | Path disallowing scheme prefix | -| `ipath_rootless` | RFC 3986 | Used in non-scheme contexts | -| `iquery` | RFC 3987 | Query extension to URI | -| `ifragment` | RFC 3987 | Fragment extension to URI | -| `ipchar`, `isegment` | RFC 3986 | Path characters and segments | -| `isegment_nz_nc` | RFC 3987 | IRI-specific path constraint | -| `iunreserved` | RFC 3987 | Includes `ucschar` | -| `ucschar`, `iprivate`| RFC 3987 | Unicode support | -| `sub_delims` | RFC 3986 | Reserved characters | -| `ip_literal` | RFC 3986 | IPv6 or IPvFuture in `[]` | -| `ipv6address` | RFC 3986 | Expanded forms only | -| `ipvfuture` | RFC 3986 | Forward-compatible | -| `ipv4address` | RFC 3986 | Dotted-decimal IPv4 | -| `ls32` | RFC 3986 | Final 32 bits of IPv6 | -| `h16`, `dec_octet` | RFC 3986 | Hex and decimal chunks | -| `port` | RFC 3986 | Optional numeric | -| `pct_encoded` | RFC 3986 | Percent encoding (e.g. `%20`) | +| Rule/Component | Source | Notes | +|----------------|--------|-------| +| `iri` | RFC 3987 | Top-level IRI rule | +| `iri_reference` | RFC 3987 | Top-level IRI Reference rule | +| `absolute_iri` | RFC 3987 | Top-level Absolute IRI rule | +| `scheme` | RFC 3986 | Referenced by RFC 3987 Β§2.2 | +| `ihier_part` | RFC 3987 | IRI-specific hierarchy | +| `irelative_ref` | RFC 3987 | IRI-specific relative ref | +| `irelative_part` | RFC 3987 | IRI-specific relative part | +| `iauthority` | RFC 3986 | Standard URI authority | +| `ipath_abempty` | RFC 3986 | Path format variant | +| `ipath_absolute` | RFC 3986 | Absolute path | +| `ipath_noscheme` | RFC 3986 | Path disallowing scheme prefix | +| `ipath_rootless` | RFC 3986 | Used in non-scheme contexts | +| `iquery` | RFC 3987 | Query extension to URI | +| `ifragment` | RFC 3987 | Fragment extension to URI | +| `ipchar`, `isegment` | RFC 3986 | Path characters and segments | +| `isegment_nz_nc` | RFC 3987 | IRI-specific path constraint | +| `iunreserved` | RFC 3987 | Includes `ucschar` | +| `ucschar`, `iprivate` | RFC 3987 | Unicode support | +| `sub_delims` | RFC 3986 | Reserved characters | +| `ip_literal` | RFC 3986 | IPv6 or IPvFuture in `[]` | +| `ipv6address` | RFC 3986 | RFC 3986 IPv6address production; syntax-level validation only | +| `ipvfuture` | RFC 3986 | Forward-compatible | +| `ipv4address` | RFC 3986 | Dotted-decimal IPv4 | +| `ls32` | RFC 3986 | Final 32 bits of IPv6 | +| `h16`, `dec_octet` | RFC 3986 | Hex and decimal chunks | +| `port` | RFC 3986 | Optional numeric | +| `pct_encoded` | RFC 3986 | Percent encoding (e.g. `%20`) | | `alpha`, `digit`, `hexdig` | RFC 3986 | Character classes | + +[shield_gh-workflow-test]: https://img.shields.io/github/actions/workflow/status/jkowalleck/rfc3987-syntax2/python-tests.yml?branch=main&logo=GitHub&logoColor=white "tests" +[shield_pypi-version]: https://img.shields.io/pypi/v/rfc3987-syntax2?logo=Python&logoColor=white&label=PyPI "PyPI" +[shield_license]: https://img.shields.io/github/license/jkowalleck/rfc3987-syntax2?logo=open%20source%20initiative&logoColor=white "license" + +[link_pypi]: https://pypi.org/project/rfc3987-syntax2/ +[link_gh-workflow-test]: https://github.com/jkowalleck/rfc3987-syntax2/actions/workflows/python-tests.yml?query=branch%3Amain +[license_file]: https://github.com/jkowalleck/rfc3987-syntax2/blob/main/LICENSE diff --git a/pyproject.toml b/pyproject.toml index e0e1c16..cbcc0e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,8 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "rfc3987-syntax" -version = "1.1.0" +name = "rfc3987-syntax2" +version = "1.1.1" description = "Helper functions to syntactically validate strings according to RFC 3987." readme = "README.md" requires-python = ">=3.9" @@ -12,42 +12,53 @@ license = "MIT" license-files = ["LICEN[CS]E*"] keywords = ["RFC 3987", "RFC3987", "validator", "syntax", "parser"] authors = [ - { name = "Will Riley", email = "wanderingwill@gmail.com" }, - { name = "Jan Kowalleck" } + { name = "Jan Kowalleck" }, + { name = "Will Riley", email = "wanderingwill@gmail.com" } ] classifiers = [ + # ALPHA - not all features of RFC 3987 are implemented, yet "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "Intended Audience :: System Administrators", - "License :: OSI Approved :: Apache Software License", + "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Topic :: Scientific/Engineering", "Topic :: Software Development", - "Topic :: Utilities" + "Topic :: Utilities", + "Typing :: Typed", ] dependencies = ["lark>=1.2.2"] [project.urls] -Homepage = "https://github.com/willynilly/rfc3987-syntax" -Documentation = "https://github.com/willynilly/rfc3987-syntax#readme" -Issues = "https://github.com/willynilly/rfc3987-syntax/issues" -Source = "https://github.com/willynilly/rfc3987-syntax" +Homepage = "https://github.com/jkowalleck/rfc3987-syntax2" +Documentation = "https://github.com/jkowalleck/rfc3987-syntax2#readme" +Issues = "https://github.com/jkowalleck/rfc3987-syntax2/issues" +Source = "https://github.com/jkowalleck/rfc3987-syntax2" [project.optional-dependencies] testing = [ + "mypy>=1.15,<2", + "typing_extensions>=4.16.0", "pytest>=8.3.5", ] [tool.pytest.ini_options] -pythonpath = [ - "src" +pythonpath = ["src"] + +[tool.hatch.build.targets.sdist] +exclude = [ + "/.*", + "/_*", +] +include = [ + "/src", + "/tests" ] -[tool.hatch.build.targets.sdist.force-include] -"CITATION.cff" = "src/rfc3987_syntax/CITATION.cff" \ No newline at end of file +[tool.hatch.build.targets.wheel] +packages = ["src/rfc3987_syntax2"] diff --git a/src/rfc3987_syntax/__init__.py b/src/rfc3987_syntax2/__init__.py similarity index 100% rename from src/rfc3987_syntax/__init__.py rename to src/rfc3987_syntax2/__init__.py diff --git a/src/rfc3987_syntax2/py.typed b/src/rfc3987_syntax2/py.typed new file mode 100644 index 0000000..161a597 --- /dev/null +++ b/src/rfc3987_syntax2/py.typed @@ -0,0 +1 @@ +# see https://peps.python.org/pep-0561/ \ No newline at end of file diff --git a/src/rfc3987_syntax/syntax_helpers.py b/src/rfc3987_syntax2/syntax_helpers.py similarity index 94% rename from src/rfc3987_syntax/syntax_helpers.py rename to src/rfc3987_syntax2/syntax_helpers.py index fcb3f32..2e3876e 100644 --- a/src/rfc3987_syntax/syntax_helpers.py +++ b/src/rfc3987_syntax2/syntax_helpers.py @@ -1,8 +1,10 @@ +from typing import Callable + from lark import Lark, ParseTree, exceptions from pathlib import Path -from rfc3987_syntax.utils import load_grammar +from .utils import load_grammar RFC3987_SYNTAX_PARSER_TYPE: str = "earley" RFC3987_SYNTAX_GRAMMAR_PATH: Path = Path(__file__).parent / "syntax_rfc3987.lark" @@ -54,7 +56,7 @@ def parse(term: str, value: str) -> ParseTree: return syntax_parser.parse(value, start=term) -def is_valid_syntax(term: str, value: str): +def is_valid_syntax(term: str, value: str) -> bool: try: parse(term=term, value=value) return True @@ -62,10 +64,10 @@ def is_valid_syntax(term: str, value: str): return False -def make_syntax_validator(rule_name): +def make_syntax_validator(rule_name: str) -> Callable[[str], bool]: parser = Lark(grammar, start=rule_name, parser=RFC3987_SYNTAX_PARSER_TYPE) - def syntax_validator(text): + def syntax_validator(text: str) -> bool: try: parser.parse(text) return True diff --git a/src/rfc3987_syntax/syntax_rfc3987.lark b/src/rfc3987_syntax2/syntax_rfc3987.lark similarity index 68% rename from src/rfc3987_syntax/syntax_rfc3987.lark rename to src/rfc3987_syntax2/syntax_rfc3987.lark index c1973c0..f0811e9 100644 --- a/src/rfc3987_syntax/syntax_rfc3987.lark +++ b/src/rfc3987_syntax2/syntax_rfc3987.lark @@ -57,8 +57,8 @@ ifragment: (ipchar | "/" | "?")* iunreserved: alpha | digit | "-" | "." | "_" | "~" | ucschar -ucschar: /[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]/ -iprivate: /[\uE000-\uF8FF]/ +ucschar: /[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF\U00010000-\U0001FFFD\U00020000-\U0002FFFD\U00030000-\U0003FFFD\U00040000-\U0004FFFD\U00050000-\U0005FFFD\U00060000-\U0006FFFD\U00070000-\U0007FFFD\U00080000-\U0008FFFD\U00090000-\U0009FFFD\U000A0000-\U000AFFFD\U000B0000-\U000BFFFD\U000C0000-\U000CFFFD\U000D0000-\U000DFFFD\U000E0000-\U000EFFFD]/ +iprivate: /[\uE000-\uF8FF\U000F0000-\U000FFFFD\U00100000-\U0010FFFD]/ sub_delims: "!" | "$" | "&" | "'" | "(" | ")" | "*" | "+" | "," | ";" | "=" @@ -67,15 +67,15 @@ ip_literal: "[" (ipv6address | ipvfuture) "]" ipvfuture: "v" hexdig+ "." (unreserved | sub_delims | ":")+ -ipv6address: h16 ":" h16 ":" h16 ":" h16 ":" h16 ":" h16 ":" ls32 - | "::" h16 ":" h16 ":" h16 ":" h16 ":" h16 ":" ls32 - | h16 "::" h16 ":" h16 ":" h16 ":" h16 ":" ls32 - | h16 ":" h16 "::" h16 ":" h16 ":" h16 ":" ls32 - | h16 ":" h16 ":" h16 "::" h16 ":" h16 ":" ls32 - | h16 ":" h16 ":" h16 ":" h16 "::" h16 ":" ls32 - | h16 ":" h16 ":" h16 ":" h16 ":" h16 "::" ls32 - | h16 ":" h16 ":" h16 ":" h16 ":" h16 ":" h16 "::" h16 - | h16 ":" h16 ":" h16 ":" h16 ":" h16 ":" h16 ":" h16 "::" +ipv6address: ( h16 ":" )~6 ls32 + | "::" ( h16 ":" )~5 ls32 + | h16? "::" ( h16 ":" )~4 ls32 + | ( ( h16 ":" )? h16 )? "::" ( h16 ":" )~3 ls32 + | ( ( h16 ":" )~0..2 h16 )? "::" ( h16 ":" )~2 ls32 + | ( ( h16 ":" )~0..3 h16 )? "::" h16 ":" ls32 + | ( ( h16 ":" )~0..4 h16 )? "::" ls32 + | ( ( h16 ":" )~0..5 h16 )? "::" h16 + | ( ( h16 ":" )~0..6 h16 )? "::" h16: hexdig | hexdig hexdig diff --git a/src/rfc3987_syntax/utils.py b/src/rfc3987_syntax2/utils.py similarity index 74% rename from src/rfc3987_syntax/utils.py rename to src/rfc3987_syntax2/utils.py index a310886..af16a5d 100644 --- a/src/rfc3987_syntax/utils.py +++ b/src/rfc3987_syntax2/utils.py @@ -1,6 +1,6 @@ from pathlib import Path -def load_grammar(path: Path): +def load_grammar(path: Path) -> str: with open(path, "r", encoding="utf-8") as file: return file.read() diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..4585dbd 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,30 @@ +import json +from pathlib import Path +from typing import TypedDict +from typing_extensions import Required, NotRequired + +TESTS_ROOT_DIR = Path(__file__).parent +TESTS_DATA_DIR = TESTS_ROOT_DIR / '_data' + +TESTS_DATA_FILES = { + 'invalid_syntax': TESTS_DATA_DIR / 'invalid_syntax.json', + 'valid_syntax': TESTS_DATA_DIR / 'valid_syntax.json', +} + +class SyntaxCase(TypedDict): + value: Required[str] + reason: Required[str] + expect_lark: NotRequired[bool] + expect_regex: NotRequired[bool] + conforms_with_rfc3987_semantics: NotRequired[bool] + semantics_notes: NotRequired[str] + +T_syntax_file = dict[str, list[SyntaxCase]] + +def valid_syntax_data() -> T_syntax_file: + with open(TESTS_DATA_FILES['valid_syntax'], "r", encoding="utf-8") as f: + return json.load(f) # type:ignore[no-any-return] + +def invalid_syntax_data() -> T_syntax_file: + with open(TESTS_DATA_FILES['invalid_syntax'], "r", encoding="utf-8") as f: + return json.load(f) # type:ignore[no-any-return] \ No newline at end of file diff --git a/tests/invalid_syntax.json b/tests/_data/invalid_syntax.json similarity index 100% rename from tests/invalid_syntax.json rename to tests/_data/invalid_syntax.json diff --git a/tests/valid_syntax.json b/tests/_data/valid_syntax.json similarity index 59% rename from tests/valid_syntax.json rename to tests/_data/valid_syntax.json index 6f60090..a6bba92 100644 --- a/tests/valid_syntax.json +++ b/tests/_data/valid_syntax.json @@ -108,10 +108,60 @@ "value": "ΠΏΡ€ΠΈΠΌΠ΅Ρ€/тСст#Ρ‡Π°ΡΡ‚ΡŒ2", "expect_lark": true, "reason": "" + }, + { + "value": "ucschar/\u00A0-\uD7FF/\uF900-\uFDCF/\uFDF0-\uFFEF", + "expect_lark": true, + "reason": "`ucschar` ranges: %xA0-D7FF / %xF900-FDCF / %xFDF0-FFEF" + }, + { + "value": "ucschar/\uD800\uDC00-\uD83F\uDFFD/\uD840\uDC00-\uD87F\uDFFD/\uD880\uDC00-\uD8BF\uDFFD", + "expect_lark": true, + "reason": "`ucschar` ranges: %x10000-1FFFD / %x20000-2FFFD / %x30000-3FFFD" + }, + { + "value": "ucschar/\uD8C0\uDC00-\uD8FF\uDFFD/\uD900\uDC00-\uD93F\uDFFD/\uD940\uDC00-\uD97F\uDFFD", + "expect_lark": true, + "reason": "`ucschar` ranges: %x40000-4FFFD / %x50000-5FFFD / %x60000-6FFFD" + }, + { + "value": "ucschar/\uD980\uDC00-\uD9BF\uDFFD/\uD9C0\uDC00-\uD9FF\uDFFD/\uDA00\uDC00-\uDA3F\uDFFD", + "expect_lark": true, + "reason": "`ucschar` ranges: %x70000-7FFFD / %x80000-8FFFD / %x90000-9FFFD" + }, + { + "value": "ucschar/\uDA40\uDC00-\uDA7F\uDFFD/\uDA80\uDC00-\uDABF\uDFFD/\uDAC0\uDC00-\uDAFF\uDFFD", + "expect_lark": true, + "reason": "`ucschar` ranges: %xA0000-AFFFD / %xB0000-BFFFD / %xC0000-CFFFD" + }, + { + "value": "ucschar/\uDB00\uDC00-\uDB3F\uDFFD/\uDB40\uDC00-\uDB7F\uDFFD", + "expect_lark": true, + "reason": "`ucschar` ranges: %xD0000-DFFFD / %xE0000-EFFFD" + }, + { + "value": "iprivate?foo=\uE000-\uF8FF&bar=\uDB80\uDC00-\uDBBF\uDFFD&baz=\uDBC0\uDC00-\uDBFF\uDFFD", + "expect_lark": true, + "reason": "`iprivate` ranges: %xE000-F8FF / %xF0000-FFFFD / %x100000-10FFFD" + }, + { + "value": "//[2345:0425:2CA1:0:0:0567:5673:23b5]/foo", + "expect_lark": true, + "reason": "`IPv6address` case: 6( h16 \":\" ) ls32" + }, + { + "value": "//[2345::BEEF]/foo", + "expect_lark": true, + "reason": "`IPv6address` case: [ *5( h16 \":\" ) h16 ] \"::\" h16" + }, + { + "value": "//[2041:0:140F::875B:131B]/foo", + "expect_lark": true, + "reason": "`IPv6address` case: [ *4( h16 \":\" ) h16 ] \"::\" ls32" } ], "reserved": [], "scheme": [], "pct_encoded": [], "ipv4address": [] -} \ No newline at end of file +} diff --git a/tests/test_syntax.py b/tests/test_syntax.py index d71923c..4262b88 100644 --- a/tests/test_syntax.py +++ b/tests/test_syntax.py @@ -1,43 +1,38 @@ -from pathlib import Path -import rfc3987_syntax as h -import json +from typing import TYPE_CHECKING + import pytest +from typing import Any, Callable + +import rfc3987_syntax2 as sut + +from . import valid_syntax_data, invalid_syntax_data, SyntaxCase, T_syntax_file + + +def syntax_data_as_params(src_cb: Callable[[], T_syntax_file]) -> Any: + for term, examples in src_cb().items(): + for example in examples: + yield pytest.param(term, example, id=f"{term}-{example['value']}") + + +@pytest.mark.parametrize("term,valid_example", syntax_data_as_params(valid_syntax_data)) +def test_is_valid_syntax(term: str, valid_example: SyntaxCase) -> None: + actual = sut.is_valid_syntax(term=term, value=valid_example["value"]) + print("") + print( + f"Testing {term} with {valid_example['value']} : {valid_example['reason']}" + ) + assert ( + actual is True + ), f"Failed term: {term} for '{valid_example['value']}' : {valid_example['reason']}" -@pytest.fixture -def valid_syntax_data(): - with open(Path("tests", "valid_syntax.json"), "r", encoding="utf-8") as f: - return json.load(f) - - -@pytest.fixture -def invalid_syntax_data(): - with open(Path("tests", "invalid_syntax.json"), "r", encoding="utf-8") as f: - return json.load(f) - - -def test_is_valid_syntax(valid_syntax_data): - for term, valid_examples in valid_syntax_data.items(): - for valid_example in valid_examples: - actual = h.is_valid_syntax(term=term, value=valid_example["value"]) - print("") - print( - f"Testing {term} with {valid_example['value']} : {valid_example['reason']}" - ) - assert ( - actual is True - ), f"Failed term: {term} for '{valid_example['value']}' : {valid_example['reason']}" - assert True - - -def test_not_is_valid_syntax(invalid_syntax_data): - for term, invalid_examples in invalid_syntax_data.items(): - for invalid_example in invalid_examples: - actual = h.is_valid_syntax(term=term, value=invalid_example["value"]) - print("") - print( - f"Testing {term} with {invalid_example['value']} : {invalid_example['reason']}" - ) - assert ( - actual is False - ), f"Failed term: {term} for '{invalid_example['value']}' : {invalid_example['reason']}" +@pytest.mark.parametrize("term,invalid_example", syntax_data_as_params(invalid_syntax_data)) +def test_not_is_valid_syntax(term: str, invalid_example: SyntaxCase) -> None: + actual = sut.is_valid_syntax(term=term, value=invalid_example["value"]) + print("") + print( + f"Testing {term} with {invalid_example['value']} : {invalid_example['reason']}" + ) + assert ( + actual is False + ), f"Failed term: {term} for '{invalid_example['value']}' : {invalid_example['reason']}"