parse_stages-0.1.9/.editorconfig0000644000000000000000000000115613615410400013601 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause # # https://editorconfig.org/ root = true [*] end_of_line = lf insert_final_newline = true charset = utf-8 [*.md] indent_style = space indent_size = 2 [*.nix] indent_style = space indent_size = 2 [*.py] indent_style = space indent_size = 4 [*.pyi] indent_style = space indent_size = 4 [*.sh] indent_style = tab tab_size = 8 [*.toml] indent_style = space indent_size = 2 [*.yml] indent_style = space indent_size = 2 [setup.cfg] indent_style = space indent_size = 4 [tox.ini] indent_style = space indent_size = 2 parse_stages-0.1.9/.readthedocs.yaml0000644000000000000000000000043213615410400014347 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause version: 2 build: os: ubuntu-22.04 tools: python: "3.12" mkdocs: configuration: mkdocs.yml python: install: - requirements: requirements/docs.txt parse_stages-0.1.9/mkdocs.yml0000644000000000000000000000240313615410400013123 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause theme: name: material features: - navigation.instant - navigation.tracking - toc.integrate - toc.follow - content.code.copy palette: - media: "(prefers-color-scheme: light)" scheme: default toggle: icon: material/weather-sunny name: Switch to dark mode - media: "(prefers-color-scheme: dark)" scheme: slate toggle: icon: material/weather-night name: Switch to light mode site_name: parse-stages repo_url: https://gitlab.com/ppentchev/parse-stages repo_name: parse-stages site_author: ppentchev site_url: https://devel.ringlet.net/devel/parse-stages/ site_dir: site/docs nav: - 'index.md' - 'Download': 'download.md' - 'Changelog': 'changes.md' - 'Language reference': 'language.md' - 'API reference': 'api.md' markdown_extensions: - toc: - pymdownx.highlight: anchor_linenums: true - pymdownx.inlinehilite: - pymdownx.superfences: plugins: - mkdocstrings: default_handler: python handlers: python: paths: [src] options: heading_level: 3 show_root_heading: true - search watch: - 'src/parse_stages' parse_stages-0.1.9/ruff-base.toml0000644000000000000000000000124713615410400013674 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause target-version = "py310" line-length = 100 [lint] select = [] ignore = [ # No blank lines before the class docstring, TYVM "D203", # The multi-line docstring summary starts on the same line "D213", # We do not document everything in the docstrings "DOC201", "DOC501", ] [lint.flake8-copyright] notice-rgx = "(?x) SPDX-FileCopyrightText: \\s \\S" [lint.isort] force-single-line = true known-first-party = ["parse_stages"] lines-after-imports = 2 single-line-exclusions = ["typing"] [lint.per-file-ignores] # This is a test suite "tests/unit/**.py" = ["S101"] parse_stages-0.1.9/tox.ini0000644000000000000000000000321413615410400012434 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause [tox] minversion = 4.1 envlist = ruff format reuse mypy unit-tests docs isolated_build = True [defs] pyfiles = src/parse_stages \ tests/unit [testenv:ruff] skip_install = True tags = check ruff quick deps = -r requirements/ruff.txt commands = ruff check -- {[defs]pyfiles} [testenv:format] skip_install = True tags = check quick deps = -r requirements/ruff.txt commands = ruff check --config ruff-base.toml --select=D,I --diff -- {[defs]pyfiles} ruff format --check --config ruff-base.toml --diff -- {[defs]pyfiles} [testenv:reformat] skip_install = True tags = format manual deps = -r requirements/ruff.txt commands = ruff check --config ruff-base.toml --select=D,I --fix -- {[defs]pyfiles} ruff format --config ruff-base.toml -- {[defs]pyfiles} [testenv:mypy] skip_install = True tags = check deps = -r requirements/install.txt -r requirements/test.txt mypy >= 1, < 2 setenv = MYPYPATH = {toxinidir}/stubs commands = mypy {[defs]pyfiles} [testenv:pyupgrade] skip_install = True tags = check manual deps = pyupgrade >= 3, < 4 allowlist_externals = sh commands = sh -c 'pyupgrade --py310-plus src/parse_stages/*.py tests/unit/*.py' [testenv:unit-tests] tags = tests deps = -r requirements/install.txt -r requirements/test.txt commands = pytest {posargs} tests/unit [testenv:docs] skip_install = True tags = check docs deps = -r requirements/docs.txt commands = mkdocs build [testenv:reuse] skip_install = True tags = check quick deps = reuse >= 4, < 5 commands = reuse lint parse_stages-0.1.9/LICENSES/BSD-2-Clause.txt0000644000000000000000000000236313615410400015054 0ustar00Copyright (c) Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. parse_stages-0.1.9/docs/api.md0000644000000000000000000000106213615410400013143 0ustar00 # API Reference ## The tagged object classes ::: parse_stages.Tagged ::: parse_stages.TaggedFrozen ## The evaluated boolean expression ::: parse_stages.BoolExpr ::: parse_stages.OrExpr ::: parse_stages.AndExpr ::: parse_stages.NotExpr ## The object's tags and keywords ::: parse_stages.TagExpr ::: parse_stages.KeywordExpr ## The helper functions ::: parse_stages.EMPTY_SET_SPECS ::: parse_stages.parse_spec ::: parse_stages.parse_stage_ids parse_stages-0.1.9/docs/changes.md0000644000000000000000000002756513615410400014022 0ustar00 # Changelog All notable changes to the parse-stages project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [0.1.9] - 2024-08-09 ### Semi-incompatible changes - Drop support for Python 3.8 and 3.9 ### Fixes - Fix the logic in a "this should never happen anyway" check for the `pyparsing` parser returning invalid results - Nix expressions: - run `python3.X`, not `python3`, so as to not accidentally invoke a "more preferred" Python version that is also installed in the Nix environment ### Other changes - Let Ruff insist on trailing commas when formatting the source code - Use `list` instead of `typing.List` for the dataclass member fields - Simplify the expression parser by using structural pattern matching - Use `typeA | typeB | typeC` as a type parameter to `isinstance()` - Documentation: - use mkdocstrings 0.25 with no changes - Test suite: - Ruff: - fold the "all" configuration into the `pyproject.toml` file and move the "base" one to the `ruff-base.toml` file in the top-level directory - use Ruff 0.5.7 - drop the override for the no longer emitted `ANN101` - add global overrides for the new docstring-related `DOC201` and `DOC501` - use Reuse 4.x with no changes - vendor-import vetox 0.2.0 and use it with no changes - Nix expressions: - drop Python 3.8 from the `run-*.sh` helpers, it was dropped form nixpkgs/unstable - run pytest on Python 3.13, too - run tox on Python 3.12 and 3.13, too - update the vendored copy of vetox to version 0.1.3 - run vetox with support for [uv](https://github.com/astral-sh/uv) and [tox-uv](https://github.com/tox-dev/tox-uv) - when running `uv`, use `/etc/ssl/certs/ca-certificates.crt` as the path to the system-wide certificates file; allow it to be overridden using the `VETOX_CERT_FILE` environment variable - only pass the minor version of Python, we only support Python 3.x - pass the Python minor version as a string for easier interpolation ## [0.1.8] - 2024-02-08 ### Additions - Support parentheses in stage specifications, e.g. `(@check or @docs) and not @manual` - Tentatively declare Python 3.13 as a supported version - Documentation: - add a "Download" page - Test suite: - vendor-import [the vetox testing tool](https://devel.ringlet.net/devel/vetox/) - add a Nix expression to run `vetox` ### Other changes - Completely switch to Ruff for source code formatting - Test suite: - use Ruff 0.2.1: - derive an abstract class directly from `abc.ABC`, no need for the metaclass - sort the list of exported symbols (`__all__`) - add an empty line to the end of the "Examples" docstring section - push some Ruff configuration settings into the `ruff.lint.*` hierarchy - use Pytest 8.x and also install Pygments for syntax highlighting - push the unit tests' source from `unit_tests/` into `tests/unit/` - also build the documentation in the second Tox stage - put the Tox stage specifications in the `pyproject.toml` file on separate lines - let `reuse` ignore the `.mypy_cache/` directory, too ## [0.1.7] - 2023-11-08 ### Fixes - Documentation: - fix the ReadTheDocs build by adding the `build` section to its configuration ### Additions - Test suite: - also run the Ruff formatter in the `format` Tox check environment - add a "reformat-ruff" Tox environment that uses the Ruff formatter - add the "reuse" environment to the list of Tox environments to run by default and install Git in the Nix derivation since the `reuse` tool uses it to figure out which files to check ### Other changes - Documentation: - use mkdocstrings 0.23 with no changes - Test suite: - use Ruff 0.1.4: - specify the copyright notice pattern for Ruff to look for - let Ruff also reformat the docstrings in addition to the import blocks - move the "reuse" Tox test environment to the first (quick) stage ## [0.1.6] - 2023-09-28 ### Other changes - Test suite: - use a pinned version of Ruff to avoid breakage with changes enabled in future versions - use Ruff 0.0.291, add a forgotten deferred annotations import - move the "format" check to the first Tox stage - use black 23.7 or later so it understands "py312" as a supported version - use reuse 2.x with no changes ## [0.1.5] - 2023-06-27 ### Fixes - Build infrastructure: - relax the `hatch-requirements-txt` to >= 0.3, which is the version in Debian testing/unstable - specify a lower requirement for `hatchling` as >= 1.8, which is the version in Ubuntu 22.10 (kinetic) - specify an upper requirement for `hatchling` - Main source: - do not disable all Flake8 / Ruff checks for three files! - fix some formatting nits in the newly-checked source files - Test suite: - drop the `pyupgrade` environment from Tox's default envlist; there is (by design) no way to run it in a no-op, report-only mode - do not pass the `python_version` option to `mypy`, let it check the code with regard to the currently-running Python interpreter ### Additions - Build infrastructure: - specify the project license - add some more PyPI trove classifiers - Test suite: - introduce the `PY_MINVER_MIN` and `PY_MINVER_MAX` environment variables for the `nix/run-pytest.sh` helper tool so that e.g. the current flakiness of Python 3.12 in nixpkgs-unstable can be skipped - add a Nix expression and the `nix/run-tox.sh` helper tool to run all the Tox tests using a different Python version - also include the `reuse` Tox environment in the ones run at the `@check` stage using the `tox-stages` tool ### Other changes - Main source: - replace assertions in the parsing code with `if` statements that raise our own exceptions - Documentation: - refer to version 1.1.0 of the "Keep a Changelog" format specification - use `mkdocstrings` 0.22, no changes - Test suite: - drop the `flake8` / `pep8`, `pylint`, and `pydocstyle` Tox test environments, Ruff handles most of these checks now - use Ruff 0.0.275, activate all the new check areas, just in case - use mypy 1.x, no changes ## [0.1.4] - 2023-05-13 ### Additions - Documentation: - add a [ReadTheDocs mirror](https://parse-stages.readthedocs.io/) - Main source: - `parse_stage_ids()`: allow an empty set to be specified by the exact strings "", "0", or "none" ### Other changes - Build system: - drop the obsolete `tool.setuptools.*` sections from the `pyproject.toml` file - Documentation: - bump the version of `mkdocstrings-python` with no changes - Test suite: - Ruff: - use Ruff 0.0.267, enable the `FLY` checks area ## [0.1.3] - 2023-05-07 ### Fixes - Add a blank line to a docstring section (ruff D214). - Drop the text in a `NotImplementedError` raised in an abstract method; it should be obvious (ruff EM101) ### Additions - Add a `.gitignore` file, mostly so that `reuse` can be run at any time - Add a "Home, GitLab, PyPI" navigational line to the documentation index page and to the `README.md` file ### Other changes - Main source: - reformat the import sections using Ruff's `isort` implementation - Build system: - switch to `hatch` / `hatchling` for the PEP517 build - Documentation: - use the `default_handler` configuration option of `mkdocstrings` instead of specifying `handler: python` for each class! - bump the versions of `mkdocstrings` and `mkdocstrings-python` with no changes - Test suite: - specify 4.1 as the Tox minimum version and switch to the Tox 4.x format for the multiline list of files - rename the `black` Tox environment to `format` and the `black-reformat` one to `reformat`, since they also run Ruff's `isort` now - Ruff: - use Ruff 0.0.265, enable the `INT` checks area although we do not use gettext - enable all ruff check areas in `config/ruff-most/`, let the `config/ruff-base/` files take care of the ignored ones - no longer disable the "relative imports" check, it does not complain about our source code - Pylint: - reenable the "empty comment" plugin; we should have no trouble with it since switching to SPDX license tags - Bump the versions of `flake8-implicit-str-concat`, `flake8-simplify`, `pylint`, and `triceratops` with no code changes ## [0.1.2] - 2023-03-10 ### Semi-incompatible changes - Drop Python 3.7 support. ### Fixes - Fix the 0.1.1 release URL in the changelog file. - Do not run the `pyupgrade` Tox environment automatically. - Add a docstring to the unit test suite's `__init__.py` file instead of overriding a linter check. ### Additions - Start some MkDocs-based documentation. - Add the `reuse` Tox test environment to run the REUSE tool manually. ### Other changes - Rework the Ruff invocation Tox targets a bit: - drop the `ruff-all-unchained` test environment; at this point in Ruff's development we are bound to specific versions anyway - move the config files to a `config/` directory, not `.config/` - move the "normal" Ruff invocation configuration to `config/ruff-most/` - use Ruff 0.0.254 with no code changes - activate all of Ruff 0.0.254's liners in the "normal" invocation - Switch to SPDX license tags. - Specify the supported Python version in the configuration of the pylint and black tools. - Move the changelog file into the documentation directory. - In the packaging metadata, point to the Ringlet homepage generated from the newly-added documentation. ## [0.1.1] - 2023-02-05 ### Fixes - Use "precedence" instead of "priority" when discussing operators in the README file. - Do not use the `list` generic type in the definition of the `TaggedFrozen` and `Tagged` classes; library consumers may try to use `typing.get_type_hints()` on them or on derived classes, and Python < 3.9 would have a problem with that. - Fill in the module docstring using the text of the README file. - Fix some problems reported by `ruff`: - fix the order of some `import` statements - fix the formatting of some docstrings ### Other changes - Add the `ruff-all` test environment that enables all the checks of the `ruff` tool for a certain locked version of `ruff`. - Add the `tool.test-stages` section in the `pyproject.toml` file to specify the order that Tox environments should be run during development using the `tox-stages` tool from the `test-stages` Python library. - Add a lot of `flake8` plugins to the Tox `pep8` test environment - Use ruff 0.0.241, pylint 2.16.x, and black 23.x. ## [0.1.0] - 2023-01-25 ### Started - First public release. [Unreleased]: https://gitlab.com/ppentchev/parse-stages/-/compare/release%2F0.1.9...main [0.1.9]: https://gitlab.com/ppentchev/parse-stages/-/compare/release%2F0.1.8...release%2F0.1.9 [0.1.8]: https://gitlab.com/ppentchev/parse-stages/-/compare/release%2F0.1.7...release%2F0.1.8 [0.1.7]: https://gitlab.com/ppentchev/parse-stages/-/compare/release%2F0.1.6...release%2F0.1.7 [0.1.6]: https://gitlab.com/ppentchev/parse-stages/-/compare/release%2F0.1.5...release%2F0.1.6 [0.1.5]: https://gitlab.com/ppentchev/parse-stages/-/compare/release%2F0.1.4...release%2F0.1.5 [0.1.4]: https://gitlab.com/ppentchev/parse-stages/-/compare/release%2F0.1.3...release%2F0.1.4 [0.1.3]: https://gitlab.com/ppentchev/parse-stages/-/compare/release%2F0.1.2...release%2F0.1.3 [0.1.2]: https://gitlab.com/ppentchev/parse-stages/-/compare/release%2F0.1.1...release%2F0.1.2 [0.1.1]: https://gitlab.com/ppentchev/parse-stages/-/compare/release%2F0.1.0...release%2F0.1.1 [0.1.0]: https://gitlab.com/ppentchev/parse-stages/-/tags/release%2F0.1.0 parse_stages-0.1.9/docs/download.md0000644000000000000000000000373413615410400014211 0ustar00 # Download These are the released versions of [parse-stages](index.md) available for download. ## [0.1.9] - 2024-08-09 ### Source tarball - [parse_stages-0.1.9.tar.gz](https://devel.ringlet.net/files/devel/parse-stages/parse_stages-0.1.9.tar.gz) (with [a PGP signature](https://devel.ringlet.net/files/devel/parse-stages/parse_stages-0.1.9.tar.gz.asc)) ### Python wheel - [parse_stages-0.1.9-py3-none-any.whl](https://devel.ringlet.net/files/devel/parse-stages/parse_stages-0.1.9-py3-none-any.whl) (with [a PGP signature](https://devel.ringlet.net/files/devel/parse-stages/parse_stages-0.1.9-py3-none-any.whl.asc)) ## [0.1.8] - 2024-02-08 ### Source tarball - [parse_stages-0.1.8.tar.gz](https://devel.ringlet.net/files/devel/parse-stages/parse_stages-0.1.8.tar.gz) (with [a PGP signature](https://devel.ringlet.net/files/devel/parse-stages/parse_stages-0.1.8.tar.gz.asc)) ### Python wheel - [parse_stages-0.1.8-py3-none-any.whl](https://devel.ringlet.net/files/devel/parse-stages/parse_stages-0.1.8-py3-none-any.whl) (with [a PGP signature](https://devel.ringlet.net/files/devel/parse-stages/parse_stages-0.1.8-py3-none-any.whl.asc)) ## [0.1.7] - 2023-11-08 ### Source tarball - [parse_stages-0.1.7.tar.gz](https://devel.ringlet.net/files/devel/parse-stages/parse_stages-0.1.7.tar.gz) (with [a PGP signature](https://devel.ringlet.net/files/devel/parse-stages/parse_stages-0.1.7.tar.gz.asc)) ### Python wheel - [parse_stages-0.1.7-py3-none-any.whl](https://devel.ringlet.net/files/devel/parse-stages/parse_stages-0.1.7-py3-none-any.whl) (with [a PGP signature](https://devel.ringlet.net/files/devel/parse-stages/parse_stages-0.1.7-py3-none-any.whl.asc)) [0.1.9]: https://gitlab.com/ppentchev/parse-stages/-/tags/release%2F0.1.9 [0.1.8]: https://gitlab.com/ppentchev/parse-stages/-/tags/release%2F0.1.8 [0.1.7]: https://gitlab.com/ppentchev/parse-stages/-/tags/release%2F0.1.7 parse_stages-0.1.9/docs/index.md0000644000000000000000000001064513615410400013510 0ustar00 # Parse a mini-language for selecting objects by tag or name \[[Home][ringlet-parse-stages] | [GitLab][gitlab] | [PyPI][pypi] | [ReadTheDocs][readthedocs]\] ## Overview The `parse-stages` Python library may be used by other tools to group objects (e.g. [Tox] or [Nox] test environments) for step-by-step processing (e.g. running some tests in parallel, then only running others if the first group passes). The language used by the library is described in the [_Grouping stages_](language.md#grouping-stages-for-step-by-step-execution) section. [tox]: https://tox.wiki/en/latest/ "The tox automation project" [nox]: https://nox.thea.codes/en/stable/ "The nox flexible automation tool" ## Installation A program that uses the `parse-stages` library should specify it in its list of requirements, e.g. using [PEP508][pep508] syntax: parse-stages >= 0.1.9, < 0.2 [pep508]: https://peps.python.org/pep-0508/ "PEP 508 – Dependency specification for Python Software Packages" ## Parsing a stage specification The `parse_spec()` function parses a string specification into a [BoolExpr][parse_stages.BoolExpr] object that may later be used to select matching objects (e.g. test environments). ``` py e_check = parse_stages.parse_spec("@check") e_check_quick = parse_stages.parse_spec("@check and @quick") e_check_no_ruff = parse_stages.parse_spec("@check and not ruff") specs = [(spec, parse_stages.parse_spec(spec)) for spec in args.stage_specs] ``` ## Check whether an object matches a parsed specification The `parse-stages` library provides two base dataclasses for objects that may be matched against parsed expressions: [TaggedFrozen][parse_stages.TaggedFrozen] and [Tagged][parse_stages.Tagged]. Both classes have the same members: - [name][parse_stages.TaggedFrozen.name]: a string - [tags][parse_stages.TaggedFrozen.tags]: a list of strings - [get_keyword_haystacks()][parse_stages.TaggedFrozen.get_keyword_haystacks]: a method that returns a list of strings, `self.name` unless overridden When a `BoolExpr` object's [evaluate()][parse_stages.BoolExpr.evaluate] method is called for a specific `TaggedFrozen` or `Tagged` object, it checks whether the specification matches the tags and keywords defined for this object. Tags are matched exactly, while a keyword is considered to match if it is contained in the checked string; e.g. `pep` would match both `pep8` and `exp_pep563`, while `@black` would not match a `black-reformat` tag. The `get_keyword_haystacks()` method returns the strings to look in for matching keywords. By default, it only returns the `name` field; however, it may be extended, e.g. for Nox sessions it may also return the name of the Python function that implements the session, for test classes with methods it may return the class name and the method name, etc. ``` py # Obtain a list (okay, a dictionary) of test environments in some way tox_envs = get_tox_environments() # {"ruff": {"tags": ["check", "quick"]}, ...} # Convert them to objects that the parsed expressions can match all_envs = [ parse_stages.TaggedFrozen(name, env["tags"]) for name, env in tox_envs.items() ] # Or define our own class that may hold additional information @dataclasses.dataclass(frozen=True) class TestEnv(parse_stages.TaggedFrozen): """A single test environment: name, tags, etc.""" ... all_envs = [TestEnv(name, env["tags"], ...) for name, env in tox_envs.items()] # Select the ones that match the "@check" expression matched = [env for env in all_envs if e_check.evaluate(env)] # Or if we only care about the names... quick_names = [env.name for env in all_envs if e_check_quick.evaluate(env)] ``` ## Contact The `parse-stages` library was written by [Peter Pentchev][roam]. It is developed in [a GitLab repository][gitlab]. This documentation is hosted at [Ringlet][ringlet-parse-stages] with a copy at [ReadTheDocs][readthedocs]. [roam]: mailto:roam@ringlet.net "Peter Pentchev" [gitlab]: https://gitlab.com/ppentchev/parse-stages "The parse-stages GitLab repository" [pypi]: https://pypi.org/project/parse-stages/ "The parse-stages Python Package Index page" [readthedocs]: https://parse-stages.readthedocs.io/ "The parse-stages ReadTheDocs page" [ringlet-parse-stages]: https://devel.ringlet.net/devel/parse-stages/ "The Ringlet parse-stages homepage" parse_stages-0.1.9/docs/language.md0000644000000000000000000000153113615410400014156 0ustar00 # The Specification Mini-Language ## Grouping stages for step-by-step execution The specification mini-language may roughly be described as: expr ::= and_expr ["or" and_expr...] and_expr ::= not_expr ["and" not_expr...] not_expr ::= ["not"] atom atom ::= tag | keyword | "(" expr ")" tag ::= "@" characters keyword ::= characters characters ::= [A-Za-z0-9_-]+ Thus, all of the following: - `@check` - `@check and @quick` - `@tests and not examples` - `not @tests` - `pep8 or not @quick and @check` - `not (@check or @tests) and @quick` ...are valid expressions, with the "not", "and", and "or" keywords having their usual precedence (`pep8 or not @quick and @check` is parsed as `pep8 or ((@not quick) and @check)`). parse_stages-0.1.9/nix/cleanpy.sh0000755000000000000000000000075713615410400013722 0ustar00#!/bin/sh # # SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause set -e find . -mindepth 1 -maxdepth 1 -type d \( \ -name '.tox' \ -or -name '.mypy_cache' \ -or -name '.pytest_cache' \ -or -name '.nox' \ -or -name '.ruff_cache' \ \) -exec rm -rf -- '{}' + find . -type d -name '__pycache__' -exec rm -rfv -- '{}' + find . -type f -name '*.pyc' -delete -print find . -mindepth 1 -maxdepth 2 -type d -name '*.egg-info' -exec rm -rfv -- '{}' + parse_stages-0.1.9/nix/mkdocs.nix0000644000000000000000000000102313615410400013713 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause { pkgs ? import { } , py-ver ? "11" }: let python-name = "python3${py-ver}"; python = builtins.getAttr python-name pkgs; python-pkgs = python.withPackages (p: with p; [ mkdocs mkdocs-material mkdocstrings mkdocstrings-python ] ); in pkgs.mkShell { buildInputs = [ python-pkgs ]; shellHook = '' set -e rm -rf site python3.${py-ver} -m mkdocs build exit ''; } parse_stages-0.1.9/nix/python-pytest.nix0000644000000000000000000000073113615410400015307 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause { pkgs ? import { } , py-ver ? "11" }: let python-name = "python3${py-ver}"; python = builtins.getAttr python-name pkgs; python-pkgs = python.withPackages (p: with p; [ pyparsing pytest ]); in pkgs.mkShell { buildInputs = [ python-pkgs ]; shellHook = '' set -e PYTHONPATH="$(pwd)/src" python3.${py-ver} -m pytest -v tests/unit exit ''; } parse_stages-0.1.9/nix/python-tox.nix0000644000000000000000000000071213615410400014570 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause { pkgs ? import { } , py-ver ? "11" }: let python-name = "python3${py-ver}"; python = builtins.getAttr python-name pkgs; python-pkgs = python.withPackages (p: with p; [ tox ]); in pkgs.mkShell { buildInputs = [ pkgs.gitMinimal python-pkgs ]; shellHook = '' set -e python3.${py-ver} -m tox run-parallel exit ''; } parse_stages-0.1.9/nix/python-vetox.nix0000644000000000000000000000107313615410400015124 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause { pkgs ? import { } , py-ver ? "11" }: let python-name = "python3${py-ver}"; python = builtins.getAttr python-name pkgs; in pkgs.mkShell { buildInputs = [ pkgs.gitMinimal pkgs.uv python ]; shellHook = '' set -e if [ -z "$VETOX_CERT_FILE" ]; then VETOX_CERT_FILE='/etc/ssl/certs/ca-certificates.crt' fi env SSL_CERT_FILE="$VETOX_CERT_FILE" python3.${py-ver} tests/vetox.py run-parallel --tox-uv --uv exit ''; } parse_stages-0.1.9/nix/reformat.sh0000755000000000000000000000041213615410400014072 0ustar00#!/bin/sh # # SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause set -e script_path="$(readlink -f -- "$0")" nix_dir="$(dirname -- "$script_path")" nix-shell --pure -p nixpkgs-fmt --run "nixpkgs-fmt '$nix_dir'/*.nix" parse_stages-0.1.9/nix/run-pytest.sh0000755000000000000000000000065313615410400014414 0ustar00#!/bin/sh # # SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause set -e : "${PY_MINVER_MIN:=10}" : "${PY_MINVER_MAX:=13}" for pyver in $(seq -- "$PY_MINVER_MIN" "$PY_MINVER_MAX"); do nix/cleanpy.sh printf -- '\n===== Running tests for 3.%s\n\n\n' "$pyver" nix-shell --pure --argstr py-ver "$pyver" nix/python-pytest.nix printf -- '\n===== Done with 3.%s\n\n' "$pyver" done parse_stages-0.1.9/nix/run-tox.sh0000755000000000000000000000065113615410400013674 0ustar00#!/bin/sh # # SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause set -e : "${PY_MINVER_MIN:=10}" : "${PY_MINVER_MAX:=13}" for pyver in $(seq -- "$PY_MINVER_MIN" "$PY_MINVER_MAX"); do nix/cleanpy.sh printf -- '\n===== Running tests for 3.%s\n\n\n' "$pyver" nix-shell --pure --argstr py-ver "$pyver" nix/python-tox.nix printf -- '\n===== Done with 3.%s\n\n' "$pyver" done parse_stages-0.1.9/nix/run-vetox.sh0000755000000000000000000000070113615410400014223 0ustar00#!/bin/sh # # SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause set -e : "${PY_MINVER_MIN:=10}" : "${PY_MINVER_MAX:=13}" for pyver in $(seq -- "$PY_MINVER_MIN" "$PY_MINVER_MAX"); do nix/cleanpy.sh printf -- '\n===== Running tests for 3.%s\n\n\n' "$pyver" nix-shell --pure --keep VETOX_CERT_FILE --argstr py-ver "$pyver" nix/python-vetox.nix printf -- '\n===== Done with 3.%s\n\n' "$pyver" done parse_stages-0.1.9/requirements/docs.txt0000644000000000000000000000032413615410400015334 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause mkdocs >= 1.4.2, < 2 mkdocs-material >= 9.1.2, < 10 mkdocstrings >= 0.25, < 0.26 mkdocstrings-python >= 1, < 2 parse_stages-0.1.9/requirements/install.txt0000644000000000000000000000017113615410400016052 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause pyparsing >= 3, < 4 parse_stages-0.1.9/requirements/ruff.txt0000644000000000000000000000016313615410400015347 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause ruff == 0.5.7 parse_stages-0.1.9/requirements/test.txt0000644000000000000000000000021313615410400015360 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause pygments >= 2.7, < 3 pytest >= 8, < 9 parse_stages-0.1.9/src/parse_stages/__init__.py0000644000000000000000000001025513615410400016504 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Parse a mini-language for selecting objects by tag or name. This library is mostly useful for command-line parsing by other tools like `tox-stages` and `nox-stages`. It may be used to parse e.g. a command-line specification like `@check and not pylint` or `@tests or ruff` and then match it against a list of objects that have names and lists of tags. Parse stage specifications -------------------------- The `parse_spec()` function parses a string specification into a `BoolExpr` object that may later be used to select matching objects (e.g. test environments). The specification mini-language may roughly be described as: expr ::= and_expr ["or" and_expr...] and_expr ::= not_expr ["and" not_expr...] not_expr ::= ["not"] atom atom ::= tag | keyword tag ::= "@" characters keyword ::= characters characters ::= [A-Za-z0-9_-]+ Thus, all of the following: - `@check` - `@check and @quick` - `@tests and not examples` - `not @tests` - `pep8 or not @quick and @check` ...are valid expressions, with the "not", "and", and "or" keywords having their usual precedence (`pep8 or not @quick and @check` is parsed as `pep8 or ((@not quick) and @check)`). Check whether an object matches a parsed specification ------------------------------------------------------ The `parse-stages` library provides two base dataclasses for objects that may be matched against parsed expressions: `TaggedFrozen` and `Tagged`. Both classes have the same members: - `name`: a string - `tags`: a list of strings - `get_keyword_haystacks()`: a method that returns a list of strings, `[self.name]` unless overridden When a `BoolExpr` object's `evaluate()` method is called for a specific `TaggedFrozen` or `Tagged` object, it checks whether the specification matches the tags and keywords defined for this object. Tags are matched exactly, while a keyword is considered to match if it is contained in the checked string; e.g. `pep` would match both `pep8` and `exp_pep563`, while `@black` would not match a `black-reformat` tag. The `get_keyword_haystacks()` method returns the strings to look in for matching keywords. By default, it only returns the `name` field; however, it may be extended, e.g. for Nox sessions it may also return the name of the Python function that implements the session, for test classes with methods it may return the class name and the method name, etc. Examples -------- Parse a list of stage specifications into expressions that may later be matched against test environment definitions: e_check = parse_stages.parse_spec("@check") e_check_quick = parse_stages.parse_spec("@check and @quick") e_check_no_ruff = parse_stages.parse_spec("@check and not ruff") specs = [(spec, parse_stages.parse_spec(spec)) for spec in args.stage_specs] Select the test environments that match the specification: # Obtain a list (okay, a dictionary) of test environments in some way tox_envs = get_tox_environments() # {"ruff": {"tags": ["check", "quick"]}, ...} # Convert them to objects that the parsed expressions can match all_envs = [ parse_stages.TaggedFrozen(name, env["tags"]) for name, env in tox_envs.items() ] # Or define our own class that may hold additional information @dataclasses.dataclass(frozen=True) class TestEnv(parse_stages.TaggedFrozen): ... all_envs = [TestEnv(name, env["tags"], ...) for name, env in tox_envs.items()] # Select the ones that match the "@check" expression matched = [env for env in all_envs if e_check.evaluate(env)] # Or if we only care about the names... quick_names = [env.name for env in all_envs if e_check_quick.evaluate(env)] """ from .defs import VERSION, Tagged, TaggedFrozen # isort: skip from .expr import AndExpr, BoolExpr, KeywordExpr, NotExpr, OrExpr, TagExpr # isort: skip from .p_pyp import EMPTY_SET_SPECS, parse_spec, parse_stage_ids # isort: skip __all__ = [ "EMPTY_SET_SPECS", "VERSION", "AndExpr", "BoolExpr", "KeywordExpr", "NotExpr", "OrExpr", "TagExpr", "Tagged", "TaggedFrozen", "parse_spec", "parse_stage_ids", ] parse_stages-0.1.9/src/parse_stages/defs.py0000644000000000000000000000223013615410400015660 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Common definitions for the parse-stages library.""" from __future__ import annotations import dataclasses @dataclasses.dataclass(frozen=True) class TaggedFrozen: """A base class for representing a constant object that has some tags.""" name: str """The name of the object, e.g. the name of the Tox test environment.""" tags: list[str] """The tags specified for this object.""" def get_keyword_haystacks(self) -> list[str]: """Get the strings to look for keywords in. Default: the object's `name` attribute. """ return [self.name] @dataclasses.dataclass class Tagged: """A base class for representing an object that has some tags.""" name: str """The name of the object, e.g. the name of the Tox test environment.""" tags: list[str] """The tags specified for this object.""" def get_keyword_haystacks(self) -> list[str]: """Get the strings to look for keywords in. Default: the object's `name` attribute. """ return [self.name] VERSION = "0.1.9" parse_stages-0.1.9/src/parse_stages/expr.py0000644000000000000000000000513313615410400015722 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """The hierarchy of classes representing an expression and its components.""" from __future__ import annotations import abc import dataclasses import typing if typing.TYPE_CHECKING: from . import defs @dataclasses.dataclass(frozen=True) class BoolExpr(abc.ABC): """A boolean expression parsed out of a specification string.""" @abc.abstractmethod def evaluate(self, obj: defs.TaggedFrozen | defs.Tagged) -> bool: """Evaluate the expression for the specified object.""" raise NotImplementedError @dataclasses.dataclass(frozen=True) class TagExpr(BoolExpr): """A tag to be checked against obj.tags.""" tag: str """The tag to be matched exactly against the object's list of tags.""" def evaluate(self, obj: defs.TaggedFrozen | defs.Tagged) -> bool: """Check whether the tag is present in the object's list of tags.""" return self.tag in obj.tags @dataclasses.dataclass(frozen=True) class KeywordExpr(BoolExpr): """A tag to be checked against an object's name or list of tags.""" keyword: str """The keyword to be matched as a substring of the object's keywords.""" def evaluate(self, obj: defs.TaggedFrozen | defs.Tagged) -> bool: """Check whether the tag is present in the object's list of tags.""" return any(self.keyword in item for item in obj.get_keyword_haystacks()) @dataclasses.dataclass(frozen=True) class NotExpr(BoolExpr): """A negated boolean expression.""" child: BoolExpr """The expression to be negated.""" def evaluate(self, obj: defs.TaggedFrozen | defs.Tagged) -> bool: """Check whether the specified expression does not hold true.""" return not self.child.evaluate(obj) @dataclasses.dataclass(frozen=True) class AndExpr(BoolExpr): """An "atom and atom [and atom...]" subexpression.""" children: list[BoolExpr] """The subexpressions to be combined.""" def evaluate(self, obj: defs.TaggedFrozen | defs.Tagged) -> bool: """Check whether all the specified expressions hold true.""" return all(child.evaluate(obj) for child in self.children) @dataclasses.dataclass(frozen=True) class OrExpr(BoolExpr): """An "subexpr or subexpr [or subexpr...]" subexpression.""" children: list[BoolExpr] """The subexpressions to be combined.""" def evaluate(self, obj: defs.TaggedFrozen | defs.Tagged) -> bool: """Check whether any of the specified expressions hold(s) true.""" return any(child.evaluate(obj) for child in self.children) parse_stages-0.1.9/src/parse_stages/p_pyp.py0000644000000000000000000001464113615410400016077 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Parse an expression using the `pyparsing` library.""" from __future__ import annotations import dataclasses import typing import pyparsing as pyp from . import expr if typing.TYPE_CHECKING: from typing import Any @dataclasses.dataclass class Error(Exception): """A base class for parse-related errors.""" @dataclasses.dataclass class ParseResultError(Error): """The pyparsing module returned an unexpected series of tokens.""" tokens: pyp.ParseResults """The unexpected sequence of tokens.""" def __str__(self) -> str: """Provide a human-readable representation of the error.""" return f"Unexpected sequence of parse tokens: {self.tokens!r}" @dataclasses.dataclass class ParseError(Error): """Our pyparsing handlers returned an unexpected object.""" res: Any """The unexpected object returned.""" def __str__(self) -> str: """Provide a human-readable representation of the error.""" return f"Unexpected parsed object: {self.res!r}" EMPTY_SET_SPECS = ["", "0", "none"] """The list of exact strings that `parse_stage_ids()` will return an empty list for.""" _p_or_expr = pyp.Forward() _p_ws = pyp.White()[...] _p_tag = pyp.Char("@").suppress() + pyp.Word(pyp.alphanums + "_-") _p_keyword = pyp.Word(pyp.alphanums + "_-") _p_bracketed = ( pyp.Char("(").suppress() + _p_ws.suppress() + _p_or_expr + _p_ws.suppress() + pyp.Char(")").suppress() ) _p_atom = _p_tag | _p_keyword | _p_bracketed _p_not_atom = pyp.Literal("not").suppress() + _p_ws.suppress() + _p_atom _p_and_expr = (_p_not_atom | _p_atom) + ( _p_ws.suppress() + pyp.Literal("and").suppress() + _p_ws.suppress() + (_p_not_atom | _p_atom) )[...] _p_or_expr <<= ( _p_and_expr + (_p_ws.suppress() + pyp.Literal("or").suppress() + _p_ws.suppress() + _p_and_expr)[...] ) _p_spec = _p_ws.suppress() + _p_or_expr + _p_ws.suppress() @_p_tag.set_parse_action def _parse_tag(tokens: pyp.ParseResults) -> expr.TagExpr: """Parse a tag name.""" match tokens: case [str(tag)]: return expr.TagExpr(tag=tag) case _: raise ParseResultError(tokens) @_p_keyword.set_parse_action def _parse_keyword(tokens: pyp.ParseResults) -> expr.KeywordExpr: """Parse a keyword.""" match tokens: case [str(keyword)]: return expr.KeywordExpr(keyword=keyword) case _: raise ParseResultError(tokens) @_p_atom.set_parse_action # type: ignore[misc] def _parse_atom(tokens: pyp.ParseResults) -> expr.BoolExpr: """Parse an atom (a tag or a keyword).""" match tokens: case [expr.TagExpr() | expr.KeywordExpr() | expr.OrExpr() as atom]: return atom case _: raise ParseResultError(tokens) @_p_not_atom.set_parse_action # type: ignore[misc] def _parse_not_atom(tokens: pyp.ParseResults) -> expr.NotExpr: """Parse a "not @tag" or "not keyword" element.""" match tokens: case [expr.BoolExpr() as child]: return expr.NotExpr(child=child) case _: raise ParseResultError(tokens) @_p_and_expr.set_parse_action # type: ignore[misc] def _parse_and_expr(tokens: pyp.ParseResults) -> expr.BoolExpr: """Parse a "atom [and atom...]" subexpression.""" children: list[expr.BoolExpr] = tokens.as_list() match children: case [expr.BoolExpr() as child]: return child case children if children and all(isinstance(item, expr.BoolExpr) for item in children): return expr.AndExpr(children=children) case _: raise ParseResultError(tokens) @_p_or_expr.set_parse_action def _parse_or_expr(tokens: pyp.ParseResults) -> expr.BoolExpr: """Parse a "subexpr [or subexpr...]" subexpression.""" children: list[expr.BoolExpr] = tokens.as_list() match children: case [expr.BoolExpr() as child]: return child case children if children and all(isinstance(item, expr.BoolExpr) for item in children): return expr.OrExpr(children=children) case _: raise ParseResultError(tokens) _p_complete = _p_spec.leave_whitespace() def parse_spec(spec: str) -> expr.BoolExpr: """Parse an expression using the `pyparsing` library.""" res = _p_complete.parse_string(spec, parse_all=True).as_list() match res: case [expr.BoolExpr() as spec]: return spec case _: raise ParseError(res) _p_stage_id = pyp.Word(pyp.srange("[1-9]"), pyp.srange("[0-9]")) _p_stage_range = _p_stage_id + pyp.Opt(pyp.Literal("-").suppress() + _p_stage_id) _p_stage_ids = _p_stage_range + (pyp.Literal(",").suppress() + _p_stage_range)[...] @_p_stage_id.set_parse_action def _parse_stage_id(tokens: pyp.ParseResults) -> int: """Parse a single stage ID, return it as a zero-based index.""" match tokens: case [str(stage_id)]: res = int(stage_id) - 1 if res < 0: raise ParseResultError(tokens) return res case _: raise ParseResultError(tokens) @_p_stage_range.set_parse_action def _parse_stage_range(tokens: pyp.ParseResults) -> list[int]: """Parse a range of stage IDs (possibly only containing a single one).""" match tokens: case [int(stage_id)]: return stage_id case [int(start), int(end)]: return list(range(start, end + 1)) case _: raise ParseResultError(tokens) _p_stage_ids_complete = _p_stage_ids.leave_whitespace() def parse_stage_ids(spec: str, *, empty_set_specs: list[str] | None = None) -> list[int]: """Parse a list of stage ranges, return them as zero-based indices. As a special case, the exact strings "" (an empty string), "0", and "none" will produce an empty list. Note that none of these strings are considered valid stage ranges, so they cannot be combined with any others (e.g. "0,3" is invalid). The default list of strings that signify an empty set ("", "0", "none") may be overridden by the `empty_set_specs` parameter. """ if empty_set_specs is None: empty_set_specs = EMPTY_SET_SPECS if spec in empty_set_specs: return [] res: list[int] = _p_stage_ids_complete.parse_string(spec, parse_all=True).as_list() if any(not isinstance(item, int) or item < 0 for item in res): raise ParseError(res) return res parse_stages-0.1.9/src/parse_stages/py.typed0000644000000000000000000000000013615410400016055 0ustar00parse_stages-0.1.9/tests/vetox.py0000644000000000000000000002201013615410400013775 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Create a virtual environment, install Tox, run it.""" from __future__ import annotations import argparse import configparser import dataclasses import functools import logging import os import pathlib import shlex import subprocess import sys import tempfile import typing import venv if typing.TYPE_CHECKING: from collections.abc import Callable from typing import Final VERSION: Final = "0.2.0" """The vetox library version.""" TOX_MIN_VERSION: Final = "4.1" """The minimum version of Tox needed to run our tests.""" @dataclasses.dataclass(frozen=True) class Config: """Runtime configuration for the venv-tox tool.""" conf: pathlib.Path """The path to the `tox.ini` file to use.""" env: dict[str, str] """The cleaned-up environment variables to pass to child processes.""" log: logging.Logger """The logger to send diagnostic, informational, warning, and error messages to.""" tempd: pathlib.Path """The temporary directory to operate in.""" tox_req: str | None """The PEP508 version requirements for Tox itself if specified.""" tox_uv: bool """Install `tox-uv` ito the virtual environment.""" uv: bool """Use `uv` to create the ephemeral Tox environment.""" # Shamelessly stolen from the logging-std module @functools.lru_cache def build_logger() -> logging.Logger: """Build a logger object, send info messages to stdout, everything else to stderr.""" logger: Final = logging.getLogger("vetox") logger.setLevel(logging.DEBUG) logger.propagate = False h_out: Final = logging.StreamHandler(sys.stdout) h_out.setLevel(logging.INFO) h_out.addFilter(lambda rec: rec.levelno == logging.INFO) logger.addHandler(h_out) h_err: Final = logging.StreamHandler(sys.stderr) h_err.setLevel(logging.INFO) h_err.addFilter(lambda rec: rec.levelno != logging.INFO) logger.addHandler(h_err) return logger def clean_env() -> dict[str, str]: """Clean up any variables related to the current virtual environment.""" return { name: value for name, value in os.environ.items() if not name.startswith(("PYTEST", "PYTHON", "TOX", "VIRTUAL_ENV")) } def create_and_update_uv_venv(cfg: Config, penv: pathlib.Path) -> pathlib.Path: """Use `uv` to create a virtual environment.""" cfg.log.info("About to create the %(penv)s virtual environment using uv", {"penv": penv}) subprocess.check_call(["uv", "venv", "-p", sys.executable, "--", penv], env=cfg.env) return penv def create_and_update_venv(cfg: Config) -> pathlib.Path: """Create a virtual environment, update all the packages within.""" penv: pathlib.Path = cfg.tempd / "venv" if cfg.uv: return create_and_update_uv_venv(cfg, penv) cfg.log.info("About to create the %(penv)s virtual environment", {"penv": penv}) cfg.log.info("- using venv.create(upgrade_deps) directly") venv.create(penv, with_pip=True, upgrade_deps=True) return penv @functools.lru_cache def get_tox_min_version(conf: pathlib.Path) -> str: """Look for a minimum Tox version in the tox.ini file, fall back to TOX_MIN_VERSION.""" cfgp: Final = configparser.ConfigParser(interpolation=None) with conf.open(encoding="UTF-8") as tox_ini: cfgp.read_file(tox_ini) return cfgp["tox"].get("min_version", cfgp["tox"].get("minversion", TOX_MIN_VERSION)) def install_tox(cfg: Config, penv: pathlib.Path) -> None: """Install Tox into the virtual environment.""" if cfg.tox_req is not None: tox_req = f"tox {cfg.tox_req}" else: minver: Final = get_tox_min_version(cfg.conf) tox_req = f"tox >= {minver}" tox_uv: Final = ["tox-uv"] if cfg.tox_uv else [] cfg.log.info( "Installing Tox %(tox_req)s%(tox_uv)s", {"tox_req": tox_req, "tox_uv": " and tox-uv" if cfg.tox_uv else ""}, ) if cfg.uv: subprocess.check_call( ["env", f"VIRTUAL_ENV={penv}", "uv", "pip", "install", tox_req, *tox_uv], env=cfg.env, ) else: subprocess.check_call( [penv / "bin/python3", "-m", "pip", "install", tox_req, *tox_uv], env=cfg.env, ) def get_tox_cmdline( cfg: Config, penv: pathlib.Path, *, parallel: bool = True, args: list[str], ) -> list[pathlib.Path | str]: """Get the Tox command with arguments.""" penv_py3: Final = penv / "bin/python3" cfg.log.info( "Running Tox%(parallel)s with %(args)s", { "parallel": " in parallel" if parallel else "", "args": ("additional arguments: " + shlex.join(args)) if args else "no additional arguments", }, ) run_cmd: Final = "run-parallel" if parallel else "run" return [penv_py3, "-m", "tox", "-c", cfg.conf, run_cmd, *args] def run_tox(cfg: Config, penv: pathlib.Path, *, parallel: bool = True, args: list[str]) -> None: """Run Tox from the virtual environment.""" subprocess.check_call(get_tox_cmdline(cfg, penv, parallel=parallel, args=args), env=cfg.env) def run(cfg_no_tempd: Config, *, parallel: bool, args: list[str]) -> None: """Create the virtual environment, install Tox, run it.""" with tempfile.TemporaryDirectory() as tempd_obj: cfg: Final = dataclasses.replace(cfg_no_tempd, tempd=pathlib.Path(tempd_obj)) penv: Final = create_and_update_venv(cfg) install_tox(cfg, penv) run_tox(cfg, penv, parallel=parallel, args=args) def cmd_run(cfg_no_tempd: Config, args: list[str]) -> None: """Run the Tox tests sequentially.""" run(cfg_no_tempd, parallel=False, args=args) def cmd_run_parallel(cfg_no_tempd: Config, args: list[str]) -> None: """Run the Tox tests in parallel.""" run(cfg_no_tempd, parallel=True, args=args) def cmd_features(_cfg_no_tempd: Config, _args: list[str]) -> None: """Display the list of features supported by the program.""" print(f"Features: vetox={VERSION} tox=1.0 tox-parallel=0.1 tox-uv=0.1 uv=0.1") def cmd_version(_cfg_no_tempd: Config, _args: list[str]) -> None: """Display the vetox version.""" print(f"vetox {VERSION}") def parse_args() -> tuple[Config, Callable[[Config, list[str]], None], list[str]]: """Parse the command-line arguments.""" parser: Final = argparse.ArgumentParser(prog="vetox") parser.add_argument( "-c", "--conf", type=pathlib.Path, default=pathlib.Path.cwd() / "tox.ini", help="The path to the tox.ini file", ) subp: Final = parser.add_subparsers() p_run: Final = subp.add_parser("run", help="Run tests sequentially") p_run.add_argument( "-t", "--tox-req", type=str, help="specify the PEP508 version requirement for Tox itself", ) p_run.add_argument( "--tox-uv", action="store_true", help="Install `tox-uv` into the virtual environment", ) p_run.add_argument( "--uv", action="store_true", help="Use `uv` to create the ephemeral virtual environment", ) p_run.add_argument("args", type=str, nargs="*", help="Additional arguments to pass to Tox") p_run.set_defaults(func=cmd_run) p_run_p: Final = subp.add_parser("run-parallel", help="Run tests in parallel") p_run_p.add_argument( "-t", "--tox-req", type=str, help="specify the PEP508 version requirement for Tox itself", ) p_run_p.add_argument( "--tox-uv", action="store_true", help="Install `tox-uv` into the virtual environment", ) p_run_p.add_argument( "--uv", action="store_true", help="Use `uv` to create the ephemeral virtual environment", ) p_run_p.add_argument("args", type=str, nargs="*", help="Additional arguments to pass to Tox") p_run_p.set_defaults(func=cmd_run_parallel) p_features: Final = subp.add_parser("features", help="Display the supported program features") p_features.set_defaults(func=cmd_features) p_version: Final = subp.add_parser("version", help="Display the vetox version") p_version.set_defaults(func=cmd_version) args: Final = parser.parse_args() func: Final[Callable[[Config, list[str]], None] | None] = getattr(args, "func", None) if func is None: sys.exit("No subcommand specified; use `--help` for a list") # Things would be a bit simpler with `click`, but we don't want any dependencies return ( Config( conf=args.conf, env=clean_env(), log=build_logger(), tempd=pathlib.Path("/nonexistent"), tox_req=getattr(args, "tox_req", False), # type: ignore[arg-type] tox_uv=getattr(args, "tox_uv", False), uv=getattr(args, "uv", False), ), func, getattr(args, "args", []), ) def main() -> None: """Parse command-line arguments, create a virtual environment, run Tox.""" cfg_no_tempd, func, args = parse_args() func(cfg_no_tempd, args) if __name__ == "__main__": main() parse_stages-0.1.9/tests/unit/__init__.py0000644000000000000000000000023013615410400015346 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Unit test suite for the parse-stages library.""" parse_stages-0.1.9/tests/unit/test_eval.py0000644000000000000000000000367313615410400015613 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Test the evaluation of some simple expressions.""" from __future__ import annotations import dataclasses from typing import NamedTuple import pytest import parse_stages as pst @dataclasses.dataclass(frozen=True) class Environment(pst.TaggedFrozen): """Specify an environment to be matched by a spec (or not).""" class Case(NamedTuple): """Specify a single test case: a string to parse, results.""" spec: str matched: list[str] _ALL = [ Environment(name="t-black", tags=["check"]), Environment(name="t-black-reformat", tags=["do", "reformat"]), Environment(name="t-pep8", tags=["check"]), Environment(name="t-mypy", tags=["check"]), Environment(name="t-pylint", tags=["check"]), Environment(name="t-unit-tests", tags=["tests"]), Environment(name="t-runner-pep8", tags=["check", "runner"]), ] _TESTS = [ Case(spec="@check", matched=["t-black", "t-pep8", "t-mypy", "t-pylint", "t-runner-pep8"]), Case(spec="@tests", matched=["t-unit-tests"]), Case(spec="@check and not pep8", matched=["t-black", "t-mypy", "t-pylint"]), Case(spec="not pep8 and @check", matched=["t-black", "t-mypy", "t-pylint"]), Case(spec="@check and pep8 or @tests", matched=["t-pep8", "t-unit-tests", "t-runner-pep8"]), Case(spec="black", matched=["t-black", "t-black-reformat"]), Case(spec="black and not black-reformat", matched=["t-black"]), Case(spec="black and not black-reformat", matched=["t-black"]), Case(spec="(black or @runner) and @check", matched=["t-black", "t-runner-pep8"]), Case(spec="not (@check or @tests)", matched=["t-black-reformat"]), ] @pytest.mark.parametrize("case", _TESTS) def test_basic(case: Case) -> None: """Make sure evaluation works more or less correctly.""" expr = pst.parse_spec(case.spec) assert [env.name for env in _ALL if expr.evaluate(env)] == case.matched parse_stages-0.1.9/tests/unit/test_parse.py0000644000000000000000000000600113615410400015762 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Test some basic parsing functionality.""" from __future__ import annotations from typing import NamedTuple import pytest import parse_stages as pst class Case(NamedTuple): """Specify a single test case: a string to parse, results.""" spec: str res: pst.BoolExpr class IdCase(NamedTuple): """Specify a single test case for stage IDs: a string to parse, results.""" spec: str res: list[int] empty_set_specs: list[str] | None _DEFS = [ Case(spec="@check", res=pst.TagExpr(tag="check")), Case(spec="pep8", res=pst.KeywordExpr(keyword="pep8")), Case(spec="not @check", res=pst.NotExpr(child=pst.TagExpr(tag="check"))), Case( spec="@check and not pep8", res=pst.AndExpr( children=[ pst.TagExpr(tag="check"), pst.NotExpr(child=pst.KeywordExpr(keyword="pep8")), ], ), ), Case( spec="not pep8 and @check or @tests or something", res=pst.OrExpr( children=[ pst.AndExpr( children=[ pst.NotExpr(child=pst.KeywordExpr(keyword="pep8")), pst.TagExpr(tag="check"), ], ), pst.TagExpr(tag="tests"), pst.KeywordExpr(keyword="something"), ], ), ), Case( spec="black and not black-reformat", res=pst.AndExpr( children=[ pst.KeywordExpr(keyword="black"), pst.NotExpr(child=pst.KeywordExpr(keyword="black-reformat")), ], ), ), Case( spec="not (@check or @docs) and @manual", res=pst.AndExpr( children=[ pst.NotExpr( child=pst.OrExpr(children=[pst.TagExpr(tag="check"), pst.TagExpr(tag="docs")]), ), pst.TagExpr(tag="manual"), ], ), ), ] _IDS = [ IdCase(spec="", res=[], empty_set_specs=None), IdCase(spec="0", res=[], empty_set_specs=None), IdCase(spec="none", res=[], empty_set_specs=None), IdCase(spec="empty", res=[], empty_set_specs=["", "empty"]), IdCase(spec="1", res=[0], empty_set_specs=None), IdCase(spec="2,6,4", res=[1, 5, 3], empty_set_specs=None), IdCase(spec="1-3,4-6", res=[0, 1, 2, 3, 4, 5], empty_set_specs=None), IdCase(spec="1-3,5-6", res=[0, 1, 2, 4, 5], empty_set_specs=None), IdCase(spec="1-3,7-10,4", res=[0, 1, 2, 6, 7, 8, 9, 3], empty_set_specs=None), ] @pytest.mark.parametrize("case", _DEFS) def test_basic(case: Case) -> None: """Make sure we parse a specification correctly.""" assert pst.parse_spec(case.spec) == case.res @pytest.mark.parametrize("case", _IDS) def test_ids_basic(case: IdCase) -> None: """Make sure we parse a set of stage IDs correctly.""" assert pst.parse_stage_ids(case.spec, empty_set_specs=case.empty_set_specs) == case.res parse_stages-0.1.9/.gitignore0000644000000000000000000000021713615410400013111 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause site/ .mypy_cache/ .tox/ **/__pycache__/ parse_stages-0.1.9/README.md0000644000000000000000000001071113615410400012400 0ustar00 # Parse a mini-language for selecting objects by tag or name \[[Home][ringlet-parse-stages] | [GitLab][gitlab] | [PyPI][pypi] | [ReadTheDocs][readthedocs]\] This library is mostly useful for command-line parsing by other tools like `tox-stages` and `nox-stages`. It may be used to parse e.g. a command-line specification like `@check and not pylint` or `@tests or ruff` and then match it against a list of objects that have names and lists of tags. ## Parse stage specifications The `parse_spec()` function parses a string specification into a `BoolExpr` object that may later be used to select matching objects (e.g. test environments). The specification mini-language may roughly be described as: expr ::= and_expr ["or" and_expr...] and_expr ::= not_expr ["and" not_expr...] not_expr ::= ["not"] atom atom ::= tag | keyword tag ::= "@" characters keyword ::= characters characters ::= [A-Za-z0-9_-]+ Thus, all of the following: - `@check` - `@check and @quick` - `@tests and not examples` - `not @tests` - `pep8 or not @quick and @check` ...are valid expressions, with the "not", "and", and "or" keywords having their usual precedence (`pep8 or not @quick and @check` is parsed as `pep8 or ((@not quick) and @check)`). ## Check whether an object matches a parsed specification The `parse-stages` library provides two base dataclasses for objects that may be matched against parsed expressions: `TaggedFrozen` and `Tagged`. Both classes have the same members: - `name`: a string - `tags`: a list of strings - `get_keyword_haystacks()`: a method that returns a list of strings, `[self.name]` unless overridden When a `BoolExpr` object's `evaluate()` method is called for a specific `TaggedFrozen` or `Tagged` object, it checks whether the specification matches the tags and keywords defined for this object. Tags are matched exactly, while a keyword is considered to match if it is contained in the checked string; e.g. `pep` would match both `pep8` and `exp_pep563`, while `@black` would not match a `black-reformat` tag. The `get_keyword_haystacks()` method returns the strings to look in for matching keywords. By default, it only returns the `name` field; however, it may be extended, e.g. for Nox sessions it may also return the name of the Python function that implements the session, for test classes with methods it may return the class name and the method name, etc. ## Examples Parse a list of stage specifications into expressions that may later be matched against test environment definitions: e_check = parse_stages.parse_spec("@check") e_check_quick = parse_stages.parse_spec("@check and @quick") e_check_no_ruff = parse_stages.parse_spec("@check and not ruff") specs = [(spec, parse_stages.parse_spec(spec)) for spec in args.stage_specs] Select the test environments that match the specification: # Obtain a list (okay, a dictionary) of test environments in some way tox_envs = get_tox_environments() # {"ruff": {"tags": ["check", "quick"]}, ...} # Convert them to objects that the parsed expressions can match all_envs = [ parse_stages.TaggedFrozen(name, env["tags"]) for name, env in tox_envs.items() ] # Or define our own class that may hold additional information @dataclasses.dataclass(frozen=True) class TestEnv(parse_stages.TaggedFrozen): """A single test environment: name, tags, etc.""" ... all_envs = [TestEnv(name, env["tags"], ...) for name, env in tox_envs.items()] # Select the ones that match the "@check" expression matched = [env for env in all_envs if e_check.evaluate(env)] # Or if we only care about the names... quick_names = [env.name for env in all_envs if e_check_quick.evaluate(env)] ## Contact The `parse-stages` library was written by [Peter Pentchev][roam]. It is developed in [a GitLab repository][gitlab]. This documentation is hosted at [Ringlet][ringlet-parse-stages] with a copy at [ReadTheDocs][readthedocs]. [roam]: mailto:roam@ringlet.net "Peter Pentchev" [gitlab]: https://gitlab.com/ppentchev/parse-stages "The parse-stages GitLab repository" [pypi]: https://pypi.org/project/parse-stages/ "The parse-stages Python Package Index page" [readthedocs]: https://parse-stages.readthedocs.io/ "The parse-stages ReadTheDocs page" [ringlet-parse-stages]: https://devel.ringlet.net/devel/parse-stages/ "The Ringlet parse-stages homepage" parse_stages-0.1.9/pyproject.toml0000644000000000000000000000402113615410400014032 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause [build-system] requires = [ "hatchling >= 1.8, < 2", "hatch-requirements-txt >= 0.3, < 0.5", ] build-backend = "hatchling.build" [project] name = "parse_stages" description = "Parse an expression for selecting stages and tags" readme = "README.md" license = {text = "BSD-2-Clause"} requires-python = ">= 3.10" dynamic = ["dependencies", "version"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: DFSG approved", "License :: Freely Distributable", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Testing", "Topic :: Software Development :: Testing :: Unit", "Typing :: Typed", ] [[project.authors]] name = "Peter Pentchev" email = "roam@ringlet.net" [project.urls] Homepage = "https://devel.ringlet.net/devel/parse-stages/" Changes = "https://devel.ringlet.net/devel/parse-stages/changes/" "Issue Tracker" = "https://gitlab.com/ppentchev/parse-stages/-/issues" "Source Code" = "https://gitlab.com/ppentchev/parse-stages" [tool.hatch.build.targets.wheel] packages = ["src/parse_stages"] [tool.hatch.metadata.hooks.requirements_txt] files = ["requirements/install.txt"] [tool.hatch.version] path = "src/parse_stages/defs.py" [tool.mypy] strict = true [tool.ruff] extend = "ruff-base.toml" output-format = "concise" preview = true [tool.ruff.lint] select = ["ALL"] [tool.test-stages] stages = [ "@check and @quick and not @manual", "@check and not @manual", "@tests and not @manual", ] parse_stages-0.1.9/PKG-INFO0000644000000000000000000001361213615410400012221 0ustar00Metadata-Version: 2.3 Name: parse_stages Version: 0.1.9 Summary: Parse an expression for selecting stages and tags Project-URL: Homepage, https://devel.ringlet.net/devel/parse-stages/ Project-URL: Changes, https://devel.ringlet.net/devel/parse-stages/changes/ Project-URL: Issue Tracker, https://gitlab.com/ppentchev/parse-stages/-/issues Project-URL: Source Code, https://gitlab.com/ppentchev/parse-stages Author-email: Peter Pentchev License: BSD-2-Clause Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: License :: DFSG approved Classifier: License :: Freely Distributable Classifier: License :: OSI Approved :: BSD License Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Topic :: Software Development :: Libraries Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Topic :: Software Development :: Testing Classifier: Topic :: Software Development :: Testing :: Unit Classifier: Typing :: Typed Requires-Python: >=3.10 Requires-Dist: pyparsing<4,>=3 Description-Content-Type: text/markdown # Parse a mini-language for selecting objects by tag or name \[[Home][ringlet-parse-stages] | [GitLab][gitlab] | [PyPI][pypi] | [ReadTheDocs][readthedocs]\] This library is mostly useful for command-line parsing by other tools like `tox-stages` and `nox-stages`. It may be used to parse e.g. a command-line specification like `@check and not pylint` or `@tests or ruff` and then match it against a list of objects that have names and lists of tags. ## Parse stage specifications The `parse_spec()` function parses a string specification into a `BoolExpr` object that may later be used to select matching objects (e.g. test environments). The specification mini-language may roughly be described as: expr ::= and_expr ["or" and_expr...] and_expr ::= not_expr ["and" not_expr...] not_expr ::= ["not"] atom atom ::= tag | keyword tag ::= "@" characters keyword ::= characters characters ::= [A-Za-z0-9_-]+ Thus, all of the following: - `@check` - `@check and @quick` - `@tests and not examples` - `not @tests` - `pep8 or not @quick and @check` ...are valid expressions, with the "not", "and", and "or" keywords having their usual precedence (`pep8 or not @quick and @check` is parsed as `pep8 or ((@not quick) and @check)`). ## Check whether an object matches a parsed specification The `parse-stages` library provides two base dataclasses for objects that may be matched against parsed expressions: `TaggedFrozen` and `Tagged`. Both classes have the same members: - `name`: a string - `tags`: a list of strings - `get_keyword_haystacks()`: a method that returns a list of strings, `[self.name]` unless overridden When a `BoolExpr` object's `evaluate()` method is called for a specific `TaggedFrozen` or `Tagged` object, it checks whether the specification matches the tags and keywords defined for this object. Tags are matched exactly, while a keyword is considered to match if it is contained in the checked string; e.g. `pep` would match both `pep8` and `exp_pep563`, while `@black` would not match a `black-reformat` tag. The `get_keyword_haystacks()` method returns the strings to look in for matching keywords. By default, it only returns the `name` field; however, it may be extended, e.g. for Nox sessions it may also return the name of the Python function that implements the session, for test classes with methods it may return the class name and the method name, etc. ## Examples Parse a list of stage specifications into expressions that may later be matched against test environment definitions: e_check = parse_stages.parse_spec("@check") e_check_quick = parse_stages.parse_spec("@check and @quick") e_check_no_ruff = parse_stages.parse_spec("@check and not ruff") specs = [(spec, parse_stages.parse_spec(spec)) for spec in args.stage_specs] Select the test environments that match the specification: # Obtain a list (okay, a dictionary) of test environments in some way tox_envs = get_tox_environments() # {"ruff": {"tags": ["check", "quick"]}, ...} # Convert them to objects that the parsed expressions can match all_envs = [ parse_stages.TaggedFrozen(name, env["tags"]) for name, env in tox_envs.items() ] # Or define our own class that may hold additional information @dataclasses.dataclass(frozen=True) class TestEnv(parse_stages.TaggedFrozen): """A single test environment: name, tags, etc.""" ... all_envs = [TestEnv(name, env["tags"], ...) for name, env in tox_envs.items()] # Select the ones that match the "@check" expression matched = [env for env in all_envs if e_check.evaluate(env)] # Or if we only care about the names... quick_names = [env.name for env in all_envs if e_check_quick.evaluate(env)] ## Contact The `parse-stages` library was written by [Peter Pentchev][roam]. It is developed in [a GitLab repository][gitlab]. This documentation is hosted at [Ringlet][ringlet-parse-stages] with a copy at [ReadTheDocs][readthedocs]. [roam]: mailto:roam@ringlet.net "Peter Pentchev" [gitlab]: https://gitlab.com/ppentchev/parse-stages "The parse-stages GitLab repository" [pypi]: https://pypi.org/project/parse-stages/ "The parse-stages Python Package Index page" [readthedocs]: https://parse-stages.readthedocs.io/ "The parse-stages ReadTheDocs page" [ringlet-parse-stages]: https://devel.ringlet.net/devel/parse-stages/ "The Ringlet parse-stages homepage"