parse_stages-0.1.8/.editorconfig0000644000000000000000000000115613615410400013600 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.8/.readthedocs.yaml0000644000000000000000000000043213615410400014346 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.8/mkdocs.yml0000644000000000000000000000240313615410400013122 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.8/tox.ini0000644000000000000000000000336713615410400012444 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 --config config/ruff-all/pyproject.toml -- {[defs]pyfiles} [testenv:format] skip_install = True tags = check quick deps = -r requirements/ruff.txt commands = ruff check --config config/ruff-base/pyproject.toml --select=D,I --diff -- {[defs]pyfiles} ruff format --check --config config/ruff-base/pyproject.toml --diff -- {[defs]pyfiles} [testenv:reformat] skip_install = True tags = format manual deps = -r requirements/ruff.txt commands = ruff check --config config/ruff-base/pyproject.toml --select=D,I --fix -- {[defs]pyfiles} ruff format --config config/ruff-base/pyproject.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 --py38-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 >= 2, < 3 commands = reuse lint parse_stages-0.1.8/LICENSES/BSD-2-Clause.txt0000644000000000000000000000236313615410400015053 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.8/config/ruff-all/pyproject.toml0000644000000000000000000000031213615410400017005 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause [tool.ruff] extend = "../ruff-base/pyproject.toml" preview = true [tool.ruff.lint] select = ["ALL"] parse_stages-0.1.8/config/ruff-base/pyproject.toml0000644000000000000000000000167513615410400017164 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause [tool.ruff] target-version = "py38" line-length = 100 [tool.ruff.lint] select = [] ignore = [ # We know what "self" is... I hope "ANN101", # We leave most of the formatting to the `black` tool "COM812", # No blank lines before the class docstring, TYVM "D203", # The multi-line docstring summary starts on the same line "D213", # The Tagged and TaggedFrozen classes need to be typedload-compatible "TCH", ] [tool.ruff.lint.flake8-copyright] notice-rgx = "(?x) SPDX-FileCopyrightText: \\s \\S" [tool.ruff.lint.isort] force-single-line = true known-first-party = ["parse_stages"] lines-after-imports = 2 single-line-exclusions = ["typing"] [tool.ruff.lint.per-file-ignores] # typedload needs to be able to parse these even on Python 3.8 "src/parse_stages/defs.py" = ["UP006"] # This is a test suite "tests/unit/**.py" = ["S101"] parse_stages-0.1.8/docs/api.md0000644000000000000000000000106213615410400013142 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.8/docs/changes.md0000644000000000000000000002361213615410400014006 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.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.8...main [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.8/docs/download.md0000644000000000000000000000246713615410400014212 0ustar00 # Download These are the released versions of [parse-stages](index.md) available for download. ## [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.7]: https://gitlab.com/ppentchev/parse-stages/-/tags/release%2F0.1.7 parse_stages-0.1.8/docs/index.md0000644000000000000000000001064513615410400013507 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.8, < 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.8/docs/language.md0000644000000000000000000000153113615410400014155 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.8/nix/cleanpy.sh0000755000000000000000000000075713615410400013721 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.8/nix/mkdocs.nix0000644000000000000000000000100513615410400013712 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause { pkgs ? import { } , py-ver ? 311 }: let python-name = "python${toString 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 mkdocs build exit ''; } parse_stages-0.1.8/nix/python-pytest.nix0000644000000000000000000000072613615410400015312 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause { pkgs ? import { } , py-ver ? 311 }: let python-name = "python${toString 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 -m pytest -v tests/unit exit ''; } parse_stages-0.1.8/nix/python-tox.nix0000644000000000000000000000067413615410400014576 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause { pkgs ? import { } , py-ver ? 311 }: let python-name = "python${toString 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 tox run-parallel exit ''; } parse_stages-0.1.8/nix/python-vetox.nix0000644000000000000000000000062013615410400015120 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause { pkgs ? import { } , py-ver ? 311 }: let python-name = "python${toString py-ver}"; python = builtins.getAttr python-name pkgs; in pkgs.mkShell { buildInputs = [ pkgs.gitMinimal python ]; shellHook = '' set -e python3 tests/vetox.py run-parallel exit ''; } parse_stages-0.1.8/nix/reformat.sh0000755000000000000000000000041213615410400014071 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.8/nix/run-pytest.sh0000755000000000000000000000066413615410400014415 0ustar00#!/bin/sh # # SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause set -e : "${PY_MINVER_MIN:=8}" : "${PY_MINVER_MAX:=12}" for minor in $(seq -- "$PY_MINVER_MIN" "$PY_MINVER_MAX"); do pyver="3$minor" nix/cleanpy.sh printf -- '\n===== Running tests for %s\n\n\n' "$pyver" nix-shell --pure --arg py-ver "$pyver" nix/python-pytest.nix printf -- '\n===== Done with %s\n\n' "$pyver" done parse_stages-0.1.8/nix/run-tox.sh0000755000000000000000000000103313615410400013666 0ustar00#!/bin/sh # # SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause set -e # backports.zoneinfo is broken for 3.8 in nixpkgs-unstable : "${PY_MINVER_MIN:=9}" # cffi is broken for 3.12 in nixpkgs-unstable : "${PY_MINVER_MAX:=11}" for minor in $(seq -- "$PY_MINVER_MIN" "$PY_MINVER_MAX"); do pyver="3$minor" nix/cleanpy.sh printf -- '\n===== Running tests for %s\n\n\n' "$pyver" nix-shell --pure --arg py-ver "$pyver" nix/python-tox.nix printf -- '\n===== Done with %s\n\n' "$pyver" done parse_stages-0.1.8/nix/run-vetox.sh0000755000000000000000000000066313615410400014231 0ustar00#!/bin/sh # # SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause set -e : "${PY_MINVER_MIN:=8}" : "${PY_MINVER_MAX:=13}" for minor in $(seq -- "$PY_MINVER_MIN" "$PY_MINVER_MAX"); do pyver="3$minor" nix/cleanpy.sh printf -- '\n===== Running tests for %s\n\n\n' "$pyver" nix-shell --pure --arg py-ver "$pyver" nix/python-vetox.nix printf -- '\n===== Done with %s\n\n' "$pyver" done parse_stages-0.1.8/requirements/docs.txt0000644000000000000000000000032413615410400015333 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause mkdocs >= 1.4.2, < 2 mkdocs-material >= 9.1.2, < 10 mkdocstrings >= 0.23, < 0.24 mkdocstrings-python >= 1, < 2 parse_stages-0.1.8/requirements/install.txt0000644000000000000000000000017113615410400016051 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause pyparsing >= 3, < 4 parse_stages-0.1.8/requirements/ruff.txt0000644000000000000000000000016313615410400015346 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause ruff == 0.2.1 parse_stages-0.1.8/requirements/test.txt0000644000000000000000000000021313615410400015357 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause pygments >= 2.7, < 3 pytest >= 8, < 9 parse_stages-0.1.8/src/parse_stages/__init__.py0000644000000000000000000001025513615410400016503 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.8/src/parse_stages/defs.py0000644000000000000000000000226013615410400015662 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Common definitions for the parse-stages library.""" from __future__ import annotations import dataclasses from typing import List @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.8" parse_stages-0.1.8/src/parse_stages/expr.py0000644000000000000000000000505713615410400015726 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 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.8/src/parse_stages/p_pyp.py0000644000000000000000000001465213615410400016100 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.""" if len(tokens) != 1 or not isinstance(tokens[0], str): raise ParseResultError(tokens) return expr.TagExpr(tag=tokens[0]) @_p_keyword.set_parse_action def _parse_keyword(tokens: pyp.ParseResults) -> expr.KeywordExpr: """Parse a keyword.""" if len(tokens) != 1 or not isinstance(tokens[0], str): raise ParseResultError(tokens) return expr.KeywordExpr(keyword=tokens[0]) @_p_atom.set_parse_action # type: ignore[misc] def _parse_atom(tokens: pyp.ParseResults) -> expr.BoolExpr: """Parse an atom (a tag or a keyword).""" if len(tokens) != 1 or not isinstance(tokens[0], (expr.TagExpr, expr.KeywordExpr, expr.OrExpr)): raise ParseResultError(tokens) return tokens[0] @_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.""" if len(tokens) != 1 or not isinstance(tokens[0], expr.BoolExpr): raise ParseResultError(tokens) return expr.NotExpr(child=tokens[0]) @_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() if not children or any(not isinstance(item, expr.BoolExpr) for item in children): raise ParseResultError(tokens) if len(children) == 1: return children[0] return expr.AndExpr(children=children) @_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() if not children or any(not isinstance(item, expr.BoolExpr) for item in children): raise ParseResultError(tokens) if len(children) == 1: return children[0] return expr.OrExpr(children=children) _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() if len(res) != 1 or not isinstance(res[0], expr.BoolExpr): raise ParseError(res) return res[0] _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.""" if len(tokens) != 1 or not isinstance(tokens[0], str): raise ParseResultError(tokens) res = int(tokens[0]) - 1 if res < 0: raise ParseResultError(tokens) return res @_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).""" if len(tokens) == 1: if not isinstance(tokens[0], int): raise ParseResultError(tokens) return [tokens[0]] # The magic value will go away once we can use Python 3.10 structural matching if ( len(tokens) != 2 # noqa: PLR2004 or not isinstance(tokens[0], int) or not isinstance(tokens[1], int) or tokens[0] >= tokens[1] ): raise ParseResultError(tokens) return list(range(tokens[0], tokens[1] + 1)) _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) and item >= 0 for item in res): raise ParseError(res) return res parse_stages-0.1.8/src/parse_stages/py.typed0000644000000000000000000000000013615410400016054 0ustar00parse_stages-0.1.8/tests/vetox.py0000644000000000000000000001642713615410400014013 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 json import logging 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.1.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.""" log: logging.Logger """The logger to send diagnostic, informational, warning, and error messages to.""" tempd: pathlib.Path """The temporary directory to operate in.""" # 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("logging-std") 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 create_and_update_venv(cfg: Config) -> pathlib.Path: """Create a virtual environment, update all the packages within.""" penv: pathlib.Path = cfg.tempd / "venv" cfg.log.info("About to create the %(penv)s virtual environment", {"penv": penv}) if sys.version_info >= (3, 9): cfg.log.info("- using venv.create(upgrade_deps) directly") venv.create(penv, with_pip=True, upgrade_deps=True) return penv cfg.log.info("- no venv.create(upgrade_deps)") venv.create(penv, with_pip=True) cfg.log.info("- obtaining the list of packages in the virtual environment") contents: Final = subprocess.check_output( [penv / "bin/python3", "-m", "pip", "list", "--format=json"], encoding="UTF-8", ) pkgs: Final = json.loads(contents) if ( not isinstance(pkgs, list) or not pkgs or not all(isinstance(pkg, dict) and "name" in pkg for pkg in pkgs) ): sys.exit(f"Unexpected `pip list --format=json` output: {pkgs!r}") names: Final = sorted(pkg["name"] for pkg in pkgs) cfg.log.info( "- upgrading the %(names)s package%(plu)s in the virtual environment", {"names": ", ".join(names), "plu": "" if len(names) == 1 else "s"}, ) subprocess.check_call([penv / "bin/python3", "-m", "pip", "install", "-U", "--", *names]) return penv @functools.lru_cache def get_tox_min_version(cfg: Config) -> 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 cfg.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.""" minver: Final = get_tox_min_version(cfg) cfg.log.info("Installing Tox >= %(minver)s", {"minver": minver}) subprocess.check_call([penv / "bin/python3", "-m", "pip", "install", f"tox >= {minver}"]) 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.""" 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", }, ) return [ penv / "bin/python3", "-m", "tox", "-c", cfg.conf, "run-parallel" if parallel else "run", *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)) 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=0.1 tox-parallel=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("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("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") return ( Config(conf=args.conf, log=build_logger(), tempd=pathlib.Path("/nonexistent")), 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.8/tests/unit/__init__.py0000644000000000000000000000023013615410400015345 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause """Unit test suite for the parse-stages library.""" parse_stages-0.1.8/tests/unit/test_eval.py0000644000000000000000000000367313615410400015612 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.8/tests/unit/test_parse.py0000644000000000000000000000577313615410400016000 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.8/.gitignore0000644000000000000000000000021713615410400013110 0ustar00# SPDX-FileCopyrightText: Peter Pentchev # SPDX-License-Identifier: BSD-2-Clause site/ .mypy_cache/ .tox/ **/__pycache__/ parse_stages-0.1.8/README.md0000644000000000000000000001071113615410400012377 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.8/pyproject.toml0000644000000000000000000000376313615410400014045 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.8" 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.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "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.test-stages] stages = [ "@check and @quick and not @manual", "@check and not @manual", "@tests and not @manual", ] parse_stages-0.1.8/PKG-INFO0000644000000000000000000001375513615410400012230 0ustar00Metadata-Version: 2.1 Name: parse_stages Version: 0.1.8 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.8 Classifier: Programming Language :: Python :: 3.9 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.8 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"