From 19905bfb401a0f6dc2193afb95956616455e76e6 Mon Sep 17 00:00:00 2001 From: InSyncWithFoo Date: Sun, 4 Feb 2024 13:10:08 +0000 Subject: [PATCH] Initial commit --- .devcontainer/devcontainer.json | 23 ++ .editorconfig | 24 ++ .github/dependabot.yaml | 13 + .github/workflows/release.yaml | 36 ++ .github/workflows/test.yaml | 40 +++ .gitignore | 160 +++++++++ CHANGELOG.md | 6 + CODE_OF_CONDUCT.md | 145 ++++++++ CODE_STYLE.md | 213 ++++++++++++ CONTRIBUTING.md | 26 ++ LICENSE.txt | 21 ++ PROJECT_STRUCTURE.md | 44 +++ README.md | 91 +++++ pyproject.toml | 113 +++++++ src/a_n_plus_b/__init__.py | 480 +++++++++++++++++++++++++++ src/a_n_plus_b/_grammar.py | 42 +++ src/a_n_plus_b/py.typed | 0 tests/__init__.py | 56 ++++ tests/test_alternate_constructors.py | 265 +++++++++++++++ tests/test_indices.py | 193 +++++++++++ tests/test_other_methods.py | 120 +++++++ tox.ini | 30 ++ 22 files changed, 2141 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .editorconfig create mode 100644 .github/dependabot.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CODE_STYLE.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.txt create mode 100644 PROJECT_STRUCTURE.md create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/a_n_plus_b/__init__.py create mode 100644 src/a_n_plus_b/_grammar.py create mode 100644 src/a_n_plus_b/py.typed create mode 100644 tests/__init__.py create mode 100644 tests/test_alternate_constructors.py create mode 100644 tests/test_indices.py create mode 100644 tests/test_other_methods.py create mode 100644 tox.ini diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..c1bfc6e --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,23 @@ +{ + "name": "Python 3", + "image": "mcr.microsoft.com/devcontainers/universal:2", + "features": { + "ghcr.io/devcontainers/features/python:1": { + "version": "3.12" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "charliermarsh.ruff", + "eamodio.gitlens", + "EditorConfig.EditorConfig", + "formulahendry.code-runner", + "ms-python.mypy-type-checker", + "ms-python.python", + "tamasfe.even-better-toml", + "usernamehw.errorlens" + ] + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dfa580d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +[*] +charset = utf-8 +end_of_line = lf +ij_visual_guides = 80, 120 + +[*.json] +indent_size = 4 +indent_style = tab + +[*.md] +indent_size = 2 +indent_style = space + +[*.py] +indent_size = 4 +indent_style = tab + +[*.rst] +indent_size = 3 +indent_style = space + +[*.{yml,yaml}] +indent_size = 2 +indent_style = space diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..7d5084c --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,13 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 + +updates: + - + package-ecosystem: pip + directory: / + schedule: + interval: daily diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..52fbea6 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,36 @@ +name: Release + +on: + release: + types: + - published + workflow_dispatch: + +jobs: + release: + name: Release + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write + steps: + - + name: Checkout + uses: actions/checkout@v4 + - + name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: 3.x + - + name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install .[dev] + - + name: Build + run: python -m build + - + name: Publish + uses: pypa/gh-action-pypi-publish@v1.8.11 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..8373fe3 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,40 @@ +name: Test + +on: + pull_request: + paths: + - src/** + - tests/** + - .github/workflows/*.yaml + - tox.ini + push: + paths: + - src/** + - tests/** + - .github/workflows/*.yaml + - tox.ini + +jobs: + test: + strategy: + matrix: + platform: [ ubuntu-latest, macos-latest, windows-latest ] + python: [ '3.10', '3.11', '3.12' ] + runs-on: ${{ matrix.platform }} + steps: + - + name: Checkout + uses: actions/checkout@v4 + - + name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - + name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - + name: Run tests with tox + run: tox diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2dc53ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f2544ea --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + + +## v0.1.0 - 2024-02-04 + +* Initial release diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..7c7bcab --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,145 @@ +# Contributor Covenant Code of Conduct + + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement by sending an +email to [InSyncWithFoo][email]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + + + [email]: mailto:insyncwithfoo@gmail.com + [homepage]: https://www.contributor-covenant.org + [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html + [Mozilla CoC]: https://github.com/mozilla/diversity + [FAQ]: https://www.contributor-covenant.org/faq + [translations]: https://www.contributor-covenant.org/translations diff --git a/CODE_STYLE.md b/CODE_STYLE.md new file mode 100644 index 0000000..fab559f --- /dev/null +++ b/CODE_STYLE.md @@ -0,0 +1,213 @@ +# Code style + +I believe code must be beautiful. +By "beautiful", I mean "beautiful to me". + +In addition to the rules listed below, +there are a few things to remember: + +* Format the files manually if necessary. +* Some rules are not documented here. +* Follow existing code when in doubt. + +These rules are not concrete. For instance, +Python code examples in this file have their indentation +set to 2 spaces as a compromise between [Python](#for-python) +and [Markdown](#for-markdown)'s rules. + +View this file in source mode for best experience. + + +## For Python + +I don't like [PEP 8][1] (for the most part). +This means _[black][2]_ and other PEP-8-enforcing tools +should not be used. + + +### Formatting styles + +* Indentation: + * Use tabs. + * Preferably displayed as 4 spaces. + +* Nesting: + * 3 levels is considered high. + * Use 4 frugally and try your best to avoid 5. + + See also: *[Why You Shouldn't Nest Your Code][3]* + +* Line length: + * A line must not be longer than 80 characters, + with tabs count as 4. + * Wrapping long lines is recommended, + even if the length does not exceed 80. + +* Quotes: + * Prefer single quotes wherever possible, + even when writing docstrings. + +* Operators: + * Use spaces around keyword arguments and operators. + +* Empty lines: + * Group multiple related lines to make mono-blocks. + Separate such blocks with blank lines. + For example: + + ```python + if foo > bar: + bar = Qux() + bar.do_this() + + something_else.do_that() + + else: + subprocess.run('rm -rf /', shell = True) + ``` + + +### Semantic styles + +* Naming: + * Use: + * `snake_case`: functions, variables and modules + * `PascalCase`: classes + * `ALL_CAPS`: enum members and constants + + * Do not name things `utils`, `helper`, `base` or `abstract`. + Give more meaningful names when possible; [it is always possible][4]. + + See also: *[Naming Things in Code][5]* + +* Variable scope: + * Limit them wherever applicable. + +* Comments: + * A comment must be at least two spaces away + from the nearest non-empty character. + + When the same line has no other contents, + the comment should be a comment for and + have the same indentation as the statement(s) + right below it. + + For example: + + ```python + print('Lorem ipsum') # This is a comment + + # This is also a comment + foo = Bar() + foo.qux() + + def function(): + # This too + lorem = ipsum.dolor().sit(amet) + ``` + + * Only use comments to explain something + that cannot otherwise be adequately made + clear by simply refactoring the code. + + See also: *[Don't Write Comments][6]* + + * `# noqa`, `# type: ignore` and the like + are exempt from the second rule. + Use `# noqa` for warnings issued by PyCharm + (as well as other IDEs), and `# type: ignore` + for those issued by type checkers. + + +### For test files + +* The rules for test files are less strict + than that of package files. The changes + include, but not limited to: + + * The 80 line width rule might be ignored. + * Global variables are of no concern. + * "Helper" code may be nested a bit deeper. + + +## For Markdown + +See the source code of this page for an example. + +* Indentation: + * Use 2 spaces. + +* Code blocks: + * Use code fences. + +* Wrapping: + * Try to split paragraph to lines of even length. + Make lines short, but not too short. + If disparity cannot be avoided, + then so be it. + + At the same time, try to preserve spaces + between phrases, link text and the like. + +* Links: + * All links should be grouped at the end + of the page as a link reference definitions + block, with numbers as link labels. + + The numbers must be ordered strictly + in ascending order, both in labels and + in definitions. Indent the whole group + to level 1 (2 spaces). + +* Headers: + * Use at most one level-1 header, + which must be at the very top if it exists. + There must be no preceding blank lines. + +* Empty lines: + * Use 2 blank lines before headers. + * Use 1 blank lines after headers. + * Use 1 blank line after blocks. + * Use 2 blank lines before the links block. + * Use 1 empty lines between list items + if they have sub-blocks. + + +## For JSON + +* Indentation: + * Use tabs. + * Preferably displayed as 4 spaces. + +* Casing: + * Use `snake_case`. + + +## For TOML + +* Indentation: + * Use 2 spaces. + +* Quotes: + * Use double quotes. + + +## For YAML + +* Indentation: + * Use 2 spaces. + + +## For all files + +* Use UTF-8 encoding. +* Use Unix-style line endings. +* End a file with a blank line. + + + [1]: https://peps.python.org/pep-0008/ + [2]: https://github.com/psf/black + [3]: https://www.youtube.com/watch?v=CFRhGnuXG-4 + [4]: https://letmegooglethat.com/?q=%E2%80%9CBe+kind+whenever+possible.+It+is+always+possible.%E2%80%9D + [5]: https://www.youtube.com/watch?v=-J3wNP6u5YU + [6]: https://www.youtube.com/watch?v=Bf7vDBBOBUA diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..edfbecf --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,26 @@ +# Contributing + +All contributions are welcome, including typo fixes. +There might be a few `TODO` comments marking future intentions; +feel free to work on those as well. + +See _[Code style][1]_ and _[Project structure][2]_ +for more information on the project itself. + + +## Run tests + +Whenever you make a fix, +remember to run the tests with `pytest`. +If everything passes, you are good to go. + +```shell +$ pytest +``` + +Otherwise, modify the tests as you go, +and make sure that those pass as well. + + + [1]: ./CODE_STYLE.md + [2]: ./PROJECT_STRUCTURE.md diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..5404648 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-now InSyncWithFoo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..e069740 --- /dev/null +++ b/PROJECT_STRUCTURE.md @@ -0,0 +1,44 @@ +# Project structure + +There are two modules (one supporting, one `__init__`) +and three test files. + + +## Source code + +The [`__init__.py`][2] file contains the main features of the package. +The private [`_grammar.py`][3] has a convenient pattern used for parsing. + +Public classes, methods and functions must have docstrings. +Parameters of a method and errors it might raise, if any, +must be documented in its own docstring. + + +## Test files + +Test cases for `ANPlusB`'s methods are divided into three files: + +* [`test_alternate_constructors.py`][4] tests the + `parse` and `from_complex` methods. +* [`test_indices.py`][5] tests the `indices` method. +* The rest are in [`test_other_methods.py`][6]. + +Most inputs are automatically generated using Hypothesis. +On the other hand, there are also concrete test cases. + + +## Type hinting + +The code must pass mypy, Pyright and PyCharm type checking. +As stated in _[Code style]_, use comments as necessary. +For test files, test cases and "helper" functions may or +may not be type hinted. Regardless, good type hints +are always strongly and explicitly recommended. + + + [1]: ./CODE_STYLE.md#for-python + [2]: ./src/a_n_plus_b/__init__.py + [3]: ./src/a_n_plus_b/_grammar.py + [4]: ./tests/test_alternate_constructors.py + [5]: ./tests/test_indices.py + [6]: ./tests/test_other_methods.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..a56dc1b --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# ANPlusB + +This tiny package provides a handy parser +for parsing the CSS `` microsyntax. + + +## Installation + +This package is available [on PyPI][1]: + +```shell +$ pip install a-n-plus-b +``` + + +## Usage + +This package only ever parses [the `` microsyntax][2]. +It does not support [the `of ` syntax][3]. + +### Examples + +```pycon +>>> from a_n_plus_b import ANPlusB +>>> ANPlusB(2, 1) +ANPlusB(2n+1) +>>> str(_) +'2n+1' +>>> ANPlusB(4) +ANPlusB(4) +>>> ANPlusB(4, 0) +ANPlusB(4n) +>>> {ANPlusB(1, 0), ANPlusB(True, False)} +{ANPlusB(n)} +``` + +```pycon +>>> from itertools import islice +>>> ANPlusB(3, 2) +ANPlusB(3n+2) +>>> values = _.values() +>>> values +_InfiniteRange(start = 2, step = 3) +>>> list(islice(values, 10)) +[2, 5, 8, 11, 14, 17, 20, 23, 26, 29] +>>> 6405429723686292014 in values +True +``` + +```pycon +>>> instance = ANPlusB(4, -7) +>>> list(instance.indices(40)) +[1, 5, 9, 13, 17, 21, 25, 29, 33, 37] +>>> list(instance.indices(40, from_last = True)) +[40, 36, 32, 28, 24, 20, 16, 12, 8, 4] +>>> list(instance.indices(40, order = 'descending')) +[37, 33, 29, 25, 21, 17, 13, 9, 5, 1] +>>> list(instance.indices(40, from_last = True, order = 'ascending')) +[4, 8, 12, 16, 20, 24, 28, 32, 36, 40] +``` + +```pycon +>>> ANPlusB.parse('odd') +ANPlusB(2n+1) +>>> ANPlusB.parse('even') +ANPlusB(2n) +>>> ANPlusB.parse('4') +ANPlusB(4) +>>> ANPlusB.parse('-1n') +ANPlusB(-n) +>>> ANPlusB.parse('+0n-8') +ANPlusB(-8) +>>> ANPlusB.parse('0n+0124') +ANPlusB(124) +``` + +```pycon +>>> ANPlusB.from_complex(5j - 2) +ANPlusB(5n-2) +``` + + +## Contributing + +Please see _[Contributing][4]_ for more information. + + + [1]: https://pypi.org/project/a-n-plus-b + [2]: https://drafts.csswg.org/css-syntax-3/#anb-microsyntax + [3]: https://developer.mozilla.org/en-US/docs/Web/CSS/:nth-child#the_of_selector_syntax + [4]: ./CONTRIBUTING.md diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1e9ae13 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,113 @@ +[project] +name = "a-n-plus-b" +version = "0.1.0" +description = "CSS microsyntax parser" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +keywords = ["CSS", "parser", "an+b"] +authors = [ + { name = "InSyncWithFoo", email = "insyncwithfoo@gmail.com" } +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Topic :: Software Development :: Build Tools", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: Implementation :: CPython", + "Typing :: Typed" +] + +dependencies = [ + "regex~=2023.12.25; python_version<='3.10'" +] + +[project.optional-dependencies] +dev = [ + "hatch~=1.9.3", + "hypothesis~=6.97.0", + "mypy~=1.8.0", + "pyright~=1.1.347", + "pytest~=8.0.0", + "pytest-cov~=4.1.0", + "ruff~=0.2.0", + "tox~=4.12.1", + "tzdata~=2023.4" +] + +[project.urls] +"Homepage" = "https://github.com/InSyncWithFoo/a-n-plus-b" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.sdist] +include = ["src"] + +[tool.pytest.ini_options] +addopts = "--strict-markers --cov=a_n_plus_b --cov-report=html" +testpaths = ["tests"] + +[tool.coverage.report] +exclude_lines = [ + "^([^\\S\n]+)@(?:overload|abstractmethod)", + "if TYPE_CHECKING:", + "if sys\\.version", + "def __repr__" +] + +[tool.mypy] +files = "src/**/*.py" +strict = true + +[tool.pyright] +include = ["src"] +strict = ["src"] +pythonPlatform = "All" +typeCheckingMode = "strict" + +[tool.ruff] +include = ["src/**"] +exclude = ["tests/**"] +line-length = 80 +target-version = "py310" + +[tool.ruff.lint] +select = ["ALL"] +ignore = [ + "ANN101", # missing-type-self + "ANN102", # missing-type-cls + + "D200", # fits-on-one-line + "D202", # no-blank-line-after-function + "D203", # one-blank-line-before-class + "D205", # blank-line-after-summary + "D206", # indent-with-spaces + "D212", # multi-line-summary-first-line + "D300", # triple-single-quotes + "D401", # non-imperative-mood + + "ERA001", # commented-out-code + + "I001", # unsorted-imports + + "N818", # error-suffix-on-exception-name + + "PIE790", # unnecessary-placeholder + + "Q000", # bad-quotes-inline-string + "Q001", # bad-quotes-multiline-string + "Q002", # bad-quotes-docstring + + "W191", # tab-indentation + "W291", # trailing-whitespace + "W293", # blank-line-with-whitespace + + "SLF001" # private-member-access +] diff --git a/src/a_n_plus_b/__init__.py b/src/a_n_plus_b/__init__.py new file mode 100644 index 0000000..ca26b11 --- /dev/null +++ b/src/a_n_plus_b/__init__.py @@ -0,0 +1,480 @@ +''' +The main feature of the package: :class:`ANPlusB`. +''' + +import math +import sys +from collections.abc import Iterator +from itertools import count +from typing import Any, Literal, overload + +from a_n_plus_b._grammar import a_n_plus_b, integer, Regex, whitespace + + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + +__all__ = [ + 'IncorrectUseOfConstructor', + 'InvalidOrder', + 'InvalidNumberOfChildren', + 'ParseError', + 'EmptyInput', + 'InputIsNotParsable', + 'ComplexWithNonIntegerPart', + 'ANPlusB' +] + +_surrounding_whitespace = Regex(fr'\A{whitespace}+|{whitespace}+\Z') + + +def _normalize(text: str, /) -> str: + ''' + Strip surrounding whitespace and + convert ``text`` to lowercase. + ''' + + return _surrounding_whitespace.sub('', text).lower() + + +def _remove_whitespace(text: str, /) -> str: + ''' + Remove all whitespace. + ''' + + return whitespace.sub('', text) + + +def _is_integer(value: float, /) -> bool: + ''' + Check if ``value`` is an integer. + ''' + + return isinstance(value, int) or value.is_integer() + + +class IncorrectUseOfConstructor(TypeError): + ''' + Raised when the main constructor + is passed a single :class:`str` argument. + ''' + + def __init__(self, cls: type['ANPlusB'], /) -> None: + ''' + :param cls: The class whose main constructor was called. + ''' + + super().__init__(f'Use {cls.__name__}.parse to parse a string') + + +class InvalidOrder(ValueError): + ''' + Raised when an unrecognized order is + passed to :meth:`ANPlusB.indices`. + ''' + + def __init__(self, value: object, /) -> None: + ''' + :param value: The value passed to :meth:`ANPlusB.indices`. + ''' + + super().__init__( + f'Expected one of: "ascending", "descending", "default", ' + f'got: {value!r}', + ) + + +class InvalidNumberOfChildren(ValueError): + ''' + Raised when an invalid number of children is + passed to :meth:`ANPlusB.indices`. + ''' + + def __init__(self, value: object, /) -> None: + ''' + :param value: The value passed to :meth:`ANPlusB.indices`. + ''' + + super().__init__( + f'Expected a non-negative number, ' + f'got: {value!r}', + ) + + +class ParseError(ValueError): + ''' + Raised when an invalid input is passed to :meth:`ANPlusB.parse`. + ''' + + pass + + +class EmptyInput(ParseError): + ''' + Raised when an empty input is passed to :meth:`ANPlusB.parse`. + ''' + + def __init__(self) -> None: # noqa: D107 + super().__init__('Input is empty or only contains whitespace') + + +class InputIsNotParsable(ParseError): + ''' + Raised when an input that is not parsable + is passed to :meth:`ANPlusB.parse`. + ''' + + def __init__(self, text: str, /) -> None: + ''' + :param text: The unparsable input. + ''' + + super().__init__(repr(text)) + + +class ComplexWithNonIntegerPart(ValueError): + ''' + Raised when a complex with non-integer parts + is passed to :meth:`ANPlusB.from_complex`. + ''' + + def __init__(self, value: complex, /) -> None: + ''' + :param value: The value passed to :meth:`ANPlusB.from_complex`. + ''' + + super().__init__( + f'Expected a complex with integral imaginary and real parts, ' + f'got: {value!r}' + ) + + +class _InfiniteRange: + ''' + Representation of all possible values + an :class:`ANPlusB` instance may yield. + + Basically a thin wrapper around :class:`count`, + providing a :class:`Sequence`-like interface. + There is no ``__len__`` method, since ``len()`` + expects an :class:`int` which ``math.inf`` is not. + ''' + + __slots__ = ('_start', '_step') + + _start: int + _step: int + + def __init__(self, start: int, step: int, /) -> None: + r''' + :param start: \ + The number to start counting from, also known as offset. + :param step: \ + The distance between values. + ''' + + self._start = start + self._step = step + + def __repr__(self) -> str: + start, step = self._start, self._step + + return f'{self.__class__.__name__}({start = }, {step = })' + + def __iter__(self) -> Iterator[int]: + yield from count(self._start, self._step) + + def __getitem__(self, item: int) -> int: + ''' + Get the value at the given index. + + :param item: The index. + :raise IndexError: If ``item`` is negative. + ''' + + # TODO: Support slices + + if item < 0: + raise IndexError(item) + + return self._start + item * self._step + + def __contains__(self, item: object) -> bool: + ''' + Check whether ``item`` is a possible value. + ''' + + if not isinstance(item, int): + return False + + if self._step == 0: + return item == self._start + + n = (item - self._start) / self._step + + return n >= 0 and n.is_integer() + + +class ANPlusB: + ''' + Implementation of `Section 6. The An+B microsyntax + `_. + ''' + + __slots__ = ('_step', '_offset') + + _step: int + _offset: int + + @overload + def __new__(cls, offset: int, /) -> 'Self': + ... + + @overload + def __new__(cls, step: int, offset: int, /) -> 'Self': + ... + + def __new__(cls, step: int, offset: int | None = None, /) -> 'Self': + ''' + If only one argument is passed, that argument would be + interpreted as ``offset`` and ``step`` would be ``0``. + That is, ``ANPlusB(3)`` is the same as ``ANPlusB(0, 3)``. + + :param step: The step, also known as ``a``. + :param offset: The offset, also known as ``b``. + ''' + + if isinstance(step, str): + raise IncorrectUseOfConstructor(cls) + + instance = super().__new__(cls) + + if offset is None: + step, offset = 0, step + + instance._step = step + instance._offset = offset + + return instance + + def __str__(self) -> str: + ''' + Implementation of `Section 9.1. Serializing + `_. + ''' + + a, b = self._step, self._offset + + if a == 0: + return str(b) + + result = '' + + if a == 1: + result += 'n' + elif a == -1: + result += '-n' + else: + result += f'{a}n' + + if b > 0: + result += f'+{b}' + elif b < 0: + result += str(b) + + return result + + def __repr__(self) -> str: # noqa: D105 + return f'{self.__class__.__name__}({self})' + + def __eq__(self, other: object) -> bool: + ''' + Two instances of :class:`ANPlusB` are equal + if their steps and offsets are equal. + ''' + + if not isinstance(other, self.__class__): + return NotImplemented + + return (self._step, self._offset) == (other._step, other._offset) + + def __hash__(self) -> int: # noqa: D105 + return hash((self._step, self._offset)) + + @property + def step(self) -> int: + ''' + The step, also known as ``a``. + ''' + + return self._step + + @property + def offset(self) -> int: + ''' + The offset, also known as ``b``. + ''' + + return self._offset + + def _indices( + self, population: int, /, *, + from_last: bool = False, + order: Literal['ascending', 'descending', 'default'] = 'default' + ) -> Iterator[int]: + a, b = self._step, self._offset + + if population == 0: + return + + if a <= 0 and b <= 0: + return + + if a == 0: + index = population - b + 1 if from_last else b + yield from [index] if 1 <= index <= population else [] + return + + if a < 0: + # 0 -> an -> -inf | 0 -> n -> inf + # + # n min <=> an + b max <=> n = 0 <=> an + b = b + + start = b + stop = 0 + + else: + # (a > 0) + # 1 <= an + b <= p + # 1 - b <= an <= p - b + # + # n min <=> an = 1 - b <=> n = (1 - b) / a + + min_n = max(0, math.ceil((1 - b) / a)) + + start = a * min_n + b + stop = population + 1 + + indices: Any = range(start, stop, a) + default_order = 'descending' if a < 0 else 'ascending' + + if order == 'default': + reverse_order = False + elif order == default_order: + reverse_order = from_last + else: + reverse_order = not from_last + + if reverse_order: + indices = reversed(indices) + + for index in indices: + yield population - index + 1 if from_last else index + + def indices( + self, population: int, *, + from_last: bool = False, + order: Literal['ascending', 'descending', 'default'] = 'default' + ) -> Iterator[int]: + r''' + Yield the 1-based indices of the children a selector + with only a ``:nth-child()``/``:nth-last-child()`` + pseudo-class whose argument is the serialization of + this ``ANPlusB`` object would match if it were to be + applied to an element with ``population`` children. + + :param population: The number of children. + :param from_last: Whether to start from the last index. + :param order: \ + The order in which to yield the indices. + ``ascending`` means the first index yielded will be the smallest. + ``descending`` means the first index yielded will be the greatest. + ``default`` means the first index yielded will + correspond to the minimum value of ``n``. + ''' + + if order not in ('ascending', 'descending', 'default'): + raise InvalidOrder(order) + + if population < 0: + raise InvalidNumberOfChildren(population) + + return self._indices( + population, + from_last = from_last, + order = order + ) + + def values(self) -> _InfiniteRange: + ''' + Return an iterable that yield possible values + as ``n`` goes from 0 to infinity. + ''' + + return _InfiniteRange(self._offset, self._step) + + @classmethod + def parse(cls, text: str, /) -> Self: + ''' + Parse the given text and returns an ``ANPlusB`` instance. + + Surrounding whitespace (spaces, tabs, carriage returns, + newlines, form feeds) are tolerated. + However, there must be no whitespace between + the digits of ``a`` (or ``n``) and its sign, if any. + + :param text: The text to parse. + :raise EmptyInput: If the input is empty or only contains whitespace. + :raise InputIsNotParsable: If the text is not parsable. + ''' + + text = _normalize(text) + + if not text: + raise EmptyInput + + if text == 'even': + return cls(2, 0) + + if text == 'odd': + return cls(2, 1) + + if integer.fullmatch(text): + return cls(int(text)) + + match = a_n_plus_b.fullmatch(text) + + if not match: + raise InputIsNotParsable(text) + + a, b = match['a'], _remove_whitespace(match['b'] or '') + + if not a: + step = 0 + elif a == '+': + step = 1 + elif a == '-': + step = -1 + else: + step = int(a) + + offset = int(b) if b else 0 + + return cls(step, offset) + + @classmethod + def from_complex(cls, value: complex, /) -> Self: + ''' + Convert a complex number to an ``ANPlusB`` instance. + + For readability, ``value`` should look like ``2j + 3``. + ''' + + imaginary, real = value.imag, value.real + + if not _is_integer(imaginary) or not _is_integer(real): + raise ComplexWithNonIntegerPart(value) + + return cls(int(imaginary), int(real)) diff --git a/src/a_n_plus_b/_grammar.py b/src/a_n_plus_b/_grammar.py new file mode 100644 index 0000000..f35590c --- /dev/null +++ b/src/a_n_plus_b/_grammar.py @@ -0,0 +1,42 @@ +import re +from typing import TypeVar + + +T = TypeVar('T') + + +class Regex: + ''' + Proxy class for ergonomic syntax. + ''' + + __slots__ = ('_raw_pattern', '_compiled') + + _raw_pattern: str + _compiled: re.Pattern[str] + + def __init__(self, pattern: str, /) -> None: + self._raw_pattern = pattern + self._compiled = re.compile(pattern) + + def __str__(self) -> str: + return self._raw_pattern + + def fullmatch(self, text: str, /) -> re.Match[str] | None: + return self._compiled.fullmatch(text) + + def sub(self, replacement: str, text: str, /) -> str: + return self._compiled.sub(replacement, text) + + +whitespace = Regex(r'[\t\n\f\r\x20]') +integer = Regex(r'[+-]?\d+') + +_blank = _ = Regex(fr'{whitespace}*') + +a_n_plus_b = Regex(fr'''(?x) +(?: + (?P [+-]? \d*) [Nn] + (?P{_} [+-] {_} \d+)? +) +''') diff --git a/src/a_n_plus_b/py.typed b/src/a_n_plus_b/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..a03f1f0 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,56 @@ +from collections.abc import Callable, Iterable +from typing import Any, ParamSpec, TypeVar + +from _pytest.mark import ParameterSet +from hypothesis import example +from hypothesis.strategies import integers, SearchStrategy, tuples + +from a_n_plus_b import ANPlusB + + +_T = TypeVar('_T') +_P = ParamSpec('_P') + +_Decorator = Callable[[Callable[_P, _T]], Callable[_P, _T]] + +newlines = ['\r\n', '\r', '\n', '\f'] +whitespace = ['\t', '\x20'] + newlines +blank = [''] + whitespace + + +def _make_a_n_plus_b(step_and_offset: tuple[int, int]) -> ANPlusB: + step, offset = step_and_offset + + return ANPlusB(step, offset) + + +def join(whatever: Iterable[Any]) -> str: + return ''.join(map(str, whatever)) + + +# Originally from https://stackoverflow.com/a/70312417 +def examples( + parameter_sets: Iterable[ParameterSet | tuple[Any, ...] | Any] +) -> _Decorator[_P, _T]: + parameter_sets = list(parameter_sets) + + def inner(test_case: Callable[_P, _T]) -> Callable[_P, _T]: + for parameter_set in reversed(parameter_sets): + if isinstance(parameter_set, ParameterSet): + parameter_set = parameter_set.values + + if not isinstance(parameter_set, tuple): + parameter_set = tuple([parameter_set]) + + test_case = example(*parameter_set)(test_case) + + return test_case + + return inner + + +def a_n_plus_b_instances( + step: SearchStrategy[int] = integers(), + offset: SearchStrategy[int] = integers() +) -> SearchStrategy[ANPlusB]: + return tuples(step, offset).map(_make_a_n_plus_b) diff --git a/tests/test_alternate_constructors.py b/tests/test_alternate_constructors.py new file mode 100644 index 0000000..d590534 --- /dev/null +++ b/tests/test_alternate_constructors.py @@ -0,0 +1,265 @@ +from collections.abc import Callable +from typing import Any, TypeVar + +import pytest +from hypothesis import given +from hypothesis.strategies import ( + composite, DrawFn, floats, integers, just, lists, + one_of, sampled_from, SearchStrategy, tuples +) + +from a_n_plus_b import ( + ANPlusB, ComplexWithNonIntegerPart, + EmptyInput, InputIsNotParsable +) +from . import examples, join, whitespace + + +_E = TypeVar('_E') +_T = TypeVar('_T') + + +def _make_complex(example: tuple[int | float, int | float]) -> complex: + return complex(*example) + + +def _text_starts_with_sign(example: tuple[str, Any]) -> bool: + text, _ = example + + return text[0] in ('+', '-') + + +def _casing_scrambled(text: str) -> SearchStrategy[str]: + lowercase = text.lower() + uppercase = text.upper() + + substrategies = (sampled_from(chars) for chars in zip(lowercase, uppercase)) + + return tuples(*substrategies).map(''.join) + + +def _variation_fragments(text: str) -> SearchStrategy[tuple[str, str, str]]: + return tuples( + whitespace_sequences_or_empty(), + _casing_scrambled(text), + whitespace_sequences_or_empty() + ) + + +def _tupled_with(value: _T) -> Callable[[_E], tuple[_E, _T]]: + def tupled_with_given_value(example: _E) -> tuple[_E, _T]: + return example, value + + return tupled_with_given_value + + +def _whitespace_sequences() -> SearchStrategy[str]: + return lists( + sampled_from(whitespace), + min_size = 1, max_size = 10 + ).map(''.join) + + +def whitespace_sequences_or_empty() -> SearchStrategy[str]: + return _whitespace_sequences() | just('') + + +def with_surrounding_whitespace(text: str) -> SearchStrategy[str]: + return tuples( + whitespace_sequences_or_empty(), + just(text), + whitespace_sequences_or_empty() + ) \ + .map(join) + + +def _odd_variations() -> SearchStrategy[str]: + return _variation_fragments('odd').map(''.join) + + +def _even_variations() -> SearchStrategy[str]: + return _variation_fragments('even').map(''.join) + + +def _non_integral_floats() -> SearchStrategy[float]: + return floats().filter(lambda example: not example.is_integer()) + + +def _operators() -> SearchStrategy[str]: + return sampled_from(['+', '-']) + + +def _signs() -> SearchStrategy[str]: + return just('') | _operators() + + +def _digits() -> SearchStrategy[int]: + return integers(min_value = 0, max_value = 9) + + +def _steps() -> SearchStrategy[str]: + return tuples(_signs(), lists(_digits(), max_size = 10).map(join)).map(join) + + +def _offsets() -> SearchStrategy[str]: + return lists(_digits(), min_size = 1, max_size = 10).map(join) + + +class ParseANPlusBTestCases: + + @staticmethod + @composite + def valid(draw: DrawFn) -> tuple[str, tuple[int, int]]: + step = draw(_steps()) + operator = draw(_signs()) + n = draw(sampled_from(['n', 'N'])) + + if not step: + a = 0 + elif step == '+': + a = 1 + elif step == '-': + a = -1 + else: + a = int(step) + + if operator: + offset = draw(_offsets()) + b = int(f'{operator}{offset}') + else: + offset = '' + b = 0 + + operator = draw(with_surrounding_whitespace(operator)) + text = draw(with_surrounding_whitespace(f'{step}{n}{operator}{offset}')) + + return text, (a, b) + + @staticmethod + @composite + def whitespace_after_a_sign(draw: DrawFn) -> str: + valid_cases_starting_with_sign = ParseANPlusBTestCases.valid() \ + .filter(_text_starts_with_sign) + valid_case = draw(valid_cases_starting_with_sign)[0] + + invalid_whitespace = draw(_whitespace_sequences()) + + return f'{valid_case[0]}{invalid_whitespace}{valid_case[1:]}' + + +@given( + one_of([ + _odd_variations().map(_tupled_with((2, 1))), + _even_variations().map(_tupled_with((2, 0))) + ]) +) +def test_parse_odd_even(text_and_expected): + text, expected = text_and_expected + instance = ANPlusB.parse(text) + + assert (instance.step, instance.offset) == expected + + +@given( + tuples(_signs(), lists(_digits(), min_size = 1, max_size = 10).map(join)) \ + .map(join) \ + .flatmap(with_surrounding_whitespace) +) +def test_parse_integer(text): + expected = int(text) + + instance = ANPlusB.parse(text) + + assert instance.step == 0 + assert instance.offset == expected + + +@given(whitespace_sequences_or_empty()) +def test_parse_empty(text): + with pytest.raises(EmptyInput): + ANPlusB.parse(text) + + +@given(ParseANPlusBTestCases.valid()) +@examples([ + (['+3n+2', (3, 2)]), + (['+4n+0', (4, 0)]), + (['+6n', (6, 0)]), + (['+5n-0', (5, 0)]), + (['+7n-1', (7, -1)]), + + (['3n+2', (3, 2)]), + (['4n+0', (4, 0)]), + (['6n', (6, 0)]), + (['5n-0', (5, 0)]), + (['7n-1', (7, -1)]), + + (['+0n+2', (0, 2)]), + (['+0n+0', (0, 0)]), + (['+0n', (0, 0)]), + (['+0n-0', (0, 0)]), + (['+0n-1', (0, -1)]), + + (['0n+2', (0, 2)]), + (['0n+0', (0, 0)]), + (['0n', (0, 0)]), + (['0n-0', (0, 0)]), + (['0n-1', (0, -1)]), + + (['-0n+2', (0, 2)]), + (['-0n+0', (0, 0)]), + (['-0n', (0, 0)]), + (['-0n-0', (0, 0)]), + (['-0n-1', (0, -1)]), + + (['-3n+2', (-3, 2)]), + (['-4n+0', (-4, 0)]), + (['-6n', (-6, 0)]), + (['-5n-0', (-5, 0)]), + (['-7n-1', (-7, -1)]), +]) +def test_parse_a_n_plus_b(text_and_expected): + text, expected = text_and_expected + instance = ANPlusB.parse(text) + + assert (instance.step, instance.offset) == expected + + +@given( + one_of([ + ParseANPlusBTestCases.whitespace_after_a_sign() + ]) +) +@examples([ + '+ 3' +]) +def test_parse_invalid(text): + with pytest.raises(InputIsNotParsable): + ANPlusB.parse(text) + + +@given( + one_of([ + tuples(integers(), integers()).map(_make_complex), + integers(), + integers().map(float) + ]) +) +def test_from_complex(value): + instance = ANPlusB.from_complex(value) + expected = (int(value.imag), int(value.real)) + + assert (instance.step, instance.offset) == expected + + +@given( + one_of([ + tuples(_non_integral_floats(), integers()), + tuples(integers(), _non_integral_floats()), + tuples(_non_integral_floats(), _non_integral_floats()) + ]) \ + .map(_make_complex) +) +def test_from_complex_invalid(value): + with pytest.raises(ComplexWithNonIntegerPart): + ANPlusB.from_complex(value) diff --git a/tests/test_indices.py b/tests/test_indices.py new file mode 100644 index 0000000..25d330d --- /dev/null +++ b/tests/test_indices.py @@ -0,0 +1,193 @@ +from collections.abc import Iterable +from itertools import product +from typing import cast, Literal + +import pytest +from _pytest.mark import ParameterSet +from hypothesis import assume, given +from hypothesis.strategies import ( + booleans, from_type, integers, just, + one_of, sampled_from, SearchStrategy, tuples +) + +from a_n_plus_b import ANPlusB, InvalidNumberOfChildren, InvalidOrder +from . import a_n_plus_b_instances, examples + + +_Order = Literal['ascending', 'descending', 'default'] +_IndicesTestCase = tuple[ANPlusB, tuple[int, bool, _Order], Iterable[int]] + + +def _ascending(values: Iterable[int]) -> Iterable[int]: + return sorted(values) + + +def _descending(indices: Iterable[int]) -> Iterable[int]: + return sorted(indices, reverse = True) + + +def _from_last(indices: Iterable[int], population: int) -> Iterable[int]: + return [population - index + 1 for index in indices] + + +def _sign(value: int, /) -> Literal['negative', 'positive', 'zero']: + return 'negative' if value < 0 else 'positive' if value > 0 else 'zero' + + +def _describe(case: _IndicesTestCase) -> str: + instance, (population, from_last, order), _ = case + step, offset = instance.step, instance.offset + + descriptions = [ + f'{_sign(step)} step', + f'{_sign(offset)} offset', + f'from last' if from_last else 'from first', + f'{order}' + ] + + return ', '.join(descriptions) + + +def _human_integers( + min_value: int = -(2 ** 16), + max_value: int = 2 ** 16, +) -> SearchStrategy[int]: + return integers( + min_value = max(-(2 ** 16), min_value), + max_value = min(2 ** 16, max_value) + ) + + +def _orders() -> SearchStrategy[_Order]: + return cast( + SearchStrategy[_Order], + sampled_from(['ascending', 'descending', 'default']) + ) + + +def _empty_indices_test_cases() -> SearchStrategy[_IndicesTestCase]: + return tuples( + a_n_plus_b_instances(integers(max_value = 0), integers(max_value = 0)), + tuples(integers(min_value = 0), booleans(), _orders()), + just(list[int]()) + ) + + +def _zero_step_indices_test_cases() -> SearchStrategy[_IndicesTestCase]: + def _tupled_with_expected( + example: tuple[ANPlusB, tuple[int, bool, _Order]] + ) -> _IndicesTestCase: + instance, (population, from_last, order) = example + b = instance.offset + + index = population - b + 1 if from_last else b + expected = [index] if 1 <= b <= population else [] + + return instance, (population, from_last, order), expected + + return tuples( + a_n_plus_b_instances(just(0), integers()), + tuples(integers(min_value = 0), booleans(), _orders()) + ) \ + .map(_tupled_with_expected) + + +def _non_positive_step_zero_offset_indices_test_cases() \ + -> SearchStrategy[_IndicesTestCase]: + def _tupled_with_expected( + example: tuple[ANPlusB, tuple[int, bool, _Order]] + ) -> _IndicesTestCase: + # PyCharm wouldn't be able to figure out the types otherwise. + instance, arguments = example + + return instance, arguments, [] + + return tuples( + a_n_plus_b_instances(_human_integers(max_value = -1), just(0)), + tuples(_human_integers(min_value = 0), booleans(), _orders()) + ) \ + .map(_tupled_with_expected) + + +def _indices_test_case_group( + instance: ANPlusB, + population: int, + base_case_expected: Iterable[int] +) -> list[ParameterSet]: + def make_case(from_last: bool, order: _Order) -> _IndicesTestCase: + expected = base_case_expected + + if from_last: + expected = _from_last(base_case_expected, population) + + if order == 'ascending': + expected = _ascending(expected) + elif order == 'descending': + expected = _descending(expected) + + return instance, (population, from_last, order), expected + + test_cases = [ + make_case(from_last, order) + for from_last, order in product( + [False, True], + ['default', 'ascending', 'descending'] + ) + ] + + return [ + pytest.param(test_case, id = _describe(test_case)) + for test_case in test_cases + ] + + +@given( + one_of([ + _empty_indices_test_cases(), + _zero_step_indices_test_cases(), + _non_positive_step_zero_offset_indices_test_cases() + ]) +) +@examples([ + *_indices_test_case_group(ANPlusB(3, 0), 100, list(range(3, 101, 3))), + + *_indices_test_case_group(ANPlusB(-2, 6), 10, [6, 4, 2]), + *_indices_test_case_group(ANPlusB(-1, 4), 8, [4, 3, 2, 1]), + *_indices_test_case_group(ANPlusB(-3, 8), 18, [8, 5, 2]), + + *_indices_test_case_group(ANPlusB(4, -5), 20, [3, 7, 11, 15, 19]), + *_indices_test_case_group(ANPlusB(5, -2), 12, [3, 8]), + + *_indices_test_case_group(ANPlusB(2, 1), 15, [1, 3, 5, 7, 9, 11, 13, 15]), + *_indices_test_case_group(ANPlusB(3, 1), 10, [1, 4, 7, 10]), + *_indices_test_case_group(ANPlusB(1, 4), 11, [4, 5, 6, 7, 8, 9, 10, 11]) +]) +def test_indices(instance_arguments_expected: _IndicesTestCase): + instance, arguments, expected = instance_arguments_expected + population, from_last, order = arguments + + indices = instance.indices(population, from_last = from_last, order = order) + + assert list(indices) == expected + + +@given( + a_n_plus_b_instances(), + integers(), booleans(), from_type(str) +) +def test_indices_invalid_order(instance, population, from_last, order): + assume(order not in ('ascending', 'descending', 'default')) + + with pytest.raises(InvalidOrder): + instance.indices(population, from_last = from_last, order = order) + + +@given( + a_n_plus_b_instances(), + integers(max_value = -1), booleans(), _orders() +) +def test_indices_invalid_number_of_children( + instance, population, from_last, order +): + with pytest.raises(InvalidNumberOfChildren): + instance.indices(population, from_last = from_last, order = order) diff --git a/tests/test_other_methods.py b/tests/test_other_methods.py new file mode 100644 index 0000000..e9ea96b --- /dev/null +++ b/tests/test_other_methods.py @@ -0,0 +1,120 @@ +import pytest +from hypothesis import given, infer +from hypothesis.strategies import from_type, integers, SearchStrategy, tuples + +from a_n_plus_b import ANPlusB, IncorrectUseOfConstructor +from . import a_n_plus_b_instances + + +_EqTestCase = tuple[ANPlusB, '_ANPlusBSubclass', bool] + + +class _ANPlusBSubclass(ANPlusB): + pass + + +def _make_eq_test_case(example: tuple[int, int]) -> _EqTestCase: + step, offset = example + + return ANPlusB(step, offset), _ANPlusBSubclass(step, offset), True + + +def _eq_test_group() -> SearchStrategy[_EqTestCase]: + return tuples(integers(), integers()).map(_make_eq_test_case) + + +@given(integers(), integers()) +def test_construction(step, offset): + instance = ANPlusB(step, offset) + + assert instance.step == step + assert instance.offset == offset + + +@given(integers()) +def test_construction_single_argument(offset): + instance = ANPlusB(offset) + + assert instance.step == 0 + assert instance.offset == offset + + +@given(infer) +def test_construction_invalid(text: str): + with pytest.raises(IncorrectUseOfConstructor): + ANPlusB(text) # noqa + + +@pytest.mark.parametrize(('instance', 'expected'), [ + (ANPlusB(0, -2), '-2'), + (ANPlusB(0, 0), '0'), + (ANPlusB(0, 2), '2'), + + (ANPlusB(1, -3), 'n-3'), + (ANPlusB(1, 0), 'n'), + (ANPlusB(1, 3), 'n+3'), + + (ANPlusB(-1, -4), '-n-4'), + (ANPlusB(-1, 0), '-n'), + (ANPlusB(-1, 4), '-n+4'), + + (ANPlusB(3, 4), '3n+4'), + (ANPlusB(3, 0), '3n'), + (ANPlusB(3, -5), '3n-5'), + + (ANPlusB(-4, 5), '-4n+5'), + (ANPlusB(-4, 0), '-4n'), + (ANPlusB(-4, -6), '-4n-6') +]) +def test_str(instance, expected): + assert str(instance) == expected + assert repr(instance) == f'{ANPlusB.__name__}({expected})' + + +@given(a_n_plus_b_instances()) +def test_values(instance): + a, b = instance.step, instance.offset + + for index, value in zip(range(10), instance.values()): + assert value == a * index + b + + +@given(a_n_plus_b_instances(), integers(min_value = 0)) +def test_values_contain_getitem(instance, index): + a, b = instance.step, instance.offset + value = instance.values()[index] + + assert value == a * index + b + assert value in instance.values() + + +@given( + a_n_plus_b_instances(), + from_type(object).filter(lambda o: not isinstance(o, int)) +) +def test_values_not_contain(instance, item): + assert item not in instance.values() + + +@given(a_n_plus_b_instances(), integers(max_value = -1)) +def test_getitem_invalid(instance, index): + with pytest.raises(IndexError): + _ = instance.values()[index] + + +@given(a_n_plus_b_instances()) +def test_eq(this): + a, b = this.step, this.offset + that = ANPlusB(a, b) + + assert this == that + assert hash(this) == hash(that) == hash((a, b)) + + +@pytest.mark.parametrize(('this', 'that', 'expected'), [ + (ANPlusB(0, 0), _ANPlusBSubclass(0, 0), True), + (_ANPlusBSubclass(0, 0), ANPlusB(0, 0), True), + +]) +def test_eq_subclass(this, that, expected): + assert (this == that) is expected diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..da7a724 --- /dev/null +++ b/tox.ini @@ -0,0 +1,30 @@ +[tox] +env_list = + py310 + py311 + py312 + typecheck +minversion = 4.12.1 +isolated_build = true + +[gh-actions] +python = + 3.10: py310, typecheck + 3.11: py311 + 3.12: py312 + +[testenv] +setenv = + PYTHONPATH = {toxinidir} +deps = + .[dev] +commands = + pytest {toxinidir}/tests + +[testenv:typecheck] +basepython = 3.10 +deps = + .[dev] +commands = + mypy src --strict + pyright src