pax_global_header00006660000000000000000000000064147562767200014532gustar00rootroot0000000000000052 comment=ec81337f99d8b557a6fce10f674c054050551444 roman-numerals-3.1.0/000077500000000000000000000000001475627672000144735ustar00rootroot00000000000000roman-numerals-3.1.0/.editorconfig000066400000000000000000000004121475627672000171450ustar00rootroot00000000000000# top-most EditorConfig file root = true # Unix-style newlines with a newline ending every file [*] end_of_line = lf insert_final_newline = true # 4 space indentation [*.{py,rs}] charset = utf-8 indent_style = space indent_size = 4 trim_trailing_whitespace = true roman-numerals-3.1.0/.gitattributes000066400000000000000000000003241475627672000173650ustar00rootroot00000000000000# Unix-style line endings [attr]unix text eol=lf *.md unix *.py unix *.rst unix *.toml unix *.txt unix # Language aware diff headers *.md diff=markdown *.py diff=python # *.rst diff=reStructuredText roman-numerals-3.1.0/.github/000077500000000000000000000000001475627672000160335ustar00rootroot00000000000000roman-numerals-3.1.0/.github/dependabot.yml000066400000000000000000000004551475627672000206670ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "cargo" directory: "/rust" schedule: interval: "daily" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" - package-ecosystem: "pip" directory: "/python" schedule: interval: "daily" roman-numerals-3.1.0/.github/workflows/000077500000000000000000000000001475627672000200705ustar00rootroot00000000000000roman-numerals-3.1.0/.github/workflows/create-release.yml000066400000000000000000000102351475627672000234750ustar00rootroot00000000000000name: Create release on: push: tags: - "v*.*.*" workflow_dispatch: permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true env: FORCE_COLOR: "1" UV_SYSTEM_PYTHON: "1" # make uv do global installs jobs: publish-pypi: runs-on: ubuntu-latest name: PyPI Release environment: release if: github.repository_owner == 'AA-Turner' permissions: attestations: write # for actions/attest id-token: write # for actions/attest & PyPI trusted publishing defaults: run: working-directory: python/ steps: - uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3" - name: Install uv uses: astral-sh/setup-uv@v5 with: version: latest enable-cache: false - name: Install build dependencies (pypa/build, twine) run: | uv pip install build "twine>=5.1" # resolution fails without betterproto uv pip install pypi-attestations==0.0.21 betterproto==2.0.0b6 - name: Copy licence file run: cp ../LICENCE.rst LICENCE.rst - name: Build distribution run: python -m build - name: Check distribution run: | twine check dist/* - name: Create Sigstore attestations for built distributions uses: actions/attest@v1 id: attest with: subject-path: "python/dist/*" predicate-type: "https://docs.pypi.org/attestations/publish/v1" predicate: "null" show-summary: "true" - name: Convert attestations to PEP 740 run: > python ../utils/convert_attestations.py "$BUNDLE_PATH" "$SIGNER_IDENTITY" env: BUNDLE_PATH: "${{ steps.attest.outputs.bundle-path }}" # workflow_ref example: AA-Turner/roman-numerals/.github/workflows/create-release.yml@refs/heads/master # this forms the "signer identity" for the attestations SIGNER_IDENTITY: "https://github.com/${{ github.workflow_ref }}" - name: Inspect PEP 740 attestations run: pypi-attestations inspect dist/*.publish.attestation - name: Prepare attestation bundles for uploading run: | mkdir -p /tmp/attestation-bundles cp "$BUNDLE_PATH" /tmp/attestation-bundles/ cp dist/*.publish.attestation /tmp/attestation-bundles/ env: BUNDLE_PATH: "${{ steps.attest.outputs.bundle-path }}" - name: Upload attestation bundles uses: actions/upload-artifact@v4 with: name: attestation-bundles path: /tmp/attestation-bundles/ - name: Mint PyPI API token id: mint-token uses: actions/github-script@v7 with: # language=JavaScript script: | // retrieve the ambient OIDC token const oidc_request_token = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; const oidc_request_url = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; const oidc_resp = await fetch(`${oidc_request_url}&audience=pypi`, { headers: {Authorization: `bearer ${oidc_request_token}`}, }); const oidc_token = (await oidc_resp.json()).value; // exchange the OIDC token for an API token const mint_resp = await fetch('https://pypi.org/_/oidc/github/mint-token', { method: 'post', body: `{"token": "${oidc_token}"}` , headers: {'Content-Type': 'application/json'}, }); const api_token = (await mint_resp.json()).token; // mask the newly minted API token, so that we don't accidentally leak it core.setSecret(api_token) core.setOutput('api-token', api_token) - name: Upload to PyPI env: TWINE_NON_INTERACTIVE: "true" TWINE_USERNAME: "__token__" TWINE_PASSWORD: "${{ steps.mint-token.outputs.api-token }}" run: | twine upload dist/* --attestations --verbose roman-numerals-3.1.0/.github/workflows/lint.yml000066400000000000000000000063701475627672000215670ustar00rootroot00000000000000name: Lint on: push: pull_request: workflow_dispatch: permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true env: FORCE_COLOR: "1" UV_SYSTEM_PYTHON: "1" # make uv do global installs RUST_BACKTRACE: "1" CARGO_TERM_COLOR: "always" jobs: ruff: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: persist-credentials: false - name: Install Ruff uses: astral-sh/ruff-action@v3 with: args: --version - name: Lint with Ruff run: ruff check python/ --output-format=github - name: Format with Ruff run: ruff format python/ --diff mypy: runs-on: ubuntu-latest defaults: run: working-directory: python/ steps: - uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3" - name: Install uv uses: astral-sh/setup-uv@v5 with: version: latest enable-cache: false - name: Copy licence file run: cp ../LICENCE.rst LICENCE.rst - name: Install dependencies run: uv pip install ".[lint,test]" - name: Type check with mypy run: mypy . pyright: runs-on: ubuntu-latest defaults: run: working-directory: python/ steps: - uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3" - name: Install uv uses: astral-sh/setup-uv@v5 with: version: latest enable-cache: false - name: Copy licence file run: cp ../LICENCE.rst LICENCE.rst - name: Install dependencies run: uv pip install ".[lint,test]" - name: Type check with pyright run: pyright . twine: runs-on: ubuntu-latest defaults: run: working-directory: python/ steps: - uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3" - name: Install uv uses: astral-sh/setup-uv@v5 with: version: latest enable-cache: false - name: Install dependencies run: uv pip install --upgrade twine build - name: Lint with twine run: | cp ../LICENCE.rst LICENCE.rst python -m build . twine check dist/* rustfmt: runs-on: ubuntu-latest defaults: run: working-directory: rust/ steps: - uses: actions/checkout@v4 with: persist-credentials: false - name: Install Rust uses: dtolnay/rust-toolchain@stable with: components: rustfmt - name: Format with rustfmt run: cargo fmt --all -- --check clippy: runs-on: ubuntu-latest defaults: run: working-directory: rust/ steps: - uses: actions/checkout@v4 with: persist-credentials: false - name: Install Rust uses: dtolnay/rust-toolchain@stable with: components: clippy - name: Lint with clippy run: cargo clippy --all-targets -- -D warnings --allow deprecated roman-numerals-3.1.0/.github/workflows/lock.yml000066400000000000000000000041351475627672000215460ustar00rootroot00000000000000name: Lock old threads on: schedule: # Run at midnight daily - cron: "0 0 * * *" workflow_dispatch: permissions: {} jobs: action: runs-on: ubuntu-latest if: github.repository_owner == 'AA-Turner' permissions: # to lock issues and PRs issues: write pull-requests: write steps: - uses: actions/github-script@v7 with: retries: 3 # language=JavaScript script: | const _FOUR_WEEKS_MILLISECONDS = 28 * 24 * 60 * 60 * 1000; const _FOUR_WEEKS_DATE = new Date(Date.now() - _FOUR_WEEKS_MILLISECONDS); const FOUR_WEEKS_AGO = `${_FOUR_WEEKS_DATE.toISOString().substring(0, 10)}T00:00:00Z`; const OWNER = context.repo.owner; const REPO = context.repo.repo; try { for (const thread_type of ["issue", "pr"]) { core.debug(`Finding ${thread_type}s to lock`); const query = thread_type === "issue" ? `repo:${OWNER}/${REPO} updated:<${FOUR_WEEKS_AGO} is:closed is:unlocked is:issue` : `repo:${OWNER}/${REPO} updated:<${FOUR_WEEKS_AGO} is:closed is:unlocked is:pr`; core.debug(`Using query '${query}'`); // https://octokit.github.io/rest.js/v21/#search-issues-and-pull-requests const {data: {items: results}} = await github.rest.search.issuesAndPullRequests({ q: query, order: "desc", sort: "updated", per_page: 100, }); for (const item of results) { if (item.locked) continue; const thread_num = item.number; core.debug(`Locking #${thread_num} (${thread_type})`); // https://octokit.github.io/rest.js/v21/#issues-lock await github.rest.issues.lock({ owner: OWNER, repo: REPO, issue_number: thread_num, lock_reason: "resolved", }); } } } catch (err) { core.setFailed(err.message); } roman-numerals-3.1.0/.github/workflows/test-python.yml000066400000000000000000000115261475627672000231160ustar00rootroot00000000000000name: Tests (Python) on: push: paths: - ".github/workflows/test-python.yml" - "python/**" pull_request: paths: - ".github/workflows/test-python.yml" - "python/**" permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true env: FORCE_COLOR: "1" PYTHONDEVMODE: "1" # -X dev PYTHONWARNDEFAULTENCODING: "1" # -X warn_default_encoding UV_SYSTEM_PYTHON: "1" # make uv do global installs defaults: run: working-directory: python/ jobs: ubuntu: runs-on: ubuntu-latest name: Python ${{ matrix.python }} timeout-minutes: 15 strategy: fail-fast: false matrix: python: - "3.9" - "3.10" - "3.11" - "3.12" - "3.13" steps: - uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - name: Check Python version run: python --version --version - name: Install uv uses: astral-sh/setup-uv@v5 with: version: latest enable-cache: false - name: Copy licence file run: cp ../LICENCE.rst LICENCE.rst - name: Install dependencies run: uv pip install .[test] - name: Test with pytest run: python -m pytest -vv --durations 25 env: PYTHONWARNINGS: "error" # treat all warnings as errors deadsnakes: runs-on: ubuntu-latest name: Python ${{ matrix.python }} timeout-minutes: 15 strategy: fail-fast: false matrix: python: - "3.14" steps: - uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python ${{ matrix.python }} (deadsnakes) uses: deadsnakes/action@v3.2.0 with: python-version: ${{ matrix.python }}-dev - name: Check Python version run: python --version --version - name: Copy licence file run: cp ../LICENCE.rst LICENCE.rst - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install .[test] - name: Test with pytest run: python -m pytest -vv --durations 25 env: PYTHONWARNINGS: "error" # treat all warnings as errors free-threaded: runs-on: ubuntu-latest name: Python ${{ matrix.python }} (free-threaded) timeout-minutes: 15 strategy: fail-fast: false matrix: python: - "3.13" steps: - uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python ${{ matrix.python }} (deadsnakes) uses: deadsnakes/action@v3.2.0 with: python-version: ${{ matrix.python }} nogil: true - name: Check Python version run: python --version --version - name: Copy licence file run: cp ../LICENCE.rst LICENCE.rst - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install .[test] - name: Test with pytest run: python -m pytest -vv --durations 25 env: PYTHONWARNINGS: "error" # treat all warnings as errors deadsnakes-free-threaded: runs-on: ubuntu-latest name: Python ${{ matrix.python }} (free-threaded) timeout-minutes: 15 strategy: fail-fast: false matrix: python: - "3.14" steps: - uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python ${{ matrix.python }} (deadsnakes) uses: deadsnakes/action@v3.2.0 with: python-version: ${{ matrix.python }}-dev nogil: true - name: Check Python version run: python --version --version - name: Copy licence file run: cp ../LICENCE.rst LICENCE.rst - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install .[test] - name: Test with pytest run: python -m pytest -vv --durations 25 env: PYTHONWARNINGS: "error" # treat all warnings as errors windows: runs-on: windows-latest name: Windows timeout-minutes: 15 steps: - uses: actions/checkout@v4 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3" - name: Check Python version run: python --version --version - name: Install uv uses: astral-sh/setup-uv@v5 with: version: latest enable-cache: false - name: Copy licence file run: cp ../LICENCE.rst LICENCE.rst - name: Install dependencies run: uv pip install .[test] - name: Test with pytest run: python -m pytest -vv --durations 25 env: PYTHONWARNINGS: "error" # treat all warnings as errors roman-numerals-3.1.0/.github/workflows/test-rust.yml000066400000000000000000000026541475627672000225740ustar00rootroot00000000000000name: Tests (Rust) on: push: paths: - ".github/workflows/test-rust.yml" - "rust/**" pull_request: paths: - ".github/workflows/test-rust.yml" - "rust/**" permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true env: FORCE_COLOR: "1" RUST_BACKTRACE: "1" CARGO_TERM_COLOR: "always" defaults: run: working-directory: rust/ jobs: cargo-test: runs-on: ${{ matrix.os }} name: Rust ${{ matrix.rust-version }} (${{ matrix.os }}) timeout-minutes: 15 strategy: fail-fast: false matrix: os: - "ubuntu-latest" - "windows-latest" - "macos-latest" rust-version: - "stable" - "beta" - "nightly" - "1.79.0" # MSRV exclude: - os: windows-latest rust-version: "nightly" - os: macos-latest rust-version: "nightly" steps: - uses: actions/checkout@v4 with: persist-credentials: false - name: Install Rust uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ matrix.rust-version }} - name: Test run: > cargo test --no-fail-fast --future-incompat-report - name: Test (no-std) run: > cargo test --lib --no-fail-fast --future-incompat-report --no-default-features roman-numerals-3.1.0/.gitignore000066400000000000000000000002341475627672000164620ustar00rootroot00000000000000*.pyc .idea/ .vscode/ .cache/ .mypy_cache/ .pytest_cache/ .ruff_cache/ .tox/ .venv/ venv/ env/ .coverage htmlcov dist/ Cargo.lock target/ .DS_Store roman-numerals-3.1.0/CHANGES.rst000066400000000000000000000012641475627672000163000ustar00rootroot00000000000000Release 3.1.0 (released 22 Feb 2025) ==================================== * Support flit-core 3.11. * Increase the minimum supported Rust version (MSRV) to 1.79.0. Release 3.0.0 (released 18 Feb 2025) ==================================== * Remove runtime imports from ``typing``. * Declare support for Python 3.14. * Implement ``RomanNumeral`` as a ``NonZero`` tuple-struct. * Add ``no-std`` support for the ``roman-numerals-rs`` crate. Release 2.0.0 (released 14 Nov 2024) ==================================== Dual-licence under either the Zero-Clause BSD or the CC0-1.0 Universal licence. Release 1.0.0 (released 10 Nov 2024) ==================================== First release. roman-numerals-3.1.0/LICENCE.rst000066400000000000000000000165211475627672000162740ustar00rootroot00000000000000========= Licence ========= This project is licenced under the terms of either the `Zero-Clause BSD licence`_ or the `CC0 1.0 Universal licence`_. Zero-Clause BSD Licence ======================= Copyright (c) 2024, Adam Turner Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. CC0 1.0 Universal licence ========================= Statement of Purpose -------------------- The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. 1. Copyright and Related Rights. -------------------------------- A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; ii. moral rights retained by the original author(s) and/or performer(s); iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; v. rights protecting the extraction, dissemination, use and reuse of data in a Work; vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. 2. Waiver. ---------- To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. 3. Public License Fallback. --------------------------- Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. 4. Limitations and Disclaimers. ------------------------------- a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. roman-numerals-3.1.0/README.rst000066400000000000000000000032611475627672000161640ustar00rootroot00000000000000=============== roman-numerals =============== .. image:: https://img.shields.io/pypi/v/roman-numerals-py.svg :target: https://pypi.org/project/roman-numerals-py/ :alt: Package on PyPI .. image:: https://img.shields.io/crates/v/roman-numerals-rs :target: https://crates.io/crates/roman-numerals-rs/ :alt: Package on Crates.io .. image:: https://img.shields.io/badge/Licence-0BSD-green.svg :target: https://opensource.org/license/0BSD :alt: Licence: 0BSD .. image:: https://img.shields.io/badge/Licence-CC0%201.0%20Universal-green.svg :target: https://creativecommons.org/publicdomain/zero/1.0/ :alt: Licence: CC0 1.0 Universal This project provides utilities manipulating well-formed Roman numerals, in various programming languages. Currently, there are implementations in Python__ and Rust__. __ ./python/README.rst __ ./rust/README.md Example usage ============= Rust ---- .. code-block:: rust use roman_numerals_rs::RomanNumeral; fn main() { let num = RomanNumeral::new(16); println!("{}", num); // XVI assert_eq!("XVI".parse().unwrap(), num); } Python ------ .. code-block:: python from roman_numerals import RomanNumeral num = RomanNumeral(16) print(num) # XVI assert RomanNumeral.from_string("XVI") == num Licence ======= This project is licenced under the terms of either the Zero-Clause BSD licence or the CC0 1.0 Universal licence. See `LICENCE.rst`__ for the full text of both licences. __ ./LICENCE.rst Contribution ------------ Unless explicitly stated otherwise, any contribution intentionally submitted for inclusion in this project shall be dual licensed as above, without any additional terms or conditions. roman-numerals-3.1.0/python/000077500000000000000000000000001475627672000160145ustar00rootroot00000000000000roman-numerals-3.1.0/python/.ruff.toml000066400000000000000000000023321475627672000177310ustar00rootroot00000000000000target-version = "py39" # Pin Ruff to Python 3.9 line-length = 88 output-format = "full" [format] preview = true quote-style = "single" [lint] preview = true select = ["ALL"] ignore = [ "COM812", # trailing comma missing "ISC001", # implicitly concatenated string literals on one line # Conflicts with ``ruff format`` "CPY001", # missing copyright notice at top of file # We don't use per-file copyright notices. "D107", # Missing docstring in `__init__` # Class docstrings are in the class body ] [lint.per-file-ignores] "roman_numerals/__init__.py" = [ "DOC201", # `return` is not documented in docstring "DOC501", # Raised exception `{exc}` missing from docstring # These don't yet support PEP 257 style field-lists. ] "tests/*" = [ "D", # tests don't need docstrings "S101", # pytest uses assert ] [lint.flake8-quotes] inline-quotes = "single" [lint.flake8-type-checking] exempt-modules = [] strict = true [lint.isort] forced-separate = [ "tests", ] required-imports = [ "from __future__ import annotations", ] [lint.pycodestyle] max-line-length = 95 [lint.pydocstyle] convention = "pep257" ignore-decorators = ["typing.overload"] ignore-var-parameters = true roman-numerals-3.1.0/python/README.rst000066400000000000000000000041351475627672000175060ustar00rootroot00000000000000=============== roman-numerals =============== A library for manipulating well-formed Roman numerals. Integers between 1 and 3,999 (inclusive) are supported. Numbers beyond this range will return an ``OutOfRangeError``. The classical system of roman numerals requires that the same character may not appear more than thrice consecutively, meaning that 'MMMCMXCIX' (3,999) is the largest well-formed Roman numeral. The smallest is 'I' (1), as there is no symbol for zero in Roman numerals. Both upper- and lower-case formatting of roman numerals are supported, and likewise for parsing strings, although the entire string must be of the same case. Numerals that do not adhere to the classical form are rejected with an ``InvalidRomanNumeralError``. Example usage ============= Creating a roman numeral ------------------------ .. code-block:: python from roman_numerals import RomanNumeral num = RomanNumeral(16) assert str(num) == 'XVI' num = RomanNumeral.from_string("XVI") assert int(num) == 16 Convert a roman numeral to a string ----------------------------------- .. code-block:: python from roman_numerals import RomanNumeral num = RomanNumeral(16) assert str(num) == 'XVI' assert num.to_uppercase() == 'XVI' assert num.to_lowercase() == 'xvi' assert repr(num) == 'RomanNumeral(16)' Extract the decimal value of a roman numeral -------------------------------------------- .. code-block:: python from roman_numerals import RomanNumeral num = RomanNumeral(42) assert int(num) == 42 Invalid input ------------- .. code-block:: python from roman_numerals import RomanNumeral, InvalidRomanNumeralError num = RomanNumeral.from_string("Spam!") # raises InvalidRomanNumeralError num = RomanNumeral.from_string("CLL") # raises InvalidRomanNumeralError num = RomanNumeral(0) # raises OutOfRangeError num = RomanNumeral(4_000) # raises OutOfRangeError Licence ======= This project is licenced under the terms of either the Zero-Clause BSD licence or the CC0 1.0 Universal licence. See `LICENCE.rst`__ for the full text of both licences. __ ./LICENCE.rst roman-numerals-3.1.0/python/pyproject.toml000066400000000000000000000052501475627672000207320ustar00rootroot00000000000000# Project configuration file for the "roman-numerals-py" package (see PEP 518) # Use flit as a build backend (https://flit.pypa.io/) # Build with (https://build.pypa.io/) [build-system] requires = ["flit_core>=3.7,<4"] build-backend = "flit_core.buildapi" # Project metadata # cf. https://packaging.python.org/en/latest/specifications/declaring-project-metadata/ [project] name = "roman-numerals-py" description = "Manipulate well-formed Roman numerals" readme = "README.rst" urls.Changelog = "https://github.com/AA-Turner/roman-numerals/blob/master/CHANGES.rst" urls.Code = "https://github.com/AA-Turner/roman-numerals/" urls.Download = "https://pypi.org/project/roman-numerals-py/" urls."Issue tracker" = "https://github.com/AA-Turner/roman-numerals/issues" license.file = "LICENCE.rst" requires-python = ">=3.9" # Classifiers list: https://pypi.org/classifiers/ classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: Zero-Clause BSD (0BSD)", "License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] dependencies = [] dynamic = ["version"] [[project.authors]] name = "Adam Turner" [project.optional-dependencies] test = [ "pytest>=8", ] lint = [ "mypy==1.15.0", "ruff==0.9.7", "pyright==1.1.394", ] [tool.flit.module] name = "roman_numerals" [tool.flit.sdist] include = [ "tests/", ] [tool.mypy] files = [ "roman_numerals", "tests", ] python_version = "3.9" strict = true show_column_numbers = true show_error_context = true strict_equality = false warn_return_any = false enable_error_code = [ "comparison-overlap", "ignore-without-code", "possibly-undefined", "redundant-expr", "redundant-self", "truthy-bool", "truthy-iterable", "type-arg", "unimported-reveal", "unused-awaitable", "unused-ignore", ] [tool.pyright] typeCheckingMode = "strict" include = [ "roman_numerals", "tests", ] [tool.pytest.ini_options] minversion = "6.0" addopts = [ "-ra", "--import-mode=prepend", "--pythonwarnings=error", "--strict-config", "--strict-markers", ] empty_parameter_set_mark = "xfail" filterwarnings = [ "all", ] log_cli_level = "INFO" testpaths = ["tests"] xfail_strict = true roman-numerals-3.1.0/python/roman_numerals/000077500000000000000000000000001475627672000210365ustar00rootroot00000000000000roman-numerals-3.1.0/python/roman_numerals/__init__.py000066400000000000000000000176731475627672000231650ustar00rootroot00000000000000"""Conversion between integers and roman numerals. Copyright (c) 2024, Adam Turner. This project is licenced under the terms of either the Zero-Clause BSD licence or the CC0 1.0 Universal licence. """ from __future__ import annotations import sys TYPE_CHECKING = False if TYPE_CHECKING: from typing import Final, TypeVar, final from typing_extensions import Self _T = TypeVar('_T') else: def final(f: _T) -> _T: return f __version__: Final = '3.1.0' version_info: Final = (3, 1, 0) __all__: Final = ( 'MAX', 'MIN', 'InvalidRomanNumeralError', 'OutOfRangeError', 'RomanNumeral', ) MIN: Final = 1 """The value of the smallest well-formed roman numeral.""" MAX: Final = 3_999 """The value of the largest well-formed roman numeral.""" @final class OutOfRangeError(TypeError): """Number out of range (must be between 1 and 3,999).""" @final class InvalidRomanNumeralError(ValueError): """Not a valid Roman numeral.""" def __init__(self, value: str, *args: object) -> None: msg = f'Invalid Roman numeral: {value}' super().__init__(msg, *args) @final class RomanNumeral: """A Roman numeral. Only values between 1 and 3,999 are valid. Stores the value internally as an ``int``. >>> answer = RomanNumeral(42) >>> print(answer.to_uppercase()) XLII """ __slots__ = ('_value',) _value: int def __init__(self, value: int, /) -> None: if not isinstance(value, int): # pyright: ignore[reportUnnecessaryIsInstance] value_qualname = type(value).__qualname__ msg = f'RomanNumeral: an integer is required, not {value_qualname!r}' raise TypeError(msg) if value < MIN or value > MAX: msg = f'Number out of range (must be between 1 and 3,999). Got {value}.' raise OutOfRangeError(msg) super().__setattr__('_value', value) def __int__(self) -> int: """Return the integer value of this numeral.""" return self._value def __str__(self) -> str: """Return the well-formed (uppercase) string for this numeral.""" return self.to_uppercase() def __repr__(self) -> str: """Return the string representation of this numeral.""" return f'{self.__class__.__name__}({self._value!r})' def __eq__(self, other: object) -> bool: """Return self == other.""" if isinstance(other, RomanNumeral): return self._value == other._value return NotImplemented def __lt__(self, other: object) -> bool: """Return self < other.""" if isinstance(other, RomanNumeral): return self._value < other._value return NotImplemented def __hash__(self) -> int: """Return the hashed value of this numeral.""" return hash(self._value) def __setattr__(self, key: str, value: object) -> None: """Implement setattr(self, name, value).""" if key == '_value': msg = f'Cannot set the {key!r} attribute.' raise AttributeError(msg) super().__setattr__(key, value) def to_uppercase(self) -> str: """Convert a ``RomanNumeral`` to an uppercase string. >>> answer = RomanNumeral(42) >>> assert answer.to_uppercase() == 'XLII' """ out: list[str] = [] n = self._value for value, name, _ in _ROMAN_NUMERAL_PREFIXES: while n >= value: n -= value out.append(name) return ''.join(out) def to_lowercase(self) -> str: """Convert a ``RomanNumeral`` to a lowercase string. >>> answer = RomanNumeral(42) >>> assert answer.to_lowercase() == 'xlii' """ out: list[str] = [] n = self._value for value, _, name in _ROMAN_NUMERAL_PREFIXES: while n >= value: n -= value out.append(name) return ''.join(out) @classmethod def from_string(cls, string: str, /) -> Self: # NoQA: C901, PLR0912, PLR0915 """Create a ``RomanNumeral`` from a well-formed string representation. Returns ``RomanNumeral`` or raises ``InvalidRomanNumeralError``. >>> answer = RomanNumeral.from_string('XLII') >>> assert int(answer) == 42 """ # Not an empty string. if not string or not isinstance(string, str): # pyright: ignore[reportUnnecessaryIsInstance] raise InvalidRomanNumeralError(string) # ASCII-only uppercase string. if string.isascii() and string.isupper(): chars = string.encode('ascii') elif string.isascii() and string.islower(): chars = string.upper().encode('ascii') else: # Either Non-ASCII or mixed-case ASCII. raise InvalidRomanNumeralError(string) # ASCII-only uppercase string only containing I, V, X, L, C, D, M. if not frozenset(b'IVXLCDM').issuperset(chars): raise InvalidRomanNumeralError(string) result: int = 0 idx: int = 0 # Thousands: between 0 and 4 "M" characters at the start for _ in range(4): if chars[idx : idx + 1] == b'M': result += 1000 idx += 1 else: break if len(chars) == idx: return cls(result) # Hundreds: 900 ("CM"), 400 ("CD"), 0-300 (0 to 3 "C" chars), # or 500-800 ("D", followed by 0 to 3 "C" chars) if chars[idx : idx + 2] == b'CM': result += 900 idx += 2 elif chars[idx : idx + 2] == b'CD': result += 400 idx += 2 else: if chars[idx : idx + 1] == b'D': result += 500 idx += 1 for _ in range(3): if chars[idx : idx + 1] == b'C': result += 100 idx += 1 else: break if len(chars) == idx: return cls(result) # Tens: 90 ("XC"), 40 ("XL"), 0-30 (0 to 3 "X" chars), # or 50-80 ("L", followed by 0 to 3 "X" chars) if chars[idx : idx + 2] == b'XC': result += 90 idx += 2 elif chars[idx : idx + 2] == b'XL': result += 40 idx += 2 else: if chars[idx : idx + 1] == b'L': result += 50 idx += 1 for _ in range(3): if chars[idx : idx + 1] == b'X': result += 10 idx += 1 else: break if len(chars) == idx: return cls(result) # Ones: 9 ("IX"), 4 ("IV"), 0-3 (0 to 3 "I" chars), # or 5-8 ("V", followed by 0 to 3 "I" chars) if chars[idx : idx + 2] == b'IX': result += 9 idx += 2 elif chars[idx : idx + 2] == b'IV': result += 4 idx += 2 else: if chars[idx : idx + 1] == b'V': result += 5 idx += 1 for _ in range(3): if chars[idx : idx + 1] == b'I': result += 1 idx += 1 else: break if len(chars) == idx: return cls(result) raise InvalidRomanNumeralError(string) _ROMAN_NUMERAL_PREFIXES: Final = [ (1000, sys.intern('M'), sys.intern('m')), (900, sys.intern('CM'), sys.intern('cm')), (500, sys.intern('D'), sys.intern('d')), (400, sys.intern('CD'), sys.intern('cd')), (100, sys.intern('C'), sys.intern('c')), (90, sys.intern('XC'), sys.intern('xc')), (50, sys.intern('L'), sys.intern('l')), (40, sys.intern('XL'), sys.intern('xl')), (10, sys.intern('X'), sys.intern('x')), (9, sys.intern('IX'), sys.intern('ix')), (5, sys.intern('V'), sys.intern('v')), (4, sys.intern('IV'), sys.intern('iv')), (1, sys.intern('I'), sys.intern('i')), ] """Numeral value, uppercase character, and lowercase character.""" roman-numerals-3.1.0/python/roman_numerals/py.typed000066400000000000000000000000001475627672000225230ustar00rootroot00000000000000roman-numerals-3.1.0/python/tests/000077500000000000000000000000001475627672000171565ustar00rootroot00000000000000roman-numerals-3.1.0/python/tests/__init__.py000066400000000000000000000000001475627672000212550ustar00rootroot00000000000000roman-numerals-3.1.0/python/tests/test_from_string.py000066400000000000000000000035501475627672000231230ustar00rootroot00000000000000from __future__ import annotations import pytest from roman_numerals import ( InvalidRomanNumeralError, RomanNumeral, ) from tests.utils import TEST_NUMERALS_LOWER, TEST_NUMERALS_UPPER @pytest.mark.parametrize(('n', 'roman_str'), enumerate(TEST_NUMERALS_UPPER, start=1)) def test_uppercase(n: int, roman_str: str) -> None: expected = RomanNumeral(n) parsed = RomanNumeral.from_string(roman_str) assert expected == parsed @pytest.mark.parametrize(('n', 'roman_str'), enumerate(TEST_NUMERALS_LOWER, start=1)) def test_lowercase(n: int, roman_str: str) -> None: expected = RomanNumeral(n) parsed = RomanNumeral.from_string(roman_str) assert expected == parsed def test_special() -> None: parsed = RomanNumeral.from_string('MDLXXXIII') assert RomanNumeral(1583) == parsed parsed = RomanNumeral.from_string('mdlxxxiii') assert RomanNumeral(1583) == parsed parsed = RomanNumeral.from_string('MCMLXXXIV') assert RomanNumeral(1984) == parsed parsed = RomanNumeral.from_string('mcmlxxxiv') assert RomanNumeral(1984) == parsed parsed = RomanNumeral.from_string('MM') assert RomanNumeral(2000) == parsed parsed = RomanNumeral.from_string('mm') assert RomanNumeral(2000) == parsed parsed = RomanNumeral.from_string('MMMCMXCIX') assert RomanNumeral(3_999) == parsed parsed = RomanNumeral.from_string('mmmcmxcix') assert RomanNumeral(3_999) == parsed def test_invalid() -> None: with pytest.raises(InvalidRomanNumeralError) as ctx: RomanNumeral.from_string('Not a Roman numeral!') msg = str(ctx.value) assert msg == 'Invalid Roman numeral: Not a Roman numeral!' def test_mixed_case() -> None: with pytest.raises(InvalidRomanNumeralError) as ctx: RomanNumeral.from_string('McMlXxXiV') msg = str(ctx.value) assert msg == 'Invalid Roman numeral: McMlXxXiV' roman-numerals-3.1.0/python/tests/test_new_roman_numeral.py000066400000000000000000000026221475627672000243010ustar00rootroot00000000000000from __future__ import annotations import pytest from roman_numerals import ( MAX, MIN, OutOfRangeError, RomanNumeral, ) def test_zero() -> None: with pytest.raises(OutOfRangeError) as ctx: RomanNumeral(0) msg = str(ctx.value) assert msg == 'Number out of range (must be between 1 and 3,999). Got 0.' def test_one() -> None: assert int(RomanNumeral(1)) == 1 def test_MIN() -> None: # NoQA: N802 assert int(RomanNumeral(MIN)) == MIN def test_forty_two() -> None: assert int(RomanNumeral(42)) == 42 # NoQA: PLR2004 def test_three_thousand_nine_hundred_and_ninety_nine() -> None: assert int(RomanNumeral(3_999)) == 3_999 # NoQA: PLR2004 def test_MAX() -> None: # NoQA: N802 assert int(RomanNumeral(MAX)) == MAX def test_four_thousand() -> None: with pytest.raises(OutOfRangeError) as ctx: RomanNumeral(4_000) msg = str(ctx.value) assert msg == 'Number out of range (must be between 1 and 3,999). Got 4000.' def test_minus_one() -> None: with pytest.raises(OutOfRangeError) as ctx: RomanNumeral(-1) msg = str(ctx.value) assert msg == 'Number out of range (must be between 1 and 3,999). Got -1.' def test_float() -> None: with pytest.raises(TypeError) as ctx: RomanNumeral(4.2) # type: ignore[arg-type] msg = str(ctx.value) assert msg == "RomanNumeral: an integer is required, not 'float'" roman-numerals-3.1.0/python/tests/test_round_trip.py000066400000000000000000000004311475627672000227520ustar00rootroot00000000000000from __future__ import annotations from roman_numerals import ( MAX, MIN, RomanNumeral, ) def test_round_trip() -> None: for n in range(MIN, MAX + 1): num = RomanNumeral(n) parsed = RomanNumeral.from_string(str(num)) assert num == parsed roman-numerals-3.1.0/python/tests/test_to_string.py000066400000000000000000000017771475627672000226130ustar00rootroot00000000000000from __future__ import annotations import pytest from roman_numerals import ( RomanNumeral, ) from tests.utils import TEST_NUMERALS_LOWER, TEST_NUMERALS_UPPER @pytest.mark.parametrize(('n', 'roman_str'), enumerate(TEST_NUMERALS_UPPER, start=1)) def test_str(n: int, roman_str: str) -> None: num = RomanNumeral(n) assert str(num) == roman_str assert f'{num}' == roman_str @pytest.mark.parametrize(('n', 'roman_str'), enumerate(TEST_NUMERALS_UPPER, start=1)) def test_uppercase(n: int, roman_str: str) -> None: num = RomanNumeral(n) assert num.to_uppercase() == roman_str @pytest.mark.parametrize(('n', 'roman_str'), enumerate(TEST_NUMERALS_LOWER, start=1)) def test_lowercase(n: int, roman_str: str) -> None: num = RomanNumeral(n) assert num.to_lowercase() == roman_str def test_minitrue() -> None: # IGNORANCE IS STRENGTH num = RomanNumeral(1984) assert f'{num}' == 'MCMLXXXIV' assert num.to_uppercase() == 'MCMLXXXIV' assert num.to_lowercase() == 'mcmlxxxiv' roman-numerals-3.1.0/python/tests/utils.py000066400000000000000000000011401475627672000206640ustar00rootroot00000000000000from __future__ import annotations TEST_NUMERALS_UPPER = [ 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII', 'XIII', 'XIV', 'XV', 'XVI', 'XVII', 'XVIII', 'XIX', 'XX', 'XXI', 'XXII', 'XXIII', 'XXIV', ] TEST_NUMERALS_LOWER = [ 'i', 'ii', 'iii', 'iv', 'v', 'vi', 'vii', 'viii', 'ix', 'x', 'xi', 'xii', 'xiii', 'xiv', 'xv', 'xvi', 'xvii', 'xviii', 'xix', 'xx', 'xxi', 'xxii', 'xxiii', 'xxiv', ] roman-numerals-3.1.0/rust/000077500000000000000000000000001475627672000154705ustar00rootroot00000000000000roman-numerals-3.1.0/rust/Cargo.toml000066400000000000000000000022721475627672000174230ustar00rootroot00000000000000[package] name = "roman-numerals-rs" version = "3.1.0" authors = ["Adam Turner"] edition = "2021" rust-version = "1.79.0" # MSRV description = "Manipulate well-formed Roman numerals" readme = "README.md" repository = "https://github.com/AA-Turner/roman-numerals/" license = "0BSD OR CC0-1.0" keywords = ["roman", "numerals", "roman-numerals"] categories = ["no-std", "text-processing"] [dependencies] # no dependencies [features] default = ["std"] std = [] [profile.dev] panic = "unwind" lto = "off" [profile.release] panic = "abort" lto = "fat" codegen-units = 1 [lints.clippy] cargo = { priority = -1, level = "deny" } complexity = { priority = -1, level = "deny" } correctness = { priority = -1, level = "deny" } nursery = { priority = -1, level = "deny" } pedantic = { priority = -1, level = "deny" } perf = { priority = -1, level = "deny" } style = { priority = -1, level = "deny" } suspicious = { priority = -1, level = "deny" } # We use reStructuredText for doc-comments doc_markdown = "allow" missing_errors_doc = "allow" # Ensure that integer literals use underscores to separate types separated_literal_suffix = "allow" unseparated_literal_suffix = "deny" roman-numerals-3.1.0/rust/README.md000066400000000000000000000043061475627672000167520ustar00rootroot00000000000000# roman-numerals A library for manipulating well-formed Roman numerals. Integers between 1 and 3,999 (inclusive) are supported. Numbers beyond this range will return an ``OutOfRangeError``. The classical system of roman numerals requires that the same character may not appear more than thrice consecutively, meaning that 'MMMCMXCIX' (3,999) is the largest well-formed Roman numeral. The smallest is 'I' (1), as there is no symbol for zero in Roman numerals. Both upper- and lower-case formatting of roman numerals are supported, and likewise for parsing strings, although the entire string must be of the same case. Numerals that do not adhere to the classical form are rejected with an ``InvalidRomanNumeralError``. ## Example usage ### Create a roman numeral ```rust use roman_numerals_rs::RomanNumeral; let num = RomanNumeral::new(16)?; assert_eq!(num.to_string(), "XVI"); let num: RomanNumeral = "XVI".parse()?; assert_eq!(num.as_u16(), 16); let num: RomanNumeral = 3_999.try_into().unwrap(); println!("{}", num); // MMMCMXCIX ``` ### Convert a roman numeral to a string ```rust use roman_numerals_rs::RomanNumeral; let num = RomanNumeral::new(16)?; assert_eq!(num.to_string(), "XVI"); assert_eq!(num.to_uppercase(), "XVI"); assert_eq!(num.to_lowercase(), "xvi"); assert_eq!(format!("{:X}", num), "XVI"); assert_eq!(format!("{:x}", num), "xvi"); ``` ### Extract the decimal value of a roman numeral ```rust use roman_numerals_rs::RomanNumeral; let num = RomanNumeral::new(42)?; assert_eq!(num.as_u16(), 42); ``` ### Invalid input ```rust use core::str::FromStr; use roman_numerals_rs::{RomanNumeral, InvalidRomanNumeralError, OutOfRangeError}; let res = RomanNumeral::from_str("Spam!"); assert!(matches!(res.unwrap_err(), InvalidRomanNumeralError)); let res = "CLL".parse::(); assert!(matches!(res.unwrap_err(), InvalidRomanNumeralError)); let res = RomanNumeral::new(0); assert!(matches!(res.unwrap_err(), OutOfRangeError)); let res = RomanNumeral::new(4_000); assert!(matches!(res.unwrap_err(), OutOfRangeError)); ``` ## Benchmarks Run the benchmarks with ``cargo bench``. ## Licence This project is licenced under the terms of either the Zero-Clause BSD licence or the CC0 1.0 Universal licence. roman-numerals-3.1.0/rust/benchmarks/000077500000000000000000000000001475627672000176055ustar00rootroot00000000000000roman-numerals-3.1.0/rust/benchmarks/Cargo.toml000066400000000000000000000003461475627672000215400ustar00rootroot00000000000000[package] name = "benchmarks" edition = "2021" publish = false [dependencies] roman-numerals-rs = { path = ".." } # no dependencies [dev-dependencies] divan = { version = "0.1.16" } [[bench]] name = "benchmark" harness = false roman-numerals-3.1.0/rust/benchmarks/benches/000077500000000000000000000000001475627672000212145ustar00rootroot00000000000000roman-numerals-3.1.0/rust/benchmarks/benches/benchmark.rs000066400000000000000000000016101475627672000235120ustar00rootroot00000000000000use core::str::FromStr; use roman_numerals_rs::InvalidRomanNumeralError; use roman_numerals_rs::RomanNumeral; fn main() { divan::main(); } #[divan::bench(args = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"])] fn from_str_one_to_ten_upper(input: &str) -> Result { RomanNumeral::from_str(input) } #[divan::bench(args = ["i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x"])] fn from_str_one_to_ten_lower(input: &str) -> Result { RomanNumeral::from_str(input) } #[divan::bench(args = ["MMMCMXCIX", "mmmcmxcix"])] fn from_str_max(input: &str) -> Result { RomanNumeral::from_str(input) } #[divan::bench(args = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10])] fn to_string_one_to_ten(input: u16) -> String { RomanNumeral::new(input).unwrap().to_string() } roman-numerals-3.1.0/rust/rustfmt.toml000066400000000000000000000000501475627672000200640ustar00rootroot00000000000000edition = "2021" newline_style = "unix" roman-numerals-3.1.0/rust/src/000077500000000000000000000000001475627672000162575ustar00rootroot00000000000000roman-numerals-3.1.0/rust/src/lib.rs000066400000000000000000000515701475627672000174030ustar00rootroot00000000000000//! # roman-numerals //! //! A library for manipulating well-formed Roman numerals. //! //! Integers between 1 and 3,999 (inclusive) are supported. //! Numbers beyond this range will return an ``OutOfRangeError``. //! //! The classical system of roman numerals requires that //! the same character may not appear more than thrice consecutively, //! meaning that 'MMMCMXCIX' (3,999) is the largest well-formed Roman numeral. //! The smallest is 'I' (1), as there is no symbol for zero in Roman numerals. //! //! Both upper- and lower-case formatting of roman numerals are supported, //! and likewise for parsing strings, //! although the entire string must be of the same case. //! Numerals that do not adhere to the classical form are rejected //! with an ``InvalidRomanNumeralError``. //! //! ## Example usage //! //! ### Create a roman numeral //! //! ```rust //! use roman_numerals_rs::RomanNumeral; //! //! let num = RomanNumeral::new(16).unwrap(); //! assert_eq!(num.to_string(), "XVI"); //! //! let num: RomanNumeral = "XVI".parse().unwrap(); //! assert_eq!(num.as_u16(), 16); //! //! let num: RomanNumeral = 3_999.try_into().unwrap(); //! println!("{}", num); // MMMCMXCIX //! ``` //! //! ### Convert a roman numeral to a string //! //! ```rust //! use roman_numerals_rs::RomanNumeral; //! //! let num = RomanNumeral::new(16).unwrap(); //! assert_eq!(num.to_string(), "XVI"); //! assert_eq!(num.to_uppercase(), "XVI"); //! assert_eq!(num.to_lowercase(), "xvi"); //! assert_eq!(format!("{:X}", num), "XVI"); //! assert_eq!(format!("{:x}", num), "xvi"); //! ``` //! //! ### Extract the decimal value of a roman numeral //! //! ```rust //! use roman_numerals_rs::RomanNumeral; //! //! let num = RomanNumeral::new(42).unwrap(); //! assert_eq!(num.as_u16(), 42); //! ``` //! //! ### Invalid input //! //! ```rust //! use core::str::FromStr; //! use roman_numerals_rs::{RomanNumeral, InvalidRomanNumeralError, OutOfRangeError}; //! //! let res = RomanNumeral::from_str("Spam!"); //! assert!(matches!(res.unwrap_err(), InvalidRomanNumeralError)); //! //! let res = "CLL".parse::(); //! assert!(matches!(res.unwrap_err(), InvalidRomanNumeralError)); //! //! let res = RomanNumeral::new(0); //! assert!(matches!(res.unwrap_err(), OutOfRangeError)); //! //! let res = RomanNumeral::new(4_000); //! assert!(matches!(res.unwrap_err(), OutOfRangeError)); //! ``` //! //! ## Licence //! //! This project is licenced under the terms of either //! the Zero-Clause BSD licence or the CC0 1.0 Universal licence. #![cfg_attr(not(feature = "std"), no_std)] #![warn(missing_docs)] #![warn(clippy::std_instead_of_core)] #![warn(clippy::print_stderr)] #![warn(clippy::print_stdout)] #[cfg(not(feature = "std"))] extern crate alloc; use core::fmt; use core::num::NonZero; use core::str::FromStr; /// The value of the smallest well-formed roman numeral. pub const MIN: u16 = 1; /// The value of the largest well-formed roman numeral. pub const MAX: u16 = 3_999; /// Returned as an error if a numeral is constructed with an invalid input. #[derive(Debug, Clone, Copy, Eq, PartialEq)] #[non_exhaustive] pub struct OutOfRangeError; impl fmt::Display for OutOfRangeError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "Number out of range (must be between 1 and 3,999).") } } /// Returned as an error if a parsed string is not a roman numeral. #[derive(Debug, Clone, Copy, Eq, PartialEq)] #[non_exhaustive] pub struct InvalidRomanNumeralError; impl fmt::Display for InvalidRomanNumeralError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "Invalid Roman numeral.") } } /// A Roman numeral. /// /// Only values between 1 and 3,999 are valid. /// Stores the value internally as a ``NonZero``. #[non_exhaustive] #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct RomanNumeral(NonZero); impl RomanNumeral { /// Creates a ``RomanNumeral`` for any value that implements. /// Requires ``value`` to be greater than 0 and less than 4,000. /// /// Example /// ------- /// /// .. code-block:: rust /// // let answer: RomanNumeral = RomanNumeral::new(42).unwrap(); // assert_eq!("XLII", answer.to_uppercase()); /// pub const fn new(value: u16) -> Result { if 0 != value && value < 4_000 { // SAFETY: 0 < value <= 3,999 Ok(Self(unsafe { NonZero::new_unchecked(value) })) } else { Err(OutOfRangeError) } } /// Return the value of this ``RomanNumeral`` as an ``u16``. /// /// Example /// ------- /// /// .. code-block:: rust /// /// let answer: RomanNumeral = RomanNumeral::new(42)?; /// assert_eq!(answer.as_u16(), 42_u16); /// #[must_use] pub const fn as_u16(self) -> u16 { self.0.get() } /// Converts a ``RomanNumeral`` to an uppercase string. /// /// Example /// ------- /// /// .. code-block:: rust /// /// let answer: RomanNumeral = RomanNumeral::new(42)?; /// assert_eq!("XLII", answer.to_uppercase()); /// #[must_use] #[cfg(feature = "std")] pub fn to_uppercase(self) -> String { let mut out = String::new(); let mut n = self.0.get(); for &(value, name, _) in ROMAN_NUMERAL_PREFIXES { while n >= value { n -= value; out.push_str(name); } } out } /// Converts a ``RomanNumeral`` to a lowercase string. /// /// Example /// ------- /// /// .. code-block:: rust /// /// let answer: RomanNumeral = RomanNumeral::new(42)?; /// assert_eq!("xlii", answer.to_lowercase()); /// #[must_use] #[cfg(feature = "std")] pub fn to_lowercase(self) -> String { let mut out = String::new(); let mut n = self.0.get(); for &(value, _, name) in ROMAN_NUMERAL_PREFIXES { while n >= value { n -= value; out.push_str(name); } } out } } #[cfg(feature = "std")] impl fmt::Display for RomanNumeral { /// Converts a ``RomanNumeral`` to an uppercase string. /// /// Example /// ------- /// /// .. code-block:: rust /// /// let answer: RomanNumeral = RomanNumeral::new(42)?; /// assert_eq!("XLII", answer.to_string()); /// fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str(&self.to_uppercase()) } } #[cfg(feature = "std")] impl fmt::UpperHex for RomanNumeral { /// Converts a ``RomanNumeral`` to an uppercase string. /// /// Example /// ------- /// /// .. code-block:: rust /// /// let answer: RomanNumeral = RomanNumeral::new(42)?; /// println!("{:X}", answer); // XLII /// fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str(&self.to_uppercase()) } } #[cfg(feature = "std")] impl fmt::LowerHex for RomanNumeral { /// Converts a ``RomanNumeral`` to a lowercase string. /// /// Example /// ------- /// /// .. code-block:: rust /// /// let answer: RomanNumeral = RomanNumeral::new(42)?; /// println!("{:x}", answer); // xlii /// fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str(&self.to_lowercase()) } } const PREFIXES_BYTES: [u8; 7] = [b'I', b'V', b'X', b'L', b'C', b'D', b'M']; impl FromStr for RomanNumeral { type Err = InvalidRomanNumeralError; /// Creates a ``RomanNumeral`` from a well-formed string /// representation of the roman numeral. /// /// Returns ``RomanNumeral`` or ``InvalidRomanNumeralError``. /// /// Example /// ------- /// /// .. code-block:: rust /// /// let answer: RomanNumeral = "XLII".parse()?; /// assert_eq!(42, answer.0); /// #[allow(clippy::too_many_lines)] fn from_str(s: &str) -> Result { if s.is_empty() { return Err(InvalidRomanNumeralError); } // ASCII-only uppercase string. let chars = if s.chars().all(|c: char| char::is_ascii_uppercase(&c)) { s.as_bytes() } else if s.chars().all(|c: char| char::is_ascii_lowercase(&c)) { &s.as_bytes().to_ascii_uppercase() } else { // Either Non-ASCII or mixed-case ASCII. return Err(InvalidRomanNumeralError); }; // ASCII-only uppercase string only containing I, V, X, L, C, D, M. if chars.iter().any(|c| !PREFIXES_BYTES.contains(c)) { return Err(InvalidRomanNumeralError); } let mut result: u16 = 0; let mut idx: usize = 0; // Thousands: between 0 and 4 "M" characters at the start for _ in 0..4 { let Some(x) = chars.get(idx..=idx) else { break; }; if x == b"M" { result += 1_000; idx += 1; } else { break; } } if chars.len() == idx { // SAFETY: idx is only incremented after adding to result, // and chars is not empty, hence ``idx > 1``. return Ok(Self(unsafe { NonZero::new_unchecked(result) })); } // Hundreds: 900 ("CM"), 400 ("CD"), 0-300 (0 to 3 "C" characters), // or 500-800 ("D", followed by 0 to 3 "C" characters) if chars[idx..].starts_with(b"CM") { result += 900; idx += 2; } else if chars[idx..].starts_with(b"CD") { result += 400; idx += 2; } else { if chars.get(idx..=idx).unwrap_or_default() == b"D" { result += 500; idx += 1; } for _ in 0..3 { let Some(x) = chars.get(idx..=idx) else { break; }; if x == b"C" { result += 100; idx += 1; } else { break; } } } if chars.len() == idx { // SAFETY: idx is only incremented after adding to result, // and chars is not empty, hence ``idx > 1``. return Ok(Self(unsafe { NonZero::new_unchecked(result) })); } // Tens: 90 ("XC"), 40 ("XL"), 0-30 (0 to 3 "X" characters), // or 50-80 ("L", followed by 0 to 3 "X" characters) if chars[idx..].starts_with(b"XC") { result += 90; idx += 2; } else if chars[idx..].starts_with(b"XL") { result += 40; idx += 2; } else { if chars.get(idx..=idx).unwrap_or_default() == b"L" { result += 50; idx += 1; } for _ in 0..3 { let Some(x) = chars.get(idx..=idx) else { break; }; if x == b"X" { result += 10; idx += 1; } else { break; } } } if chars.len() == idx { // SAFETY: idx is only incremented after adding to result, // and chars is not empty, hence ``idx > 1``. return Ok(Self(unsafe { NonZero::new_unchecked(result) })); } // Ones: 9 ("IX"), 4 ("IV"), 0-3 (0 to 3 "I" characters), // or 5-8 ("V", followed by 0 to 3 "I" characters) if chars[idx..].starts_with(b"IX") { result += 9; idx += 2; } else if chars[idx..].starts_with(b"IV") { result += 4; idx += 2; } else { if chars.get(idx..=idx).unwrap_or_default() == b"V" { result += 5; idx += 1; } for _ in 0..3 { let Some(x) = chars.get(idx..=idx) else { break; }; if x == b"I" { result += 1; idx += 1; } else { break; } } } if chars.len() == idx { // SAFETY: idx is only incremented after adding to result, // and chars is not empty, hence ``idx > 1``. Ok(Self(unsafe { NonZero::new_unchecked(result) })) } else { Err(InvalidRomanNumeralError) } } } /// Numeral value, uppercase character, and lowercase character. #[cfg(feature = "std")] const ROMAN_NUMERAL_PREFIXES: &[(u16, &str, &str)] = &[ (1000, "M", "m"), (900, "CM", "cm"), (500, "D", "d"), (400, "CD", "cd"), (100, "C", "c"), (90, "XC", "xc"), (50, "L", "l"), (40, "XL", "xl"), (10, "X", "x"), (9, "IX", "ix"), (5, "V", "v"), (4, "IV", "iv"), (1, "I", "i"), ]; impl TryFrom for RomanNumeral { type Error = OutOfRangeError; /// Creates a ``RomanNumeral`` from an ``u8``. /// /// Returns ``RomanNumeral`` or ``OutOfRangeError``. fn try_from(value: u8) -> Result { Self::new(u16::from(value)) } } impl TryFrom for RomanNumeral { type Error = OutOfRangeError; /// Creates a ``RomanNumeral`` from an ``u16``. /// /// Returns ``RomanNumeral`` or ``OutOfRangeError``. fn try_from(value: u16) -> Result { Self::new(value) } } impl TryFrom for RomanNumeral { type Error = OutOfRangeError; /// Creates a ``RomanNumeral`` from an ``u32``. /// /// Returns ``RomanNumeral`` or ``OutOfRangeError``. fn try_from(value: u32) -> Result { u16::try_from(value).map_or(Err(OutOfRangeError), Self::new) } } impl TryFrom for RomanNumeral { type Error = OutOfRangeError; /// Creates a ``RomanNumeral`` from an ``u64``. /// /// Returns ``RomanNumeral`` or ``OutOfRangeError``. fn try_from(value: u64) -> Result { u16::try_from(value).map_or(Err(OutOfRangeError), Self::new) } } impl TryFrom for RomanNumeral { type Error = OutOfRangeError; /// Creates a ``RomanNumeral`` from an ``u128``. /// /// Returns ``RomanNumeral`` or ``OutOfRangeError``. fn try_from(value: u128) -> Result { u16::try_from(value).map_or(Err(OutOfRangeError), Self::new) } } impl TryFrom for RomanNumeral { type Error = OutOfRangeError; /// Creates a ``RomanNumeral`` from an ``usize``. /// /// Returns ``RomanNumeral`` or ``OutOfRangeError``. fn try_from(value: usize) -> Result { u16::try_from(value).map_or(Err(OutOfRangeError), Self::new) } } impl TryFrom for RomanNumeral { type Error = OutOfRangeError; /// Creates a ``RomanNumeral`` from an ``i8``. /// /// Returns ``RomanNumeral`` or ``OutOfRangeError``. fn try_from(value: i8) -> Result { u16::try_from(value).map_or(Err(OutOfRangeError), Self::new) } } impl TryFrom for RomanNumeral { type Error = OutOfRangeError; /// Creates a ``RomanNumeral`` from an ``i16``. /// /// Returns ``RomanNumeral`` or ``OutOfRangeError``. fn try_from(value: i16) -> Result { u16::try_from(value).map_or(Err(OutOfRangeError), Self::new) } } impl TryFrom for RomanNumeral { type Error = OutOfRangeError; /// Creates a ``RomanNumeral`` from an ``i32``. /// /// Returns ``RomanNumeral`` or ``OutOfRangeError``. fn try_from(value: i32) -> Result { u16::try_from(value).map_or(Err(OutOfRangeError), Self::new) } } impl TryFrom for RomanNumeral { type Error = OutOfRangeError; /// Creates a ``RomanNumeral`` from an ``i64``. /// /// Returns ``RomanNumeral`` or ``OutOfRangeError``. fn try_from(value: i64) -> Result { u16::try_from(value).map_or(Err(OutOfRangeError), Self::new) } } impl TryFrom for RomanNumeral { type Error = OutOfRangeError; /// Creates a ``RomanNumeral`` from an ``i128``. /// /// Returns ``RomanNumeral`` or ``OutOfRangeError``. fn try_from(value: i128) -> Result { u16::try_from(value).map_or(Err(OutOfRangeError), Self::new) } } #[cfg(test)] mod test { use super::*; #[test] fn test_roman_numeral_new() { assert_eq!(RomanNumeral::new(0), Err(OutOfRangeError)); assert_eq!( RomanNumeral::new(1), Ok(RomanNumeral(NonZero::new(1_u16).unwrap())) ); assert_eq!( RomanNumeral::new(1_u8.into()), Ok(RomanNumeral(NonZero::new(1_u16).unwrap())) ); assert_eq!( RomanNumeral::new(1_u16), Ok(RomanNumeral(NonZero::new(1_u16).unwrap())) ); assert_eq!( RomanNumeral::new(42), Ok(RomanNumeral(NonZero::new(42_u16).unwrap())) ); assert_eq!( RomanNumeral::new(3_999), Ok(RomanNumeral(NonZero::new(3_999_u16).unwrap())) ); assert_eq!( RomanNumeral::new(MAX), Ok(RomanNumeral(NonZero::new(3_999_u16).unwrap())) ); assert!(matches!(RomanNumeral::new(4_000), Err(OutOfRangeError))); assert!(matches!(RomanNumeral::new(u16::MAX), Err(OutOfRangeError))); } #[test] #[cfg(feature = "std")] fn test_roman_numeral_to_string() { let test_numerals = [ "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX", "XXI", "XXII", "XXIII", "XXIV", ]; for (i, roman_str) in test_numerals.iter().enumerate() { let n = u16::try_from(i + 1).unwrap(); let r = RomanNumeral::new(n).unwrap().to_string(); assert_eq!(&r, roman_str); } assert_eq!(RomanNumeral::new(1984).unwrap().to_string(), "MCMLXXXIV"); } #[test] fn test_roman_numeral_parse_string() { let test_numerals = [ "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX", "XXI", "XXII", "XXIII", "XXIV", ]; for (i, roman_str) in test_numerals.iter().enumerate() { let n = u16::try_from(i + 1).unwrap(); let expected = RomanNumeral::new(n).unwrap(); let parsed: RomanNumeral = roman_str.parse().expect("parsing failed!"); assert_eq!(parsed, expected); } let parsed: RomanNumeral = "xvi".parse().unwrap(); assert_eq!(parsed, RomanNumeral::new(16).unwrap()); let parsed: RomanNumeral = "MDLXXXIII".parse().unwrap(); assert_eq!(parsed, RomanNumeral::new(1583).unwrap()); let parsed: RomanNumeral = "MCMLXXXIV".parse().unwrap(); assert_eq!(parsed, RomanNumeral::new(1984).unwrap()); let parsed: RomanNumeral = "MM".parse().unwrap(); assert_eq!(parsed, RomanNumeral::new(2000).unwrap()); let parsed: RomanNumeral = "MMMCMXCIX".parse().unwrap(); assert_eq!(parsed, RomanNumeral::new(3_999).unwrap()); } #[test] fn test_try_from_one() { assert_eq!( RomanNumeral::try_from(1_u8), Ok(RomanNumeral(NonZero::new(1_u16).unwrap())) ); assert_eq!( RomanNumeral::try_from(1_u16), Ok(RomanNumeral(NonZero::new(1_u16).unwrap())) ); assert_eq!( RomanNumeral::try_from(1_u32), Ok(RomanNumeral(NonZero::new(1_u16).unwrap())) ); assert_eq!( RomanNumeral::try_from(1_u64), Ok(RomanNumeral(NonZero::new(1_u16).unwrap())) ); assert_eq!( RomanNumeral::try_from(1_u128), Ok(RomanNumeral(NonZero::new(1_u16).unwrap())) ); assert_eq!( RomanNumeral::try_from(1_usize), Ok(RomanNumeral(NonZero::new(1_u16).unwrap())) ); assert_eq!( RomanNumeral::try_from(1_i8), Ok(RomanNumeral(NonZero::new(1_u16).unwrap())) ); assert_eq!( RomanNumeral::try_from(1_i16), Ok(RomanNumeral(NonZero::new(1_u16).unwrap())) ); assert_eq!( RomanNumeral::try_from(1_i32), Ok(RomanNumeral(NonZero::new(1_u16).unwrap())) ); assert_eq!( RomanNumeral::try_from(1_i64), Ok(RomanNumeral(NonZero::new(1_u16).unwrap())) ); assert_eq!( RomanNumeral::try_from(1_i128), Ok(RomanNumeral(NonZero::new(1_u16).unwrap())) ); } #[test] #[cfg(feature = "std")] fn test_roman_numeral_round_trip() { for i in 1..=3_999 { let r = RomanNumeral::new(i).unwrap().to_string(); let parsed: RomanNumeral = r.parse().unwrap(); let val = parsed.0.get(); assert_eq!(val, i); } } } roman-numerals-3.1.0/utils/000077500000000000000000000000001475627672000156335ustar00rootroot00000000000000roman-numerals-3.1.0/utils/convert_attestations.py000066400000000000000000000033531475627672000224730ustar00rootroot00000000000000"""Convert Sigstore attestations to PEP 740. See https://github.com/trailofbits/pypi-attestations. """ # resolution fails without betterproto and protobuf-specs # /// script # requires-python = ">=3.11" # dependencies = [ # "pypi-attestations==0.0.21", # "betterproto==2.0.0b6", # ] # /// from __future__ import annotations import json import sys from base64 import b64decode from pathlib import Path from pypi_attestations import Attestation, Distribution from sigstore.models import Bundle from sigstore.verify.policy import Identity ROOT = Path(__file__).resolve().parent.parent / 'python' DIST = ROOT / 'dist' bundle_path = Path(sys.argv[1]) signer_identity = sys.argv[2] for line in bundle_path.read_bytes().splitlines(): dsse_envelope_payload = json.loads(line)['dsseEnvelope']['payload'] subjects = json.loads(b64decode(dsse_envelope_payload))['subject'] for subject in subjects: filename = subject['name'] assert (DIST / filename).is_file() # Convert attestation from Sigstore to PEP 740 print(f'Converting attestation for {filename}') sigstore_bundle = Bundle.from_json(line) attestation = Attestation.from_bundle(sigstore_bundle) attestation_path = DIST / f'{filename}.publish.attestation' attestation_path.write_text(attestation.model_dump_json()) print(f'Attestation for {filename} written to {attestation_path}') print() # Validate attestation dist = Distribution.from_file(DIST / filename) attestation = Attestation.model_validate_json(attestation_path.read_bytes()) identity = Identity(identity=signer_identity) attestation.verify(identity=identity, dist=dist) print(f'Verified {attestation_path}')