├── .coveragerc ├── .editorconfig ├── .envrc ├── .envrc.use_venv ├── .github └── workflows │ ├── release-to-pypi.yml │ ├── test-pypy27.yml │ └── test.yml ├── .gitignore ├── .repos ├── CHANGES.txt ├── LICENSE ├── MANIFEST.in ├── README.rst ├── SECURITY.md ├── bin ├── github-workflow.json_schema ├── github-workflow_check.py ├── make_localpi.py ├── project_bootstrap.sh └── toxcmd.py ├── invoke.yaml ├── justfile ├── parse_type ├── __init__.py ├── builder.py ├── cardinality.py ├── cardinality_field.py ├── cfparse.py ├── parse.py └── parse_util.py ├── py.requirements ├── all.txt ├── basic.txt ├── ci.github.testing.txt ├── develop.txt ├── docs.txt ├── optional.txt ├── packaging.txt └── testing.txt ├── pyproject.toml ├── pytest.ini ├── setup.cfg ├── setup.py ├── tasks ├── __init__.py ├── _compat_shutil.py ├── docs.py ├── invoke_dry_run.py ├── py.requirements.txt ├── release.py └── test.py ├── tests ├── __init__.py ├── parse_tests │ ├── __init__.py │ ├── test_bugs.py │ ├── test_findall.py │ ├── test_parse.py │ ├── test_parsetype.py │ ├── test_pattern.py │ ├── test_result.py │ └── test_search.py ├── parse_tests_with_parse_type │ ├── __init__.py │ ├── test_bugs.py │ ├── test_findall.py │ ├── test_parse.py │ ├── test_parsetype.py │ ├── test_pattern.py │ ├── test_result.py │ └── test_search.py ├── parse_type_test.py ├── test_builder.py ├── test_cardinality.py ├── test_cardinality_field.py ├── test_cardinality_field0.py ├── test_cfparse.py ├── test_parse_decorator.py ├── test_parse_number.py └── test_parse_util.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | # ========================================================================= 2 | # COVERAGE CONFIGURATION FILE: .coveragerc 3 | # ========================================================================= 4 | # LANGUAGE: Python 5 | # SEE ALSO: 6 | # * http://nedbatchelder.com/code/coverage/ 7 | # * http://nedbatchelder.com/code/coverage/config.html 8 | # ========================================================================= 9 | 10 | [run] 11 | # data_file = .coverage 12 | source = parse_type 13 | branch = True 14 | parallel = True 15 | omit = mock.py, ez_setup.py, distribute.py 16 | 17 | 18 | [report] 19 | ignore_errors = True 20 | show_missing = True 21 | # Regexes for lines to exclude from consideration 22 | exclude_lines = 23 | # Have to re-enable the standard pragma 24 | pragma: no cover 25 | 26 | # Don't complain about missing debug-only code: 27 | def __repr__ 28 | if self\.debug 29 | 30 | # Don't complain if tests don't hit defensive assertion code: 31 | raise AssertionError 32 | raise NotImplementedError 33 | 34 | # Don't complain if non-runnable code isn't run: 35 | if 0: 36 | if False: 37 | if __name__ == .__main__.: 38 | 39 | 40 | [html] 41 | directory = build/coverage.html 42 | title = Coverage Report: parse_type 43 | 44 | [xml] 45 | output = build/coverage.xml 46 | 47 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # ============================================================================= 2 | # EDITOR CONFIGURATION: http://editorconfig.org 3 | # ============================================================================= 4 | 5 | root = true 6 | 7 | # -- DEFAULT: Unix-style newlines with a newline ending every file. 8 | [*] 9 | charset = utf-8 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.{py,rst,ini,txt}] 15 | indent_style = space 16 | indent_size = 4 17 | 18 | [*.feature] 19 | indent_style = space 20 | indent_size = 2 21 | 22 | [**/makefile] 23 | indent_style = tab 24 | 25 | [*.{cmd,bat}] 26 | end_of_line = crlf 27 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | # =========================================================================== 2 | # PROJECT ENVIRONMENT SETUP: parse_type/.envrc 3 | # =========================================================================== 4 | # SHELL: bash (or similiar) 5 | # USAGE: 6 | # # -- BETTER: Use direnv (requires: Setup in bash -- $HOME/.bashrc) 7 | # # BASH PROFILE NEEDS: eval "$(direnv hook bash)" 8 | # direnv allow . 9 | # 10 | # SIMPLISTIC ALTERNATIVE (without cleanup when directory scope is left again): 11 | # source .envrc 12 | # 13 | # SEE ALSO: 14 | # * https://direnv.net/ 15 | # * https://peps.python.org/pep-0582/ Python local packages directory 16 | # =========================================================================== 17 | # MAYBE: HERE="${PWD}" 18 | 19 | # -- USE OPTIONAL PARTS (if exist/enabled): 20 | # DISABLED: dotenv_if_exists .env 21 | source_env_if_exists .envrc.use_venv 22 | 23 | # -- SETUP-PYTHON: Prepend ${HERE} to PYTHONPATH (as PRIMARY search path) 24 | # SIMILAR TO: export PYTHONPATH="${HERE}:${PYTHONPATH}" 25 | path_add PYTHONPATH . 26 | path_add PATH bin 27 | 28 | # DISABLED: source_env_if_exists .envrc.override 29 | -------------------------------------------------------------------------------- /.envrc.use_venv: -------------------------------------------------------------------------------- 1 | # =========================================================================== 2 | # PROJECT ENVIRONMENT SETUP: parse_type/.envrc.use_venv 3 | # =========================================================================== 4 | # DESCRIPTION: 5 | # Setup and use a Python virtual environment (venv). 6 | # On entering the directory: Creates and activates a venv for a python version. 7 | # On leaving the directory: Deactivates the venv (virtual environment). 8 | # 9 | # SEE ALSO: 10 | # * https://direnv.net/ 11 | # * https://github.com/direnv/direnv/wiki/Python 12 | # * https://direnv.net/man/direnv-stdlib.1.html#codelayout-python-ltpythonexegtcode 13 | # =========================================================================== 14 | 15 | # -- VIRTUAL ENVIRONMENT SUPPORT: layout python python3 16 | # VENV LOCATION: .direnv/python-$(PYTHON_VERSION) 17 | layout python python3 18 | -------------------------------------------------------------------------------- /.github/workflows/release-to-pypi.yml: -------------------------------------------------------------------------------- 1 | # -- WORKFLOW: Publish/release this package on PyPI 2 | # SEE: 3 | # * https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 4 | # * https://docs.github.com/en/actions/use-cases-and-examples/building-and-testing/building-and-testing-python#publishing-to-pypi 5 | # 6 | # * https://docs.github.com/en/actions/writing-workflows 7 | # * https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs 8 | # * https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#release 9 | # 10 | # GITHUB ACTIONS: 11 | # * https://github.com/actions/checkout 12 | # * https://github.com/pypa/gh-action-pypi-publish 13 | # 14 | # RELATED: 15 | # * https://github.com/actions/starter-workflows/blob/main/ci/python-publish.yml 16 | 17 | # -- STATE: PREPARED_ONLY, NOT_RELEASED_YET 18 | name: release-to-pypi 19 | on: 20 | release: 21 | types: [published] 22 | tags: 23 | - v0.* 24 | - v1.* 25 | permissions: 26 | contents: read 27 | 28 | jobs: 29 | publish-package: 30 | runs-on: ubuntu-latest 31 | if: ${{ startsWith(github.ref, 'refs/tags/v') }} 32 | environment: 33 | name: pypi 34 | url: https://pypi.org/p/parse-type 35 | permissions: 36 | id-token: write # REQUIRED-FOR: Trusted publishing. 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: actions/setup-python@v5 40 | with: 41 | python-version: "3.10" 42 | - name: "Install Python package dependencies (with: uv)" 43 | run: | 44 | python -m pip install -U uv 45 | python -m uv pip install -U pip setuptools wheel build twine 46 | - name: Build this package 47 | run: python -m build 48 | - name: Check this package (before upload) 49 | run: twine check dist/* 50 | - name: Upload this package to PyPI 51 | uses: pypa/gh-action-pypi-publish@release/v1 52 | with: 53 | print-hash: true 54 | verbose: true 55 | -------------------------------------------------------------------------------- /.github/workflows/test-pypy27.yml: -------------------------------------------------------------------------------- 1 | # -- TEST-VARIANT: pypy-27 on ubuntu-latest 2 | # BASED ON: test.yml 3 | # DESCRIPTION: Checks for Python 2.7 support and any problems 4 | 5 | name: test-pypy27 6 | on: 7 | workflow_dispatch: 8 | push: 9 | branches: [ "main", "release/**" ] 10 | pull_request: 11 | types: [opened, reopened, review_requested] 12 | branches: [ "main" ] 13 | 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: ["pypy-2.7"] 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | cache: 'pip' 27 | cache-dependency-path: 'py.requirements/*.txt' 28 | 29 | - name: Install Python package dependencies 30 | run: | 31 | python -m pip install -U pip setuptools wheel 32 | pip install --upgrade -r py.requirements/ci.github.testing.txt 33 | pip install -e . 34 | - name: Run tests 35 | run: pytest 36 | - name: Upload test reports 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: test reports 40 | path: | 41 | build/testing/report.xml 42 | build/testing/report.html 43 | if: ${{ job.status == 'failure' }} 44 | # MAYBE: if: ${{ always() }} 45 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # -- SOURCE: https://github.com/marketplace/actions/setup-python 2 | # SEE: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | # SUPPORTED PYTHON VERSIONS: https://github.com/actions/python-versions 4 | 5 | name: test 6 | on: 7 | workflow_dispatch: 8 | push: 9 | branches: [ "main", "release/**" ] 10 | pull_request: 11 | types: [opened, reopened, review_requested] 12 | branches: [ "main" ] 13 | 14 | jobs: 15 | test: 16 | # -- EXAMPLE: runs-on: ubuntu-latest 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | # PREPARED: os: [ubuntu-latest, macos-latest, windows-latest] 22 | os: [ubuntu-latest, windows-latest] 23 | python-version: ["3.13.0-rc.3", "3.12", "3.11", "3.10"] 24 | exclude: 25 | - os: windows-latest 26 | python-version: "2.7" 27 | steps: 28 | - uses: actions/checkout@v4 29 | # DISABLED: name: Setup Python ${{ matrix.python-version }} on platform=${{ matrix.os }} 30 | - uses: actions/setup-python@v5 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | cache: 'pip' 34 | cache-dependency-path: 'py.requirements/*.txt' 35 | - name: setup-uv -- Speed-up Python package installations ... 36 | uses: astral-sh/setup-uv@v3 37 | with: 38 | enable-cache: true 39 | cache-dependency-glob: | 40 | **/pyproject.toml 41 | **/py.requirements/ci.github.testing.txt 42 | **/py.requirements/basic.txt 43 | - name: "Install Python package dependencies (with: uv)" 44 | run: | 45 | uv pip install --system -U pip setuptools wheel 46 | uv pip install --system -U -r py.requirements/ci.github.testing.txt 47 | uv pip install --system -e . 48 | - name: Run tests 49 | run: pytest 50 | - name: Upload test reports 51 | uses: actions/upload-artifact@v3 52 | with: 53 | name: test reports 54 | path: | 55 | build/testing/report.xml 56 | build/testing/report.html 57 | if: ${{ job.status == 'failure' }} 58 | # MAYBE: if: ${{ always() }} 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # -- TEMPORARY PYTHON PACKAGE PARTS: 4 | parse_type/_version.py 5 | MANIFEST 6 | *.egg 7 | *.egg-info 8 | dist 9 | build 10 | downloads 11 | __pycache__ 12 | 13 | # Installer logs 14 | pip-log.txt 15 | 16 | Pipfile 17 | Pipfile.lock 18 | 19 | # -- TESTS, COVERAGE REPORTS, ... 20 | .cache/ 21 | .direnv/ 22 | .eggs/ 23 | .pytest_cache/ 24 | .ruff_cache/ 25 | .tox/ 26 | .venv*/ 27 | .coverage 28 | .done.* 29 | 30 | # -- IDE-RELATED: 31 | .fleet/ 32 | .idea/ 33 | .vscode/ 34 | .project 35 | .pydevproject 36 | 37 | # -- EXCLUDE GIT-SUBPROJECTS: 38 | /lib/parse/ 39 | -------------------------------------------------------------------------------- /.repos: -------------------------------------------------------------------------------- 1 | # =========================================================================== 2 | # vcs: Multi-repo configuration 3 | # =========================================================================== 4 | # USAGE: 5 | # vcs --commands # Show available commands 6 | # 7 | # vcs import < .repos 8 | # vcs import --input=.repos 9 | # vcs import --input=https://github.com/jenisys/cxx.simplelog/blob/master/.repos 10 | # vcs import --input=https://github.com/jenisys/cxx.simplelog/blob/master/.rosinstall 11 | # vcs import --shallow --input=.repos 12 | # vcs import lib/ --input=.repos 13 | # 14 | # vcs pull 15 | # vcs status 16 | # 17 | # vcs export --nested # Use branch-name 18 | # vcs export --nested --exact # Use commit-hashes instead of branch-name 19 | # vcs export --nested --exact-with-tags # Use tags or commit-hashes 20 | # vcs export --nested lib/doctest # For a specific path instead of ".". 21 | # 22 | # BAD: vcs-export adds basename of current-directory to repositories. 23 | # 24 | # SEE ALSO: 25 | # * https://github.com/dirk-thomas/vcstool 26 | # =========================================================================== 27 | # REQUIRES: pip install vcstool 28 | 29 | repositories: 30 | lib/parse: 31 | type: git 32 | url: https://github.com/r1chardj0n3s/parse.git 33 | version: master 34 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | Version History 2 | =============================================================================== 3 | 4 | 5 | Version: 0.7.0 (UNRELEASED) 6 | ------------------------------------------------------------------------------- 7 | 8 | GOALS: 9 | 10 | * Drop support for Python 2.7 11 | * Support Python >= 3.7 (probably) 12 | 13 | Version: 0.6.2 (2023-07-04) 14 | ------------------------------------------------------------------------------- 15 | 16 | FIXES: 17 | 18 | * #21: tests/test_parse.py tests ``parse_type.parse`` (per default). 19 | REASON: Using for older installed ``parse`` module may cause weird problems. 20 | RELATED TO: ``parse v1.19.1`` (behavior changed compared to ``v1.19.0``) 21 | 22 | 23 | Version: 0.6.1 (2023-07-02) 24 | ------------------------------------------------------------------------------- 25 | 26 | * Switch to MIT license (same as: `parse`_ module) 27 | * Use SPDX-License-Identifier in source code (to simplify understanding) 28 | * UPDATE/SYNC to `parse`_ v1.19.1 29 | * ADDED: ``pyproject.toml`` to support newer ``pip`` versions 30 | REASON: ``setup.py`` becomes DEPRECATED in 2023-09 for newer ``pip`` versions. 31 | 32 | FIXED: 33 | 34 | * Issue #19: 0.6.0: pytest is failing in two units (submitted by: kloczek; caused by: `parse`_ v1.19.1) 35 | * Issue #1: Licensing confusion 36 | 37 | DEVELOPMENT: 38 | 39 | * VCS: Renamed default branch of Git repository to "main" (was: "master"). 40 | * CI: Use github-actions as CI pipeline. 41 | 42 | 43 | Version: 0.6.0 (2022-01-18) 44 | ------------------------------------------------------------------------------- 45 | 46 | FIXED: 47 | 48 | + issue #17: setup.py: Remove use of "use_2to3" (submitted by: xxx) 49 | 50 | 51 | Version: 0.5.6 (2020-09-11) 52 | ------------------------------------------------------------------------------- 53 | 54 | FIXED: 55 | 56 | + parse issue #119 (same as: #121): int_convert memory effect with number-base discovery 57 | + UPDATE to parse v1.18.0 (needed by: parse issue #119) 58 | 59 | 60 | Version: 0.5.5 (2020-09-10) 61 | ------------------------------------------------------------------------------- 62 | 63 | FIXED: 64 | 65 | + parse PR #122: Fixes issue #121 in parse: int_convert memory effect. 66 | 67 | 68 | Version: 0.5.4 (2020-09-10) 69 | ------------------------------------------------------------------------------- 70 | 71 | UPDATED: 72 | 73 | + parse v1.17.0 74 | 75 | 76 | Version: 0.5.3 (2019-12-15) 77 | ------------------------------------------------------------------------------- 78 | 79 | UPDATED: 80 | 81 | + setup.py: Add support for Python 3.8. 82 | + UPDATE: Dependencies 83 | 84 | 85 | Version: 0.5.2 (2019-07-14) 86 | ------------------------------------------------------------------------------- 87 | 88 | UPDATED: 89 | 90 | + parse v1.12.0 91 | 92 | FIXED: 93 | 94 | + Python3 DeprecationWarning for regex (here: in docstrings). 95 | 96 | 97 | Version: 0.5.1 (2018-05-27) 98 | ------------------------------------------------------------------------------- 99 | 100 | CHANGED: 101 | 102 | + Add parse_type.cfparse.Parser(..., case_sensitive=False, ...) parameter 103 | to match functionality in parse.Parser constructor (in parse-1.8.4). 104 | + UPDATE to parse-1.8.4 105 | 106 | 107 | Version: 0.5.0 (2018-04-08; includes: v0.4.3) 108 | ------------------------------------------------------------------------------- 109 | 110 | FIXED: 111 | 112 | + FIX doctest for parse_type.parse module. 113 | 114 | CHANGES: 115 | 116 | * UPDATE: parse-1.8.3 (was: parse-1.8.2) 117 | NOTE: ``parse`` module and ``parse_type.parse`` module are now identical. 118 | 119 | BACKWARD INCOMPATIBLE CHANGES: 120 | 121 | * RENAMED: type_converter.regex_group_count attribute (was: .group_count) 122 | (pull-request review changes of the ``parse`` module). 123 | 124 | 125 | .. _parse: https://github.com/r1chardj0n3s/parse 126 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2013-2023 jenisys 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | include .coveragerc 4 | include .editorconfig 5 | include *.py 6 | include *.rst 7 | include *.txt 8 | include *.ini 9 | include *.cfg 10 | include *.yaml 11 | include bin/invoke* 12 | exclude __*.rst 13 | exclude __*.txt 14 | 15 | recursive-include bin *.cmd *.py *.sh 16 | recursive-include py.requirements *.txt 17 | recursive-include tasks *.py *.txt 18 | recursive-include tests *.py 19 | # -- DISABLED: recursive-include docs *.rst *.txt *.py 20 | 21 | prune .direnv 22 | prune .tox 23 | prune .venv* 24 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============================================================================== 2 | parse_type 3 | =============================================================================== 4 | 5 | .. |badge.CI_status| image:: https://github.com/jenisys/parse_type/actions/workflows/test.yml/badge.svg 6 | :target: https://github.com/jenisys/parse_type/actions/workflows/test.yml 7 | :alt: CI Build Status 8 | 9 | .. |badge.latest_version| image:: https://img.shields.io/pypi/v/parse_type.svg 10 | :target: https://pypi.python.org/pypi/parse_type 11 | :alt: Latest Version 12 | 13 | .. |badge.downloads| image:: https://img.shields.io/pypi/dm/parse_type.svg 14 | :target: https://pypi.python.org/pypi/parse_type 15 | :alt: Downloads 16 | 17 | .. |badge.license| image:: https://img.shields.io/pypi/l/parse_type.svg 18 | :target: https://pypi.python.org/pypi/parse_type/ 19 | :alt: License 20 | 21 | |badge.CI_status| |badge.latest_version| |badge.license| |badge.downloads| 22 | 23 | `parse_type`_ extends the `parse`_ module (opposite of `string.format()`_) 24 | with the following features: 25 | 26 | * build type converters for common use cases (enum/mapping, choice) 27 | * build a type converter with a cardinality constraint (0..1, 0..*, 1..*) 28 | from the type converter with cardinality=1. 29 | * compose a type converter from other type converters 30 | * an extended parser that supports the CardinalityField naming schema 31 | and creates missing type variants (0..1, 0..*, 1..*) from the 32 | primary type converter 33 | 34 | .. _parse_type: http://pypi.python.org/pypi/parse_type 35 | .. _parse: http://pypi.python.org/pypi/parse 36 | .. _`string.format()`: http://docs.python.org/library/string.html#format-string-syntax 37 | 38 | 39 | Definitions 40 | ------------------------------------------------------------------------------- 41 | 42 | *type converter* 43 | A type converter function that converts a textual representation 44 | of a value type into instance of this value type. 45 | In addition, a type converter function is often annotated with attributes 46 | that allows the `parse`_ module to use it in a generic way. 47 | A type converter is also called a *parse_type* (a definition used here). 48 | 49 | *cardinality field* 50 | A naming convention for related types that differ in cardinality. 51 | A cardinality field is a type name suffix in the format of a field. 52 | It allows parse format expression, ala:: 53 | 54 | "{person:Person}" #< Cardinality: 1 (one; the normal case) 55 | "{person:Person?}" #< Cardinality: 0..1 (zero or one = optional) 56 | "{persons:Person*}" #< Cardinality: 0..* (zero or more = many0) 57 | "{persons:Person+}" #< Cardinality: 1..* (one or more = many) 58 | 59 | This naming convention mimics the relationship descriptions in UML diagrams. 60 | 61 | 62 | Basic Example 63 | ------------------------------------------------------------------------------- 64 | 65 | Define an own type converter for numbers (integers): 66 | 67 | .. code-block:: python 68 | 69 | # -- USE CASE: 70 | def parse_number(text): 71 | return int(text) 72 | parse_number.pattern = r"\d+" # -- REGULAR EXPRESSION pattern for type. 73 | 74 | This is equivalent to: 75 | 76 | .. code-block:: python 77 | 78 | import parse 79 | 80 | @parse.with_pattern(r"\d+") 81 | def parse_number(text): 82 | return int(text) 83 | assert hasattr(parse_number, "pattern") 84 | assert parse_number.pattern == r"\d+" 85 | 86 | 87 | .. code-block:: python 88 | 89 | # -- USE CASE: Use the type converter with the parse module. 90 | schema = "Hello {number:Number}" 91 | parser = parse.Parser(schema, dict(Number=parse_number)) 92 | result = parser.parse("Hello 42") 93 | assert result is not None, "REQUIRE: text matches the schema." 94 | assert result["number"] == 42 95 | 96 | result = parser.parse("Hello XXX") 97 | assert result is None, "MISMATCH: text does not match the schema." 98 | 99 | .. hint:: 100 | 101 | The described functionality above is standard functionality 102 | of the `parse`_ module. It serves as introduction for the remaining cases. 103 | 104 | 105 | Cardinality 106 | ------------------------------------------------------------------------------- 107 | 108 | Create an type converter for "ManyNumbers" (List, separated with commas) 109 | with cardinality "1..* = 1+" (many) from the type converter for a "Number". 110 | 111 | .. code-block:: python 112 | 113 | # -- USE CASE: Create new type converter with a cardinality constraint. 114 | # CARDINALITY: many := one or more (1..*) 115 | from parse import Parser 116 | from parse_type import TypeBuilder 117 | parse_numbers = TypeBuilder.with_many(parse_number, listsep=",") 118 | 119 | schema = "List: {numbers:ManyNumbers}" 120 | parser = Parser(schema, dict(ManyNumbers=parse_numbers)) 121 | result = parser.parse("List: 1, 2, 3") 122 | assert result["numbers"] == [1, 2, 3] 123 | 124 | 125 | Create an type converter for an "OptionalNumbers" with cardinality "0..1 = ?" 126 | (optional) from the type converter for a "Number". 127 | 128 | .. code-block:: python 129 | 130 | # -- USE CASE: Create new type converter with cardinality constraint. 131 | # CARDINALITY: optional := zero or one (0..1) 132 | from parse import Parser 133 | from parse_type import TypeBuilder 134 | 135 | parse_optional_number = TypeBuilder.with_optional(parse_number) 136 | schema = "Optional: {number:OptionalNumber}" 137 | parser = Parser(schema, dict(OptionalNumber=parse_optional_number)) 138 | result = parser.parse("Optional: 42") 139 | assert result["number"] == 42 140 | result = parser.parse("Optional: ") 141 | assert result["number"] == None 142 | 143 | 144 | Enumeration (Name-to-Value Mapping) 145 | ------------------------------------------------------------------------------- 146 | 147 | Create an type converter for an "Enumeration" from the description of 148 | the mapping as dictionary. 149 | 150 | .. code-block:: python 151 | 152 | # -- USE CASE: Create a type converter for an enumeration. 153 | from parse import Parser 154 | from parse_type import TypeBuilder 155 | 156 | parse_enum_yesno = TypeBuilder.make_enum({"yes": True, "no": False}) 157 | parser = Parser("Answer: {answer:YesNo}", dict(YesNo=parse_enum_yesno)) 158 | result = parser.parse("Answer: yes") 159 | assert result["answer"] == True 160 | 161 | 162 | Create an type converter for an "Enumeration" from the description of 163 | the mapping as an enumeration class (`Python 3.4 enum`_ or the `enum34`_ 164 | backport; see also: `PEP-0435`_). 165 | 166 | .. code-block:: python 167 | 168 | # -- USE CASE: Create a type converter for enum34 enumeration class. 169 | # NOTE: Use Python 3.4 or enum34 backport. 170 | from parse import Parser 171 | from parse_type import TypeBuilder 172 | from enum import Enum 173 | 174 | class Color(Enum): 175 | red = 1 176 | green = 2 177 | blue = 3 178 | 179 | parse_enum_color = TypeBuilder.make_enum(Color) 180 | parser = Parser("Select: {color:Color}", dict(Color=parse_enum_color)) 181 | result = parser.parse("Select: red") 182 | assert result["color"] is Color.red 183 | 184 | .. _`Python 3.4 enum`: http://docs.python.org/3.4/library/enum.html#module-enum 185 | .. _enum34: http://pypi.python.org/pypi/enum34 186 | .. _PEP-0435: http://www.python.org/dev/peps/pep-0435 187 | 188 | 189 | Choice (Name Enumeration) 190 | ------------------------------------------------------------------------------- 191 | 192 | A Choice data type allows to select one of several strings. 193 | 194 | Create an type converter for an "Choice" list, a list of unique names 195 | (as string). 196 | 197 | .. code-block:: python 198 | 199 | from parse import Parser 200 | from parse_type import TypeBuilder 201 | 202 | parse_choice_yesno = TypeBuilder.make_choice(["yes", "no"]) 203 | schema = "Answer: {answer:ChoiceYesNo}" 204 | parser = Parser(schema, dict(ChoiceYesNo=parse_choice_yesno)) 205 | result = parser.parse("Answer: yes") 206 | assert result["answer"] == "yes" 207 | 208 | 209 | Variant (Type Alternatives) 210 | ------------------------------------------------------------------------------- 211 | 212 | Sometimes you need a type converter that can accept text for multiple 213 | type converter alternatives. This is normally called a "variant" (or: union). 214 | 215 | Create an type converter for an "Variant" type that accepts: 216 | 217 | * Numbers (positive numbers, as integer) 218 | * Color enum values (by name) 219 | 220 | .. code-block:: python 221 | 222 | from parse import Parser, with_pattern 223 | from parse_type import TypeBuilder 224 | from enum import Enum 225 | 226 | class Color(Enum): 227 | red = 1 228 | green = 2 229 | blue = 3 230 | 231 | @with_pattern(r"\d+") 232 | def parse_number(text): 233 | return int(text) 234 | 235 | # -- MAKE VARIANT: Alternatives of different type converters. 236 | parse_color = TypeBuilder.make_enum(Color) 237 | parse_variant = TypeBuilder.make_variant([parse_number, parse_color]) 238 | schema = "Variant: {variant:Number_or_Color}" 239 | parser = Parser(schema, dict(Number_or_Color=parse_variant)) 240 | 241 | # -- TEST VARIANT: With number, color and mismatch. 242 | result = parser.parse("Variant: 42") 243 | assert result["variant"] == 42 244 | result = parser.parse("Variant: blue") 245 | assert result["variant"] is Color.blue 246 | result = parser.parse("Variant: __MISMATCH__") 247 | assert not result 248 | 249 | 250 | 251 | Extended Parser with CardinalityField support 252 | ------------------------------------------------------------------------------- 253 | 254 | The parser extends the ``parse.Parser`` and adds the following functionality: 255 | 256 | * supports the CardinalityField naming scheme 257 | * automatically creates missing type variants for types with 258 | a CardinalityField by using the primary type converter for cardinality=1 259 | * extends the provide type converter dictionary with new type variants. 260 | 261 | Example: 262 | 263 | .. code-block:: python 264 | 265 | # -- USE CASE: Parser with CardinalityField support. 266 | # NOTE: Automatically adds missing type variants with CardinalityField part. 267 | # USE: parse_number() type converter from above. 268 | from parse_type.cfparse import Parser 269 | 270 | # -- PREPARE: parser, adds missing type variant for cardinality 1..* (many) 271 | type_dict = dict(Number=parse_number) 272 | schema = "List: {numbers:Number+}" 273 | parser = Parser(schema, type_dict) 274 | assert "Number+" in type_dict, "Created missing type variant based on: Number" 275 | 276 | # -- USE: parser. 277 | result = parser.parse("List: 1, 2, 3") 278 | assert result["numbers"] == [1, 2, 3] 279 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The following versions are currently being supported with security updates. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | HEAD | :white_check_mark: | 10 | | 0.6.x | :white_check_mark: | 11 | | < 0.6.0 | :x: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | Please report security issues by using the new 16 | [Github vulnerability reporting mechanism][security advisories for this repository] 17 | that is enabled for [this repository]. 18 | 19 | SEE ALSO: 20 | 21 | * [THIS REPOSITORY]: [Security Advisories][security advisories for this repository] 22 | * [docs.github.com]/.../[guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability] 23 | 24 | 25 | [this repository]: https://github.com/jenisys/parse_type 26 | [security advisories for this repository]: https://github.com/jenisys/parse_type/security/advisories 27 | 28 | [docs.github.com]: https://docs.github.com/en/ 29 | [guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability]: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability 30 | -------------------------------------------------------------------------------- /bin/github-workflow_check.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Check a github-workflow YAML file. 4 | 5 | RELATED: JSON schema for Github Action workflows 6 | 7 | * https://dev.to/robertobutti/vscode-how-to-check-workflow-syntax-for-github-actions-4k0o 8 | 9 | - https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions 10 | - https://github.com/actions/starter-workflows/tree/master/ci 11 | - https://github.com/actions/starter-workflows/blob/main/ci/python-publish.yml 12 | 13 | REQUIRES: 14 | pip install check-jsonschema 15 | DOWNLOAD: https://github.com/SchemaStore/schemastore/blob/master/src/schemas/json/github-workflow.json 16 | USE: check-jsonschema --schemafile github-workflow.json_schema.txt .github/workflows/release-to-pypi.yml 17 | 18 | * https://github.com/SchemaStore/schemastore/blob/master/src/schemas/json/github-action.json 19 | * MAYBE: https://github.com/softprops/github-actions-schemas/blob/master/workflow.json 20 | 21 | REQUIRES: 22 | 23 | * pip install check-jsonschema 24 | * pip install typer >= 0.12.5 25 | * pip install typing-extensions 26 | 27 | GITHUB WORKFLOW SCHEMA: 28 | 29 | * https://github.com/SchemaStore/schemastore/blob/master/src/schemas/json/github-workflow.json 30 | """ 31 | 32 | from pathlib import Path 33 | from subprocess import run 34 | from typing import Optional 35 | from typing_extensions import Self 36 | 37 | import typer 38 | 39 | # ----------------------------------------------------------------------------- 40 | # CONSTANTS 41 | # ----------------------------------------------------------------------------- 42 | HERE = Path(__file__).parent.absolute() 43 | GITHUB_WORKFLOW_SCHEMA_URL = "https://github.com/SchemaStore/schemastore/blob/master/src/schemas/json/github-workflow.json" 44 | GITHUB_WORKFLOW_SCHEMA_PATH = HERE/"github-workflow.json_schema" 45 | 46 | 47 | # ----------------------------------------------------------------------------- 48 | # CLASSES: 49 | # ----------------------------------------------------------------------------- 50 | class Verdict: 51 | def __init__(self, path: Path, outcome: bool, message: Optional[str] = None): 52 | self.path = path 53 | self.outcome = outcome 54 | self.message = message or "" 55 | 56 | @property 57 | def verdict(self): 58 | the_verdict = "FAILED" 59 | if self.outcome: 60 | the_verdict = "OK" 61 | return the_verdict 62 | 63 | def as_bool(self): 64 | return bool(self.outcome) 65 | 66 | def __bool__(self): 67 | return self.as_bool() 68 | 69 | def __str__(self): 70 | return f"{self.verdict}: {self.path} {self.message}".strip() 71 | 72 | def __repr__(self): 73 | class_name = self.__class__.__name__ 74 | return f"<{class_name}: path={self.path}, verdict={self.verdict}, message='{self.message}'>" 75 | 76 | @classmethod 77 | def make_success(cls, path: Path, message: Optional[str] = None) -> Self: 78 | return cls(path, outcome=True, message=message) 79 | 80 | @classmethod 81 | def make_failure(cls, path: Path, message: Optional[str] = None) -> Self: 82 | return cls(path, outcome=False, message=message) 83 | 84 | 85 | def workflow_check(path: Path) -> Verdict: 86 | schema = GITHUB_WORKFLOW_SCHEMA_PATH 87 | 88 | print(f"CHECK: {path} ... ") 89 | result = run(["check-jsonschema", f"--schemafile={schema}", f"{path}"]) 90 | if result.returncode == 0: 91 | return Verdict.make_success(path) 92 | # -- OTHERWISE: 93 | return Verdict.make_failure(path) 94 | 95 | 96 | def workflow_check_many(paths: list[Path]) -> list[Verdict]: 97 | verdicts = [] 98 | for path in paths: 99 | verdict = workflow_check(path) 100 | verdicts.append(verdict) 101 | return verdicts 102 | 103 | 104 | def main(paths: list[Path]) -> int: 105 | """ 106 | Check github-workflow YAML file(s). 107 | 108 | :param paths: Paths to YAML file(s). 109 | :return: 0, if all checks pass. 1, otherwise 110 | """ 111 | verdicts = workflow_check_many(paths) 112 | 113 | count_passed = 0 114 | count_failed = 0 115 | for verdict in verdicts: 116 | # DISABLED: print(str(verdict)) 117 | if verdict: 118 | count_passed += 1 119 | else: 120 | count_failed += 1 121 | 122 | summary = f"SUMMARY: {len(verdicts)} files, {count_passed} passed, {count_failed} failed" 123 | print(summary) 124 | result = 1 125 | if count_failed == 0: 126 | result = 0 127 | return result 128 | 129 | 130 | if __name__ == '__main__': 131 | typer.run(main) 132 | -------------------------------------------------------------------------------- /bin/make_localpi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Utility script to create a pypi-like directory structure (localpi) 5 | from a number of Python packages in a directory of the local filesystem. 6 | 7 | DIRECTORY STRUCTURE (before): 8 | +-- downloads/ 9 | +-- alice-1.0.zip 10 | +-- alice-1.0.tar.gz 11 | +-- bob-1.3.0.tar.gz 12 | +-- bob-1.4.2.tar.gz 13 | +-- charly-1.0.tar.bz2 14 | 15 | DIRECTORY STRUCTURE (afterwards): 16 | +-- downloads/ 17 | +-- simple/ 18 | | +-- alice/index.html --> ../../alice-*.* 19 | | +-- bob/index.html --> ../../bob-*.* 20 | | +-- charly/index.html --> ../../charly-*.* 21 | | +-- index.html --> alice/, bob/, ... 22 | +-- alice-1.0.zip 23 | +-- alice-1.0.tar.gz 24 | +-- bob-1.3.0.tar.gz 25 | +-- bob-1.4.2.tar.gz 26 | +-- charly-1.0.tar.bz2 27 | 28 | USAGE EXAMPLE: 29 | 30 | mkdir -p /tmp/downloads 31 | pip install --download=/tmp/downloads argparse Jinja2 32 | make_localpi.py /tmp/downloads 33 | pip install --index-url=file:///tmp/downloads/simple argparse Jinja2 34 | 35 | ALTERNATIVE: 36 | 37 | pip install --download=/tmp/downloads argparse Jinja2 38 | pip install --find-links=/tmp/downloads --no-index argparse Jinja2 39 | """ 40 | 41 | from __future__ import with_statement, print_function 42 | from fnmatch import fnmatch 43 | import os.path 44 | import shutil 45 | import sys 46 | 47 | 48 | __author__ = "Jens Engel" 49 | __version__ = "0.2" 50 | __license__ = "BSD" 51 | __copyright__ = "(c) 2013 by Jens Engel" 52 | 53 | 54 | class Package(object): 55 | """ 56 | Package entity that keeps track of: 57 | * one or more versions of this package 58 | * one or more archive types 59 | """ 60 | PATTERNS = [ 61 | "*.egg", "*.exe", "*.whl", "*.zip", "*.tar.gz", "*.tar.bz2", "*.7z" 62 | ] 63 | 64 | def __init__(self, filename, name=None): 65 | if not name and filename: 66 | name = self.get_pkgname(filename) 67 | self.name = name 68 | self.files = [] 69 | if filename: 70 | self.files.append(filename) 71 | 72 | @property 73 | def versions(self): 74 | versions_info = [ self.get_pkgversion(p) for p in self.files ] 75 | return versions_info 76 | 77 | @classmethod 78 | def get_pkgversion(cls, filename): 79 | parts = os.path.basename(filename).rsplit("-", 1) 80 | version = "" 81 | if len(parts) >= 2: 82 | version = parts[1] 83 | for pattern in cls.PATTERNS: 84 | assert pattern.startswith("*") 85 | suffix = pattern[1:] 86 | if version.endswith(suffix): 87 | version = version[:-len(suffix)] 88 | break 89 | return version 90 | 91 | @staticmethod 92 | def get_pkgname(filename): 93 | name = os.path.basename(filename).rsplit("-", 1)[0] 94 | if name.startswith("http%3A") or name.startswith("https%3A"): 95 | # -- PIP DOWNLOAD-CACHE PACKAGE FILE NAME SCHEMA: 96 | pos = name.rfind("%2F") 97 | name = name[pos+3:] 98 | return name 99 | 100 | @staticmethod 101 | def splitext(filename): 102 | fname = os.path.splitext(filename)[0] 103 | if fname.endswith(".tar"): 104 | fname = os.path.splitext(fname)[0] 105 | return fname 106 | 107 | @classmethod 108 | def isa(cls, filename): 109 | basename = os.path.basename(filename) 110 | if basename.startswith("."): 111 | return False 112 | for pattern in cls.PATTERNS: 113 | if fnmatch(filename, pattern): 114 | return True 115 | return False 116 | 117 | 118 | def make_index_for(package, index_dir, verbose=True): 119 | """ 120 | Create an 'index.html' for one package. 121 | 122 | :param package: Package object to use. 123 | :param index_dir: Where 'index.html' should be created. 124 | """ 125 | index_template = """\ 126 | 127 | {title} 128 | 129 |

{title}

130 | 133 | 134 | 135 | """ 136 | item_template = '
  • {0}
  • ' 137 | index_filename = os.path.join(index_dir, "index.html") 138 | if not os.path.isdir(index_dir): 139 | os.makedirs(index_dir) 140 | 141 | parts = [] 142 | for pkg_filename in package.files: 143 | pkg_name = os.path.basename(pkg_filename) 144 | if pkg_name == "index.html": 145 | # -- ROOT-INDEX: 146 | pkg_name = os.path.basename(os.path.dirname(pkg_filename)) 147 | else: 148 | pkg_name = package.splitext(pkg_name) 149 | pkg_relpath_to = os.path.relpath(pkg_filename, index_dir) 150 | parts.append(item_template.format(pkg_name, pkg_relpath_to)) 151 | 152 | if not parts: 153 | print("OOPS: Package %s has no files" % package.name) 154 | return 155 | 156 | if verbose: 157 | root_index = not Package.isa(package.files[0]) 158 | if root_index: 159 | info = "with %d package(s)" % len(package.files) 160 | else: 161 | package_versions = sorted(set(package.versions)) 162 | info = ", ".join(reversed(package_versions)) 163 | message = "%-30s %s" % (package.name, info) 164 | print(message) 165 | 166 | with open(index_filename, "w") as f: 167 | packages = "\n".join(parts) 168 | text = index_template.format(title=package.name, packages=packages) 169 | f.write(text.strip()) 170 | f.close() 171 | 172 | 173 | def make_package_index(download_dir): 174 | """ 175 | Create a pypi server like file structure below download directory. 176 | 177 | :param download_dir: Download directory with packages. 178 | 179 | EXAMPLE BEFORE: 180 | +-- downloads/ 181 | +-- alice-1.0.zip 182 | +-- alice-1.0.tar.gz 183 | +-- bob-1.3.0.tar.gz 184 | +-- bob-1.4.2.tar.gz 185 | +-- charly-1.0.tar.bz2 186 | 187 | EXAMPLE AFTERWARDS: 188 | +-- downloads/ 189 | +-- simple/ 190 | | +-- alice/index.html --> ../../alice-*.* 191 | | +-- bob/index.html --> ../../bob-*.* 192 | | +-- charly/index.html --> ../../charly-*.* 193 | | +-- index.html --> alice/index.html, bob/index.html, ... 194 | +-- alice-1.0.zip 195 | +-- alice-1.0.tar.gz 196 | +-- bob-1.3.0.tar.gz 197 | +-- bob-1.4.2.tar.gz 198 | +-- charly-1.0.tar.bz2 199 | """ 200 | if not os.path.isdir(download_dir): 201 | raise ValueError("No such directory: %r" % download_dir) 202 | 203 | pkg_rootdir = os.path.join(download_dir, "simple") 204 | if os.path.isdir(pkg_rootdir): 205 | shutil.rmtree(pkg_rootdir, ignore_errors=True) 206 | os.mkdir(pkg_rootdir) 207 | 208 | # -- STEP: Collect all packages. 209 | package_map = {} 210 | packages = [] 211 | for filename in sorted(os.listdir(download_dir)): 212 | if not Package.isa(filename): 213 | continue 214 | pkg_filepath = os.path.join(download_dir, filename) 215 | package_name = Package.get_pkgname(pkg_filepath) 216 | package = package_map.get(package_name, None) 217 | if not package: 218 | # -- NEW PACKAGE DETECTED: Store/register package. 219 | package = Package(pkg_filepath) 220 | package_map[package.name] = package 221 | packages.append(package) 222 | else: 223 | # -- SAME PACKAGE: Collect other variant/version. 224 | package.files.append(pkg_filepath) 225 | 226 | # -- STEP: Make local PYTHON PACKAGE INDEX. 227 | root_package = Package(None, "Python Package Index") 228 | root_package.files = [ os.path.join(pkg_rootdir, pkg.name, "index.html") 229 | for pkg in packages ] 230 | make_index_for(root_package, pkg_rootdir) 231 | for package in packages: 232 | index_dir = os.path.join(pkg_rootdir, package.name) 233 | make_index_for(package, index_dir) 234 | 235 | 236 | # ----------------------------------------------------------------------------- 237 | # MAIN: 238 | # ----------------------------------------------------------------------------- 239 | if __name__ == "__main__": 240 | if (len(sys.argv) != 2) or "-h" in sys.argv[1:] or "--help" in sys.argv[1:]: 241 | print("USAGE: %s DOWNLOAD_DIR" % os.path.basename(sys.argv[0])) 242 | print(__doc__) 243 | sys.exit(1) 244 | make_package_index(sys.argv[1]) 245 | -------------------------------------------------------------------------------- /bin/project_bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ============================================================================= 3 | # BOOTSTRAP PROJECT: Download all requirements 4 | # ============================================================================= 5 | # test ${PIP_DOWNLOADS_DIR} || mkdir -p ${PIP_DOWNLOADS_DIR} 6 | # tox -e init 7 | 8 | set -e 9 | 10 | # -- CONFIGURATION: 11 | HERE=`dirname $0` 12 | TOP="${HERE}/.." 13 | : ${PIP_INDEX_URL="http://pypi.python.org/simple"} 14 | : ${PIP_DOWNLOAD_DIR:="${TOP}/downloads"} 15 | export PIP_INDEX_URL PIP_DOWNLOADS_DIR 16 | 17 | # -- EXECUTE STEPS: 18 | ${HERE}/toxcmd.py mkdir ${PIP_DOWNLOAD_DIR} 19 | pip install --download=${PIP_DOWNLOAD_DIR} -r ${TOP}/requirements/all.txt 20 | ${HERE}/make_localpi.py ${PIP_DOWNLOAD_DIR} 21 | 22 | -------------------------------------------------------------------------------- /bin/toxcmd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: UTF-8 -*- 3 | """ 4 | Provides a command container for additional tox commands, used in "tox.ini". 5 | 6 | COMMANDS: 7 | 8 | * copytree 9 | * copy 10 | * py2to3 11 | 12 | REQUIRES: 13 | * argparse 14 | """ 15 | 16 | from glob import glob 17 | import argparse 18 | import inspect 19 | import os.path 20 | import shutil 21 | import sys 22 | 23 | __author__ = "Jens Engel" 24 | __copyright__ = "(c) 2013 by Jens Engel" 25 | __license__ = "BSD" 26 | 27 | # ----------------------------------------------------------------------------- 28 | # CONSTANTS: 29 | # ----------------------------------------------------------------------------- 30 | VERSION = "0.1.0" 31 | FORMATTER_CLASS = argparse.RawDescriptionHelpFormatter 32 | 33 | 34 | # ----------------------------------------------------------------------------- 35 | # SUBCOMMAND: copytree 36 | # ----------------------------------------------------------------------------- 37 | def command_copytree(args): 38 | """ 39 | Copy one or more source directory(s) below a destination directory. 40 | Parts of the destination directory path are created if needed. 41 | Similar to the UNIX command: 'cp -R srcdir destdir' 42 | """ 43 | for srcdir in args.srcdirs: 44 | basename = os.path.basename(srcdir) 45 | destdir2 = os.path.normpath(os.path.join(args.destdir, basename)) 46 | if os.path.exists(destdir2): 47 | shutil.rmtree(destdir2) 48 | sys.stdout.write("copytree: %s => %s\n" % (srcdir, destdir2)) 49 | shutil.copytree(srcdir, destdir2) 50 | return 0 51 | 52 | 53 | def setup_parser_copytree(parser): 54 | parser.add_argument("srcdirs", nargs="+", help="Source directory(s)") 55 | parser.add_argument("destdir", help="Destination directory") 56 | 57 | 58 | command_copytree.usage = "%(prog)s srcdir... destdir" 59 | command_copytree.short = "Copy source dir(s) below a destination directory." 60 | command_copytree.setup_parser = setup_parser_copytree 61 | 62 | 63 | # ----------------------------------------------------------------------------- 64 | # SUBCOMMAND: copy 65 | # ----------------------------------------------------------------------------- 66 | def command_copy(args): 67 | """ 68 | Copy one or more source-files(s) to a destpath (destfile or destdir). 69 | Destdir mode is used if: 70 | * More than one srcfile is provided 71 | * Last parameter ends with a slash ("/"). 72 | * Last parameter is an existing directory 73 | 74 | Destination directory path is created if needed. 75 | Similar to the UNIX command: 'cp srcfile... destpath' 76 | """ 77 | sources = args.sources 78 | destpath = args.destpath 79 | source_files = [] 80 | for file_ in sources: 81 | if "*" in file_: 82 | selected = glob(file_) 83 | source_files.extend(selected) 84 | elif os.path.isfile(file_): 85 | source_files.append(file_) 86 | 87 | if destpath.endswith("/") or os.path.isdir(destpath) or len(sources) > 1: 88 | # -- DESTDIR-MODE: Last argument is a directory. 89 | destdir = destpath 90 | else: 91 | # -- DESTFILE-MODE: Copy (and rename) one file. 92 | assert len(source_files) == 1 93 | destdir = os.path.dirname(destpath) 94 | 95 | # -- WORK-HORSE: Copy one or more files to destpath. 96 | if not os.path.isdir(destdir): 97 | sys.stdout.write("copy: Create dir %s\n" % destdir) 98 | os.makedirs(destdir) 99 | for source in source_files: 100 | destname = os.path.join(destdir, os.path.basename(source)) 101 | sys.stdout.write("copy: %s => %s\n" % (source, destname)) 102 | shutil.copy(source, destname) 103 | return 0 104 | 105 | 106 | def setup_parser_copy(parser): 107 | parser.add_argument("sources", nargs="+", help="Source files.") 108 | parser.add_argument("destpath", help="Destination path") 109 | 110 | 111 | command_copy.usage = "%(prog)s sources... destpath" 112 | command_copy.short = "Copy one or more source files to a destinition." 113 | command_copy.setup_parser = setup_parser_copy 114 | 115 | 116 | # ----------------------------------------------------------------------------- 117 | # SUBCOMMAND: mkdir 118 | # ----------------------------------------------------------------------------- 119 | def command_mkdir(args): 120 | """ 121 | Create a non-existing directory (or more ...). 122 | If the directory exists, the step is skipped. 123 | Similar to the UNIX command: 'mkdir -p dir' 124 | """ 125 | errors = 0 126 | for directory in args.dirs: 127 | if os.path.exists(directory): 128 | if not os.path.isdir(directory): 129 | # -- SANITY CHECK: directory exists, but as file... 130 | sys.stdout.write("mkdir: %s\n" % directory) 131 | sys.stdout.write("ERROR: Exists already, but as file...\n") 132 | errors += 1 133 | else: 134 | # -- NORMAL CASE: Directory does not exits yet. 135 | assert not os.path.isdir(directory) 136 | sys.stdout.write("mkdir: %s\n" % directory) 137 | os.makedirs(directory) 138 | return errors 139 | 140 | 141 | def setup_parser_mkdir(parser): 142 | parser.add_argument("dirs", nargs="+", help="Directory(s)") 143 | 144 | command_mkdir.usage = "%(prog)s dir..." 145 | command_mkdir.short = "Create non-existing directory (or more...)." 146 | command_mkdir.setup_parser = setup_parser_mkdir 147 | 148 | # ----------------------------------------------------------------------------- 149 | # SUBCOMMAND: py2to3 150 | # ----------------------------------------------------------------------------- 151 | def command_py2to3(args): 152 | """ 153 | Apply '2to3' tool (Python2 to Python3 conversion tool) to Python sources. 154 | """ 155 | from lib2to3.main import main 156 | sys.exit(main("lib2to3.fixes", args=args.sources)) 157 | 158 | 159 | def setup_parser4py2to3(parser): 160 | parser.add_argument("sources", nargs="+", help="Source files.") 161 | 162 | 163 | command_py2to3.name = "2to3" 164 | command_py2to3.usage = "%(prog)s sources..." 165 | command_py2to3.short = "Apply python's 2to3 tool to Python sources." 166 | command_py2to3.setup_parser = setup_parser4py2to3 167 | 168 | 169 | # ----------------------------------------------------------------------------- 170 | # COMMAND HELPERS/UTILS: 171 | # ----------------------------------------------------------------------------- 172 | def discover_commands(): 173 | commands = [] 174 | for name, func in inspect.getmembers(inspect.getmodule(toxcmd_main)): 175 | if name.startswith("__"): 176 | continue 177 | if name.startswith("command_") and callable(func): 178 | command_name0 = name.replace("command_", "") 179 | command_name = getattr(func, "name", command_name0) 180 | commands.append(Command(command_name, func)) 181 | return commands 182 | 183 | 184 | class Command(object): 185 | def __init__(self, name, func): 186 | assert isinstance(name, basestring) 187 | assert callable(func) 188 | self.name = name 189 | self.func = func 190 | self.parser = None 191 | 192 | def setup_parser(self, command_parser): 193 | setup_parser = getattr(self.func, "setup_parser", None) 194 | if setup_parser and callable(setup_parser): 195 | setup_parser(command_parser) 196 | else: 197 | command_parser.add_argument("args", nargs="*") 198 | 199 | @property 200 | def usage(self): 201 | usage = getattr(self.func, "usage", None) 202 | return usage 203 | 204 | @property 205 | def short_description(self): 206 | short_description = getattr(self.func, "short", "") 207 | return short_description 208 | 209 | @property 210 | def description(self): 211 | return inspect.getdoc(self.func) 212 | 213 | def __call__(self, args): 214 | return self.func(args) 215 | 216 | 217 | # ----------------------------------------------------------------------------- 218 | # MAIN-COMMAND: 219 | # ----------------------------------------------------------------------------- 220 | def toxcmd_main(args=None): 221 | """Command util with subcommands for tox environments.""" 222 | usage = "USAGE: %(prog)s [OPTIONS] COMMAND args..." 223 | if args is None: 224 | args = sys.argv[1:] 225 | 226 | # -- STEP: Build command-line parser. 227 | parser = argparse.ArgumentParser(description=inspect.getdoc(toxcmd_main), 228 | formatter_class=FORMATTER_CLASS) 229 | common_parser = parser.add_argument_group("Common options") 230 | common_parser.add_argument("--version", action="version", version=VERSION) 231 | subparsers = parser.add_subparsers(help="commands") 232 | for command in discover_commands(): 233 | command_parser = subparsers.add_parser(command.name, 234 | usage=command.usage, 235 | description=command.description, 236 | help=command.short_description, 237 | formatter_class=FORMATTER_CLASS) 238 | command_parser.set_defaults(func=command) 239 | command.setup_parser(command_parser) 240 | command.parser = command_parser 241 | 242 | # -- STEP: Process command-line and run command. 243 | options = parser.parse_args(args) 244 | command_function = options.func 245 | return command_function(options) 246 | 247 | 248 | # ----------------------------------------------------------------------------- 249 | # MAIN: 250 | # ----------------------------------------------------------------------------- 251 | if __name__ == "__main__": 252 | sys.exit(toxcmd_main()) 253 | -------------------------------------------------------------------------------- /invoke.yaml: -------------------------------------------------------------------------------- 1 | # ===================================================== 2 | # INVOKE CONFIGURATION: parse_type 3 | # ===================================================== 4 | # -- ON WINDOWS: 5 | # run: 6 | # echo: true 7 | # pty: false 8 | # shell: C:\Windows\System32\cmd.exe 9 | # ===================================================== 10 | 11 | project: 12 | name: parse_type 13 | repo: "pypi" 14 | # -- TODO: until upload problems are resolved. 15 | repo_url: "https://upload.pypi.org/legacy/" 16 | 17 | tasks: 18 | auto_dash_names: false 19 | 20 | run: 21 | echo: true 22 | 23 | cleanup_all: 24 | extra_directories: 25 | - build 26 | - dist 27 | - .hypothesis 28 | - .pytest_cache 29 | - .ruff_cache 30 | - ".venv*" 31 | - ".tox" 32 | extra_files: 33 | - ".done.*" 34 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # ============================================================================= 2 | # justfile: A makefile-like build script -- parse_type 3 | # ============================================================================= 4 | # REQUIRES: cargo install just 5 | # PLATFORMS: Windows, Linux, macOS, ... 6 | # USAGE: 7 | # just --list 8 | # just 9 | # just 10 | # 11 | # SEE ALSO: 12 | # * https://github.com/casey/just 13 | # ============================================================================= 14 | 15 | # -- OPTION: Load environment-variables from "$HERE/.env" file (if exists) 16 | set dotenv-load 17 | 18 | # ----------------------------------------------------------------------------- 19 | # CONFIG: 20 | # ----------------------------------------------------------------------------- 21 | HERE := justfile_directory() 22 | PIP_INSTALL_OPTIONS := env_var_or_default("PIP_INSTALL_OPTIONS", "--quiet") 23 | PYTEST_OPTIONS := env_var_or_default("PYTEST_OPTIONS", "") 24 | 25 | 26 | # ----------------------------------------------------------------------------- 27 | # BUILD RECIPES / TARGETS: 28 | # ----------------------------------------------------------------------------- 29 | 30 | # DEFAULT-TARGET: Ensure that packages are installed and runs tests. 31 | default: (_ensure-install-packages "testing") test 32 | 33 | # PART=all, testing, ... 34 | install-packages PART="all": 35 | @echo "INSTALL-PACKAGES: {{PART}} ..." 36 | pip install {{PIP_INSTALL_OPTIONS}} -r py.requirements/{{PART}}.txt 37 | @touch "{{HERE}}/.done.install-packages.{{PART}}" 38 | 39 | # ENSURE: Python packages are installed. 40 | _ensure-install-packages PART="all": 41 | #!/usr/bin/env python3 42 | from subprocess import run 43 | from os import path 44 | if not path.exists("{{HERE}}/.done.install-packages.{{PART}}"): 45 | run("just install-packages {{PART}}", shell=True) 46 | 47 | # -- SIMILAR: This solution requires a Bourne-like shell (may not work on: Windows). 48 | # _ensure-install-packages PART="testing": 49 | # @test -e "{{HERE}}/.done.install-packages.{{PART}}" || just install-packages {{PART}} 50 | 51 | # Run tests. 52 | test *TESTS: 53 | python -m pytest {{PYTEST_OPTIONS}} {{TESTS}} 54 | 55 | # Determine test coverage by running the tests. 56 | coverage: 57 | coverage run -m pytest 58 | coverage combine 59 | coverage report 60 | coverage html 61 | 62 | # Cleanup most parts (but leave PRECIOUS parts). 63 | cleanup: (_ensure-install-packages "all") 64 | invoke cleanup 65 | 66 | # Cleanup everything. 67 | cleanup-all: 68 | invoke cleanup.all 69 | -------------------------------------------------------------------------------- /parse_type/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # Copyright 2013 - 2023, jenisys 3 | # SPDX-License-Identifier: MIT 4 | """ 5 | This module extends the :mod:`parse` to build and derive additional 6 | parse-types from other, existing types. 7 | """ 8 | 9 | from __future__ import absolute_import 10 | from parse_type.cardinality import Cardinality 11 | from parse_type.builder import TypeBuilder, build_type_dict 12 | 13 | __all__ = ["Cardinality", "TypeBuilder", "build_type_dict"] 14 | -------------------------------------------------------------------------------- /parse_type/builder.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=missing-docstring 3 | r""" 4 | Provides support to compose user-defined parse types. 5 | 6 | Cardinality 7 | ------------ 8 | 9 | It is often useful to constrain how often a data type occurs. 10 | This is also called the cardinality of a data type (in a context). 11 | The supported cardinality are: 12 | 13 | * 0..1 zero_or_one, optional: T or None 14 | * 0..N zero_or_more, list_of 15 | * 1..N one_or_more, list_of (many) 16 | 17 | 18 | .. doctest:: cardinality 19 | 20 | >>> from parse_type import TypeBuilder 21 | >>> from parse import Parser 22 | 23 | >>> def parse_number(text): 24 | ... return int(text) 25 | >>> parse_number.pattern = r"\d+" 26 | 27 | >>> parse_many_numbers = TypeBuilder.with_many(parse_number) 28 | >>> more_types = { "Numbers": parse_many_numbers } 29 | >>> parser = Parser("List: {numbers:Numbers}", more_types) 30 | >>> parser.parse("List: 1, 2, 3") 31 | 32 | 33 | 34 | Enumeration Type (Name-to-Value Mappings) 35 | ----------------------------------------- 36 | 37 | An Enumeration data type allows to select one of several enum values by using 38 | its name. The converter function returns the selected enum value. 39 | 40 | .. doctest:: make_enum 41 | 42 | >>> parse_enum_yesno = TypeBuilder.make_enum({"yes": True, "no": False}) 43 | >>> more_types = { "YesNo": parse_enum_yesno } 44 | >>> parser = Parser("Answer: {answer:YesNo}", more_types) 45 | >>> parser.parse("Answer: yes") 46 | 47 | 48 | 49 | Choice (Name Enumerations) 50 | ----------------------------- 51 | 52 | A Choice data type allows to select one of several strings. 53 | 54 | .. doctest:: make_choice 55 | 56 | >>> parse_choice_yesno = TypeBuilder.make_choice(["yes", "no"]) 57 | >>> more_types = { "ChoiceYesNo": parse_choice_yesno } 58 | >>> parser = Parser("Answer: {answer:ChoiceYesNo}", more_types) 59 | >>> parser.parse("Answer: yes") 60 | 61 | 62 | """ 63 | 64 | from __future__ import absolute_import 65 | import inspect 66 | import re 67 | import enum 68 | from parse_type.cardinality import pattern_group_count, \ 69 | Cardinality, TypeBuilder as CardinalityTypeBuilder 70 | 71 | __all__ = ["TypeBuilder", "build_type_dict", "parse_anything"] 72 | 73 | 74 | class TypeBuilder(CardinalityTypeBuilder): 75 | """ 76 | Provides a utility class to build type-converters (parse_types) for 77 | the :mod:`parse` module. 78 | """ 79 | default_strict = True 80 | default_re_opts = (re.IGNORECASE | re.DOTALL) 81 | 82 | @classmethod 83 | def make_list(cls, item_converter=None, listsep=','): 84 | """ 85 | Create a type converter for a list of items (many := 1..*). 86 | The parser accepts anything and the converter needs to fail on errors. 87 | 88 | :param item_converter: Type converter for an item. 89 | :param listsep: List separator to use (as string). 90 | :return: Type converter function object for the list. 91 | """ 92 | if not item_converter: 93 | item_converter = parse_anything 94 | return cls.with_cardinality(Cardinality.many, item_converter, 95 | pattern=cls.anything_pattern, 96 | listsep=listsep) 97 | 98 | @staticmethod 99 | def make_enum(enum_mappings): 100 | """ 101 | Creates a type converter for an enumeration or text-to-value mapping. 102 | 103 | :param enum_mappings: Defines enumeration names and values. 104 | :return: Type converter function object for the enum/mapping. 105 | """ 106 | if (inspect.isclass(enum_mappings) and 107 | issubclass(enum_mappings, enum.Enum)): 108 | enum_class = enum_mappings 109 | enum_mappings = enum_class.__members__ 110 | 111 | def convert_enum(text): 112 | if text not in convert_enum.mappings: 113 | text = text.lower() # REQUIRED-BY: parse re.IGNORECASE 114 | return convert_enum.mappings[text] #< text.lower() ??? 115 | convert_enum.pattern = r"|".join(enum_mappings.keys()) 116 | convert_enum.mappings = enum_mappings 117 | return convert_enum 118 | 119 | @staticmethod 120 | def _normalize_choices(choices, transform): 121 | assert transform is None or callable(transform) 122 | if transform: 123 | choices = [transform(value) for value in choices] 124 | else: 125 | choices = list(choices) 126 | return choices 127 | 128 | @classmethod 129 | def make_choice(cls, choices, transform=None, strict=None): 130 | """ 131 | Creates a type-converter function to select one from a list of strings. 132 | The type-converter function returns the selected choice_text. 133 | The :param:`transform()` function is applied in the type converter. 134 | It can be used to enforce the case (because parser uses re.IGNORECASE). 135 | 136 | :param choices: List of strings as choice. 137 | :param transform: Optional, initial transform function for parsed text. 138 | :return: Type converter function object for this choices. 139 | """ 140 | # -- NOTE: Parser uses re.IGNORECASE flag 141 | # => transform may enforce case. 142 | choices = cls._normalize_choices(choices, transform) 143 | if strict is None: 144 | strict = cls.default_strict 145 | 146 | def convert_choice(text): 147 | if transform: 148 | text = transform(text) 149 | if strict and text not in convert_choice.choices: 150 | values = ", ".join(convert_choice.choices) 151 | raise ValueError("%s not in: %s" % (text, values)) 152 | return text 153 | convert_choice.pattern = r"|".join(choices) 154 | convert_choice.choices = choices 155 | return convert_choice 156 | 157 | @classmethod 158 | def make_choice2(cls, choices, transform=None, strict=None): 159 | """ 160 | Creates a type converter to select one item from a list of strings. 161 | The type converter function returns a tuple (index, choice_text). 162 | 163 | :param choices: List of strings as choice. 164 | :param transform: Optional, initial transform function for parsed text. 165 | :return: Type converter function object for this choices. 166 | """ 167 | choices = cls._normalize_choices(choices, transform) 168 | if strict is None: 169 | strict = cls.default_strict 170 | 171 | def convert_choice2(text): 172 | if transform: 173 | text = transform(text) 174 | if strict and text not in convert_choice2.choices: 175 | values = ", ".join(convert_choice2.choices) 176 | raise ValueError("%s not in: %s" % (text, values)) 177 | index = convert_choice2.choices.index(text) 178 | return index, text 179 | convert_choice2.pattern = r"|".join(choices) 180 | convert_choice2.choices = choices 181 | return convert_choice2 182 | 183 | @classmethod 184 | def make_variant(cls, converters, re_opts=None, compiled=False, strict=True): 185 | """ 186 | Creates a type converter for a number of type converter alternatives. 187 | The first matching type converter is used. 188 | 189 | REQUIRES: type_converter.pattern attribute 190 | 191 | :param converters: List of type converters as alternatives. 192 | :param re_opts: Regular expression options zu use (=default_re_opts). 193 | :param compiled: Use compiled regexp matcher, if true (=False). 194 | :param strict: Enable assertion checks. 195 | :return: Type converter function object. 196 | 197 | .. note:: 198 | 199 | Works only with named fields in :class:`parse.Parser`. 200 | Parser needs group_index delta for unnamed/fixed fields. 201 | This is not supported for user-defined types. 202 | Otherwise, you need to use :class:`parse_type.parse.Parser` 203 | (patched version of the :mod:`parse` module). 204 | """ 205 | # -- NOTE: Uses double-dispatch with regex pattern rematch because 206 | # match is not passed through to primary type converter. 207 | assert converters, "REQUIRE: Non-empty list." 208 | if len(converters) == 1: 209 | return converters[0] 210 | if re_opts is None: 211 | re_opts = cls.default_re_opts 212 | 213 | pattern = r")|(".join([tc.pattern for tc in converters]) 214 | pattern = r"("+ pattern + ")" 215 | group_count = len(converters) 216 | for converter in converters: 217 | group_count += pattern_group_count(converter.pattern) 218 | 219 | if compiled: 220 | convert_variant = cls.__create_convert_variant_compiled(converters, 221 | re_opts, 222 | strict) 223 | else: 224 | convert_variant = cls.__create_convert_variant(re_opts, strict) 225 | convert_variant.pattern = pattern 226 | convert_variant.converters = tuple(converters) 227 | convert_variant.regex_group_count = group_count 228 | return convert_variant 229 | 230 | @staticmethod 231 | def __create_convert_variant(re_opts, strict): 232 | # -- USE: Regular expression pattern (compiled on use). 233 | def convert_variant(text, m=None): 234 | # pylint: disable=invalid-name, unused-argument, missing-docstring 235 | for converter in convert_variant.converters: 236 | if re.match(converter.pattern, text, re_opts): 237 | return converter(text) 238 | # -- pragma: no cover 239 | assert not strict, "OOPS-VARIANT-MISMATCH: %s" % text 240 | return None 241 | return convert_variant 242 | 243 | @staticmethod 244 | def __create_convert_variant_compiled(converters, re_opts, strict): 245 | # -- USE: Compiled regular expression matcher. 246 | for converter in converters: 247 | matcher = getattr(converter, "matcher", None) 248 | if not matcher: 249 | converter.matcher = re.compile(converter.pattern, re_opts) 250 | 251 | def convert_variant(text, m=None): 252 | # pylint: disable=invalid-name, unused-argument, missing-docstring 253 | for converter in convert_variant.converters: 254 | if converter.matcher.match(text): 255 | return converter(text) 256 | # -- pragma: no cover 257 | assert not strict, "OOPS-VARIANT-MISMATCH: %s" % text 258 | return None 259 | return convert_variant 260 | 261 | 262 | def build_type_dict(converters): 263 | """ 264 | Builds type dictionary for user-defined type converters, 265 | used by :mod:`parse` module. 266 | This requires that each type converter has a "name" attribute. 267 | 268 | :param converters: List of type converters (parse_types) 269 | :return: Type converter dictionary 270 | """ 271 | more_types = {} 272 | for converter in converters: 273 | assert callable(converter) 274 | more_types[converter.name] = converter 275 | return more_types 276 | 277 | # ----------------------------------------------------------------------------- 278 | # COMMON TYPE CONVERTERS 279 | # ----------------------------------------------------------------------------- 280 | def parse_anything(text, match=None, match_start=0): 281 | """ 282 | Provides a generic type converter that accepts anything and returns 283 | the text (unchanged). 284 | 285 | :param text: Text to convert (as string). 286 | :return: Same text (as string). 287 | """ 288 | # pylint: disable=unused-argument 289 | return text 290 | parse_anything.pattern = TypeBuilder.anything_pattern 291 | 292 | 293 | # ----------------------------------------------------------------------------- 294 | # Copyright (c) 2012-2020 by Jens Engel (https://github/jenisys/parse_type) 295 | # 296 | # Permission is hereby granted, free of charge, to any person obtaining a copy 297 | # of this software and associated documentation files (the "Software"), to deal 298 | # in the Software without restriction, including without limitation the rights 299 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 300 | # copies of the Software, and to permit persons to whom the Software is 301 | # furnished to do so, subject to the following conditions: 302 | # 303 | # The above copyright notice and this permission notice shall be included in 304 | # all copies or substantial portions of the Software. 305 | # 306 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 307 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 308 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 309 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 310 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 311 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 312 | # SOFTWARE. 313 | -------------------------------------------------------------------------------- /parse_type/cardinality.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | This module simplifies to build parse types and regular expressions 4 | for a data type with the specified cardinality. 5 | """ 6 | 7 | # -- USE: enum34 8 | from __future__ import absolute_import 9 | from enum import Enum 10 | 11 | 12 | # ----------------------------------------------------------------------------- 13 | # FUNCTIONS: 14 | # ----------------------------------------------------------------------------- 15 | def pattern_group_count(pattern): 16 | """Count the pattern-groups within a regex-pattern (as text).""" 17 | return pattern.replace(r"\(", "").count("(") 18 | 19 | 20 | # ----------------------------------------------------------------------------- 21 | # CLASS: Cardinality (Enum Class) 22 | # ----------------------------------------------------------------------------- 23 | class Cardinality(Enum): 24 | """Cardinality enumeration class to simplify building regular expression 25 | patterns for a data type with the specified cardinality. 26 | """ 27 | # pylint: disable=bad-whitespace 28 | __order__ = "one, zero_or_one, zero_or_more, one_or_more" 29 | one = (None, 0) 30 | zero_or_one = (r"(%s)?", 1) # SCHEMA: pattern 31 | zero_or_more = (r"(%s)?(\s*%s\s*(%s))*", 3) # SCHEMA: pattern sep pattern 32 | one_or_more = (r"(%s)(\s*%s\s*(%s))*", 3) # SCHEMA: pattern sep pattern 33 | 34 | # -- ALIASES: 35 | optional = zero_or_one 36 | many0 = zero_or_more 37 | many = one_or_more 38 | 39 | def __init__(self, schema, group_count=0): 40 | self.schema = schema 41 | self.group_count = group_count #< Number of match groups. 42 | 43 | def is_many(self): 44 | """Checks for a more general interpretation of "many". 45 | 46 | :return: True, if Cardinality.zero_or_more or Cardinality.one_or_more. 47 | """ 48 | return ((self is Cardinality.zero_or_more) or 49 | (self is Cardinality.one_or_more)) 50 | 51 | def make_pattern(self, pattern, listsep=','): 52 | """Make pattern for a data type with the specified cardinality. 53 | 54 | .. code-block:: python 55 | 56 | yes_no_pattern = r"yes|no" 57 | many_yes_no = Cardinality.one_or_more.make_pattern(yes_no_pattern) 58 | 59 | :param pattern: Regular expression for type (as string). 60 | :param listsep: List separator for multiple items (as string, optional) 61 | :return: Regular expression pattern for type with cardinality. 62 | """ 63 | if self is Cardinality.one: 64 | return pattern 65 | elif self is Cardinality.zero_or_one: 66 | return self.schema % pattern 67 | # -- OTHERWISE: 68 | return self.schema % (pattern, listsep, pattern) 69 | 70 | def compute_group_count(self, pattern): 71 | """Compute the number of regexp match groups when the pattern is provided 72 | to the :func:`Cardinality.make_pattern()` method. 73 | 74 | :param pattern: Item regexp pattern (as string). 75 | :return: Number of regexp match groups in the cardinality pattern. 76 | """ 77 | group_count = self.group_count 78 | pattern_repeated = 1 79 | if self.is_many(): 80 | pattern_repeated = 2 81 | return group_count + pattern_repeated * pattern_group_count(pattern) 82 | 83 | 84 | # ----------------------------------------------------------------------------- 85 | # CLASS: TypeBuilder 86 | # ----------------------------------------------------------------------------- 87 | class TypeBuilder(object): 88 | """Provides a utility class to build type-converters (parse_types) for parse. 89 | It supports to build new type-converters for different cardinality 90 | based on the type-converter for cardinality one. 91 | """ 92 | anything_pattern = r".+?" 93 | default_pattern = anything_pattern 94 | 95 | @classmethod 96 | def with_cardinality(cls, cardinality, converter, pattern=None, 97 | listsep=','): 98 | """Creates a type converter for the specified cardinality 99 | by using the type converter for T. 100 | 101 | :param cardinality: Cardinality to use (0..1, 0..*, 1..*). 102 | :param converter: Type converter (function) for data type T. 103 | :param pattern: Regexp pattern for an item (=converter.pattern). 104 | :return: type-converter for optional (T or None). 105 | """ 106 | if cardinality is Cardinality.one: 107 | return converter 108 | # -- NORMAL-CASE 109 | builder_func = getattr(cls, "with_%s" % cardinality.name) 110 | if cardinality is Cardinality.zero_or_one: 111 | return builder_func(converter, pattern) 112 | # -- MANY CASE: 0..*, 1..* 113 | return builder_func(converter, pattern, listsep=listsep) 114 | 115 | @classmethod 116 | def with_zero_or_one(cls, converter, pattern=None): 117 | """Creates a type converter for a T with 0..1 times 118 | by using the type converter for one item of T. 119 | 120 | :param converter: Type converter (function) for data type T. 121 | :param pattern: Regexp pattern for an item (=converter.pattern). 122 | :return: type-converter for optional (T or None). 123 | """ 124 | cardinality = Cardinality.zero_or_one 125 | if not pattern: 126 | pattern = getattr(converter, "pattern", cls.default_pattern) 127 | optional_pattern = cardinality.make_pattern(pattern) 128 | group_count = cardinality.compute_group_count(pattern) 129 | 130 | def convert_optional(text, m=None): 131 | # pylint: disable=invalid-name, unused-argument, missing-docstring 132 | if text: 133 | text = text.strip() 134 | if not text: 135 | return None 136 | return converter(text) 137 | convert_optional.pattern = optional_pattern 138 | convert_optional.regex_group_count = group_count 139 | return convert_optional 140 | 141 | @classmethod 142 | def with_zero_or_more(cls, converter, pattern=None, listsep=","): 143 | """Creates a type converter function for a list with 0..N items 144 | by using the type converter for one item of T. 145 | 146 | :param converter: Type converter (function) for data type T. 147 | :param pattern: Regexp pattern for an item (=converter.pattern). 148 | :param listsep: Optional list separator between items (default: ',') 149 | :return: type-converter for list 150 | """ 151 | cardinality = Cardinality.zero_or_more 152 | if not pattern: 153 | pattern = getattr(converter, "pattern", cls.default_pattern) 154 | many0_pattern = cardinality.make_pattern(pattern, listsep) 155 | group_count = cardinality.compute_group_count(pattern) 156 | 157 | def convert_list0(text, m=None): 158 | # pylint: disable=invalid-name, unused-argument, missing-docstring 159 | if text: 160 | text = text.strip() 161 | if not text: 162 | return [] 163 | return [converter(part.strip()) for part in text.split(listsep)] 164 | convert_list0.pattern = many0_pattern 165 | # OLD convert_list0.group_count = group_count 166 | convert_list0.regex_group_count = group_count 167 | return convert_list0 168 | 169 | @classmethod 170 | def with_one_or_more(cls, converter, pattern=None, listsep=","): 171 | """Creates a type converter function for a list with 1..N items 172 | by using the type converter for one item of T. 173 | 174 | :param converter: Type converter (function) for data type T. 175 | :param pattern: Regexp pattern for an item (=converter.pattern). 176 | :param listsep: Optional list separator between items (default: ',') 177 | :return: Type converter for list 178 | """ 179 | cardinality = Cardinality.one_or_more 180 | if not pattern: 181 | pattern = getattr(converter, "pattern", cls.default_pattern) 182 | many_pattern = cardinality.make_pattern(pattern, listsep) 183 | group_count = cardinality.compute_group_count(pattern) 184 | 185 | def convert_list(text, m=None): 186 | # pylint: disable=invalid-name, unused-argument, missing-docstring 187 | return [converter(part.strip()) for part in text.split(listsep)] 188 | convert_list.pattern = many_pattern 189 | # OLD: convert_list.group_count = group_count 190 | convert_list.regex_group_count = group_count 191 | return convert_list 192 | 193 | # -- ALIAS METHODS: 194 | @classmethod 195 | def with_optional(cls, converter, pattern=None): 196 | """Alias for :py:meth:`with_zero_or_one()` method.""" 197 | return cls.with_zero_or_one(converter, pattern) 198 | 199 | @classmethod 200 | def with_many(cls, converter, pattern=None, listsep=','): 201 | """Alias for :py:meth:`with_one_or_more()` method.""" 202 | return cls.with_one_or_more(converter, pattern, listsep) 203 | 204 | @classmethod 205 | def with_many0(cls, converter, pattern=None, listsep=','): 206 | """Alias for :py:meth:`with_zero_or_more()` method.""" 207 | return cls.with_zero_or_more(converter, pattern, listsep) 208 | -------------------------------------------------------------------------------- /parse_type/cardinality_field.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Provides support for cardinality fields. 4 | A cardinality field is a type suffix for parse format expression, ala: 5 | 6 | "{person:Person?}" #< Cardinality: 0..1 = zero or one = optional 7 | "{persons:Person*}" #< Cardinality: 0..* = zero or more = many0 8 | "{persons:Person+}" #< Cardinality: 1..* = one or more = many 9 | """ 10 | 11 | from __future__ import absolute_import 12 | import six 13 | from parse_type.cardinality import Cardinality, TypeBuilder 14 | 15 | 16 | class MissingTypeError(KeyError): # pylint: disable=missing-docstring 17 | pass 18 | 19 | # ----------------------------------------------------------------------------- 20 | # CLASS: Cardinality (Field Part) 21 | # ----------------------------------------------------------------------------- 22 | class CardinalityField(object): 23 | """Cardinality field for parse format expression, ala: 24 | 25 | "{person:Person?}" #< Cardinality: 0..1 = zero or one = optional 26 | "{persons:Person*}" #< Cardinality: 0..* = zero or more = many0 27 | "{persons:Person+}" #< Cardinality: 1..* = one or more = many 28 | """ 29 | 30 | # -- MAPPING SUPPORT: 31 | pattern_chars = "?*+" 32 | from_char_map = { 33 | '?': Cardinality.zero_or_one, 34 | '*': Cardinality.zero_or_more, 35 | '+': Cardinality.one_or_more, 36 | } 37 | to_char_map = dict([(value, key) for key, value in from_char_map.items()]) 38 | 39 | @classmethod 40 | def matches_type(cls, type_name): 41 | """Checks if a type name uses the CardinalityField naming scheme. 42 | 43 | :param type_name: Type name to check (as string). 44 | :return: True, if type name has CardinalityField name suffix. 45 | """ 46 | return type_name and type_name[-1] in CardinalityField.pattern_chars 47 | 48 | @classmethod 49 | def split_type(cls, type_name): 50 | """Split type of a type name with CardinalityField suffix into its parts. 51 | 52 | :param type_name: Type name (as string). 53 | :return: Tuple (type_basename, cardinality) 54 | """ 55 | if cls.matches_type(type_name): 56 | basename = type_name[:-1] 57 | cardinality = cls.from_char_map[type_name[-1]] 58 | else: 59 | # -- ASSUME: Cardinality.one 60 | cardinality = Cardinality.one 61 | basename = type_name 62 | return (basename, cardinality) 63 | 64 | @classmethod 65 | def make_type(cls, basename, cardinality): 66 | """Build new type name according to CardinalityField naming scheme. 67 | 68 | :param basename: Type basename of primary type (as string). 69 | :param cardinality: Cardinality of the new type (as Cardinality item). 70 | :return: Type name with CardinalityField suffix (if needed) 71 | """ 72 | if cardinality is Cardinality.one: 73 | # -- POSTCONDITION: assert not cls.make_type(type_name) 74 | return basename 75 | # -- NORMAL CASE: type with CardinalityField suffix. 76 | type_name = "%s%s" % (basename, cls.to_char_map[cardinality]) 77 | # -- POSTCONDITION: assert cls.make_type(type_name) 78 | return type_name 79 | 80 | 81 | # ----------------------------------------------------------------------------- 82 | # CLASS: CardinalityFieldTypeBuilder 83 | # ----------------------------------------------------------------------------- 84 | class CardinalityFieldTypeBuilder(object): 85 | """Utility class to create type converters based on: 86 | 87 | * the CardinalityField naming scheme and 88 | * type converter for cardinality=1 89 | """ 90 | 91 | listsep = ',' 92 | 93 | @classmethod 94 | def create_type_variant(cls, type_name, type_converter): 95 | r"""Create type variants for types with a cardinality field. 96 | The new type converters are based on the type converter with 97 | cardinality=1. 98 | 99 | .. code-block:: python 100 | 101 | import parse 102 | 103 | @parse.with_pattern(r'\d+') 104 | def parse_number(text): 105 | return int(text) 106 | 107 | new_type = CardinalityFieldTypeBuilder.create_type_variant( 108 | "Number+", parse_number) 109 | new_type = CardinalityFieldTypeBuilder.create_type_variant( 110 | "Number+", dict(Number=parse_number)) 111 | 112 | :param type_name: Type name with cardinality field suffix. 113 | :param type_converter: Type converter or type dictionary. 114 | :return: Type converter variant (function). 115 | :raises: ValueError, if type_name does not end with CardinalityField 116 | :raises: MissingTypeError, if type_converter is missing in type_dict 117 | """ 118 | assert isinstance(type_name, six.string_types) 119 | if not CardinalityField.matches_type(type_name): 120 | message = "type_name='%s' has no CardinalityField" % type_name 121 | raise ValueError(message) 122 | 123 | primary_name, cardinality = CardinalityField.split_type(type_name) 124 | if isinstance(type_converter, dict): 125 | type_dict = type_converter 126 | type_converter = type_dict.get(primary_name, None) 127 | if not type_converter: 128 | raise MissingTypeError(primary_name) 129 | 130 | assert callable(type_converter) 131 | type_variant = TypeBuilder.with_cardinality(cardinality, 132 | type_converter, 133 | listsep=cls.listsep) 134 | type_variant.name = type_name 135 | return type_variant 136 | 137 | 138 | @classmethod 139 | def create_type_variants(cls, type_names, type_dict): 140 | """Create type variants for types with a cardinality field. 141 | The new type converters are based on the type converter with 142 | cardinality=1. 143 | 144 | .. code-block:: python 145 | 146 | # -- USE: parse_number() type converter function. 147 | new_types = CardinalityFieldTypeBuilder.create_type_variants( 148 | ["Number?", "Number+"], dict(Number=parse_number)) 149 | 150 | :param type_names: List of type names with cardinality field suffix. 151 | :param type_dict: Type dictionary with named type converters. 152 | :return: Type dictionary with type converter variants. 153 | """ 154 | type_variant_dict = {} 155 | for type_name in type_names: 156 | type_variant = cls.create_type_variant(type_name, type_dict) 157 | type_variant_dict[type_name] = type_variant 158 | return type_variant_dict 159 | 160 | # MAYBE: Check if really needed. 161 | @classmethod 162 | def create_missing_type_variants(cls, type_names, type_dict): 163 | """Create missing type variants for types with a cardinality field. 164 | 165 | :param type_names: List of type names with cardinality field suffix. 166 | :param type_dict: Type dictionary with named type converters. 167 | :return: Type dictionary with missing type converter variants. 168 | """ 169 | missing_type_names = [name for name in type_names 170 | if name not in type_dict] 171 | return cls.create_type_variants(missing_type_names, type_dict) 172 | -------------------------------------------------------------------------------- /parse_type/cfparse.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Provides an extended :class:`parse.Parser` class that supports the 4 | cardinality fields in (user-defined) types. 5 | """ 6 | 7 | from __future__ import absolute_import 8 | import logging 9 | import parse 10 | from .cardinality_field import CardinalityField, CardinalityFieldTypeBuilder 11 | from .parse_util import FieldParser 12 | 13 | 14 | log = logging.getLogger(__name__) # pylint: disable=invalid-name 15 | 16 | 17 | class Parser(parse.Parser): 18 | """Provides an extended :class:`parse.Parser` with cardinality field support. 19 | A cardinality field is a type suffix for parse format expression, ala: 20 | 21 | "... {person:Person?} ..." -- OPTIONAL: Cardinality zero or one, 0..1 22 | "... {persons:Person*} ..." -- MANY0: Cardinality zero or more, 0.. 23 | "... {persons:Person+} ..." -- MANY: Cardinality one or more, 1.. 24 | 25 | When the primary type converter for cardinality=1 is provided, 26 | the type variants for the other cardinality cases can be derived from it. 27 | 28 | This parser class automatically creates missing type variants for types 29 | with a cardinality field and passes the extended type dictionary 30 | to its base class. 31 | """ 32 | # -- TYPE-BUILDER: For missing types in Fields with CardinalityField part. 33 | type_builder = CardinalityFieldTypeBuilder 34 | 35 | def __init__(self, schema, extra_types=None, case_sensitive=False, 36 | type_builder=None): 37 | """Creates a parser with CardinalityField part support. 38 | 39 | :param schema: Parse schema (or format) for parser (as string). 40 | :param extra_types: Type dictionary with type converters (or None). 41 | :param case_sensitive: Indicates if case-sensitive regexp are used. 42 | :param type_builder: Type builder to use for missing types. 43 | """ 44 | if extra_types is None: 45 | extra_types = {} 46 | missing = self.create_missing_types(schema, extra_types, type_builder) 47 | if missing: 48 | # pylint: disable=logging-not-lazy 49 | log.debug("MISSING TYPES: %s" % ",".join(missing.keys())) 50 | extra_types.update(missing) 51 | 52 | # -- FINALLY: Delegate to base class. 53 | super(Parser, self).__init__(schema, extra_types, 54 | case_sensitive=case_sensitive) 55 | 56 | @classmethod 57 | def create_missing_types(cls, schema, type_dict, type_builder=None): 58 | """Creates missing types for fields with a CardinalityField part. 59 | It is assumed that the primary type converter for cardinality=1 60 | is registered in the type dictionary. 61 | 62 | :param schema: Parse schema (or format) for parser (as string). 63 | :param type_dict: Type dictionary with type converters. 64 | :param type_builder: Type builder to use for missing types. 65 | :return: Type dictionary with missing types. Empty, if none. 66 | :raises: MissingTypeError, 67 | if a primary type converter with cardinality=1 is missing. 68 | """ 69 | if not type_builder: 70 | type_builder = cls.type_builder 71 | 72 | missing = cls.extract_missing_special_type_names(schema, type_dict) 73 | return type_builder.create_type_variants(missing, type_dict) 74 | 75 | @staticmethod 76 | def extract_missing_special_type_names(schema, type_dict): 77 | # pylint: disable=invalid-name 78 | """Extract the type names for fields with CardinalityField part. 79 | Selects only the missing type names that are not in the type dictionary. 80 | 81 | :param schema: Parse schema to use (as string). 82 | :param type_dict: Type dictionary with type converters. 83 | :return: Generator with missing type names (as string). 84 | """ 85 | for name in FieldParser.extract_types(schema): 86 | if CardinalityField.matches_type(name) and (name not in type_dict): 87 | yield name 88 | -------------------------------------------------------------------------------- /parse_type/parse_util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=missing-docstring 3 | """ 4 | Provides generic utility classes for the :class:`parse.Parser` class. 5 | """ 6 | 7 | from __future__ import absolute_import 8 | from collections import namedtuple 9 | import parse 10 | import six 11 | 12 | 13 | # -- HELPER-CLASS: For format part in a Field. 14 | # REQUIRES: Python 2.6 or newer. 15 | # pylint: disable=redefined-builtin, too-many-arguments 16 | FormatSpec = namedtuple("FormatSpec", 17 | ["type", "width", "zero", "align", "fill", "precision"]) 18 | 19 | def make_format_spec(type=None, width="", zero=False, align=None, fill=None, 20 | precision=None): 21 | return FormatSpec(type, width, zero, align, fill, precision) 22 | # pylint: enable=redefined-builtin 23 | 24 | class Field(object): 25 | """ 26 | Provides a ValueObject for a Field in a parse expression. 27 | 28 | Examples: 29 | * "{}" 30 | * "{name}" 31 | * "{:format}" 32 | * "{name:format}" 33 | 34 | Format specification: [[fill]align][0][width][.precision][type] 35 | """ 36 | # pylint: disable=redefined-builtin 37 | ALIGN_CHARS = '<>=^' 38 | 39 | def __init__(self, name="", format=None): 40 | self.name = name 41 | self.format = format 42 | self._format_spec = None 43 | 44 | def set_format(self, format): 45 | self.format = format 46 | self._format_spec = None 47 | 48 | @property 49 | def has_format(self): 50 | return bool(self.format) 51 | 52 | @property 53 | def format_spec(self): 54 | if not self._format_spec and self.format: 55 | self._format_spec = self.extract_format_spec(self.format) 56 | return self._format_spec 57 | 58 | def __str__(self): 59 | name = self.name or "" 60 | if self.has_format: 61 | return "{%s:%s}" % (name, self.format) 62 | return "{%s}" % name 63 | 64 | def __eq__(self, other): 65 | if isinstance(other, Field): 66 | format1 = self.format or "" 67 | format2 = other.format or "" 68 | return (self.name == other.name) and (format1 == format2) 69 | elif isinstance(other, six.string_types): 70 | return str(self) == other 71 | else: 72 | raise ValueError(other) 73 | 74 | def __ne__(self, other): 75 | return not self.__eq__(other) 76 | 77 | @staticmethod 78 | def make_format(format_spec): 79 | """Build format string from a format specification. 80 | 81 | :param format_spec: Format specification (as FormatSpec object). 82 | :return: Composed format (as string). 83 | """ 84 | fill = '' 85 | align = '' 86 | zero = '' 87 | width = format_spec.width 88 | if format_spec.align: 89 | align = format_spec.align[0] 90 | if format_spec.fill: 91 | fill = format_spec.fill[0] 92 | if format_spec.zero: 93 | zero = '0' 94 | 95 | precision_part = "" 96 | if format_spec.precision: 97 | precision_part = ".%s" % format_spec.precision 98 | 99 | # -- FORMAT-SPEC: [[fill]align][0][width][.precision][type] 100 | return "%s%s%s%s%s%s" % (fill, align, zero, width, 101 | precision_part, format_spec.type) 102 | 103 | 104 | @classmethod 105 | def extract_format_spec(cls, format): 106 | """Pull apart the format: [[fill]align][0][width][.precision][type]""" 107 | # -- BASED-ON: parse.extract_format() 108 | # pylint: disable=redefined-builtin, unsubscriptable-object 109 | if not format: 110 | raise ValueError("INVALID-FORMAT: %s (empty-string)" % format) 111 | 112 | orig_format = format 113 | fill = align = None 114 | if format[0] in cls.ALIGN_CHARS: 115 | align = format[0] 116 | format = format[1:] 117 | elif len(format) > 1 and format[1] in cls.ALIGN_CHARS: 118 | fill = format[0] 119 | align = format[1] 120 | format = format[2:] 121 | 122 | zero = False 123 | if format and format[0] == '0': 124 | zero = True 125 | format = format[1:] 126 | 127 | width = '' 128 | while format: 129 | if not format[0].isdigit(): 130 | break 131 | width += format[0] 132 | format = format[1:] 133 | 134 | precision = None 135 | if format.startswith('.'): 136 | # Precision isn't needed but we need to capture it so that 137 | # the ValueError isn't raised. 138 | format = format[1:] # drop the '.' 139 | precision = '' 140 | while format: 141 | if not format[0].isdigit(): 142 | break 143 | precision += format[0] 144 | format = format[1:] 145 | 146 | # the rest is the type, if present 147 | type = format 148 | if not type: 149 | raise ValueError("INVALID-FORMAT: %s (without type)" % orig_format) 150 | return FormatSpec(type, width, zero, align, fill, precision) 151 | 152 | 153 | class FieldParser(object): 154 | """ 155 | Utility class that parses/extracts fields in parse expressions. 156 | """ 157 | 158 | @classmethod 159 | def parse(cls, text): 160 | if not (text.startswith('{') and text.endswith('}')): 161 | message = "FIELD-SCHEMA MISMATCH: text='%s' (missing braces)" % text 162 | raise ValueError(message) 163 | 164 | # first: lose the braces 165 | text = text[1:-1] 166 | if ':' in text: 167 | # -- CASE: Typed field with format. 168 | name, format_ = text.split(':') 169 | else: 170 | name = text 171 | format_ = None 172 | return Field(name, format_) 173 | 174 | @classmethod 175 | def extract_fields(cls, schema): 176 | """Extract fields in a parse expression schema. 177 | 178 | :param schema: Parse expression schema/format to use (as string). 179 | :return: Generator for fields in schema (as Field objects). 180 | """ 181 | # -- BASED-ON: parse.Parser._generate_expression() 182 | for part in parse.PARSE_RE.split(schema): 183 | if not part or part == '{{' or part == '}}': 184 | continue 185 | elif part[0] == '{': 186 | # this will be a braces-delimited field to handle 187 | yield cls.parse(part) 188 | 189 | @classmethod 190 | def extract_types(cls, schema): 191 | """Extract types (names) for typed fields (with format/type part). 192 | 193 | :param schema: Parser schema/format to use. 194 | :return: Generator for type names (as string). 195 | """ 196 | for field in cls.extract_fields(schema): 197 | if field.has_format: 198 | yield field.format_spec.type 199 | -------------------------------------------------------------------------------- /py.requirements/all.txt: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # BEHAVE: PYTHON PACKAGE REQUIREMENTS: All requirements 3 | # ============================================================================ 4 | # DESCRIPTION: 5 | # pip install -r 6 | # 7 | # SEE ALSO: 8 | # * http://www.pip-installer.org/ 9 | # ============================================================================ 10 | 11 | -r basic.txt 12 | -r packaging.txt 13 | -r develop.txt 14 | -r testing.txt 15 | -------------------------------------------------------------------------------- /py.requirements/basic.txt: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # PYTHON PACKAGE REQUIREMENTS: Normal usage/installation (minimal) 3 | # ============================================================================ 4 | # DESCRIPTION: 5 | # pip install -r 6 | # 7 | # SEE ALSO: 8 | # * http://www.pip-installer.org/ 9 | # ============================================================================ 10 | 11 | parse >= 1.18.0; python_version >= '3.0' 12 | parse >= 1.13.1; python_version <= '2.7' 13 | enum34; python_version < '3.4' 14 | six >= 1.15 15 | -------------------------------------------------------------------------------- /py.requirements/ci.github.testing.txt: -------------------------------------------------------------------------------- 1 | pytest < 5.0; python_version < '3.0' 2 | pytest >= 5.0; python_version >= '3.0' 3 | pytest-html >= 1.19.0 4 | 5 | # -- NEEDED: By some tests (as proof of concept) 6 | # NOTE: path.py-10.1 is required for python2.6 7 | # HINT: path.py => path (python-install-package was renamed for python3) 8 | # DISABLED: path.py >= 11.5.0; python_version < '3.5' 9 | # DISABLED: path >= 13.1.0; python_version >= '3.5' 10 | 11 | -r basic.txt 12 | -------------------------------------------------------------------------------- /py.requirements/develop.txt: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # PYTHON PACKAGE REQUIREMENTS FOR: parse_type -- For development only 3 | # ============================================================================ 4 | 5 | # -- BUILD-SYSTEM SUPPORT: Using invoke 6 | -r ../tasks/py.requirements.txt 7 | 8 | # -- RELEASE MANAGEMENT: Push package to pypi. 9 | twine >= 1.13.0 10 | -r packaging.txt 11 | 12 | # -- PYTHON2/PYTHON3 COMPATIBILITY: 13 | modernize >= 0.5 14 | 15 | # -- PYTHON 3 TYPE HINTS: 16 | typing-extensions; python_version >= '3.8' 17 | typer >= 0.12.5; python_version >= '3.7' 18 | 19 | # -- MULTI-REPO TOOL: 20 | vcstool >= 0.3.0 21 | 22 | # -- LINTERS: 23 | ruff; python_version >= '3.7' 24 | pylint 25 | 26 | # -- TEST SUPPORT: CODE COVERAGE SUPPORT, ... 27 | coverage >= 4.4 28 | pytest-cov 29 | 30 | tox >= 1.8.1,<4.0 # -- HINT: tox >= 4.0 has breaking changes. 31 | virtualenv < 20.22.0; python_version <= '3.6' # -- SUPPORT FOR: Python 2.7, Python <= 3.6 32 | virtualenv >= 20.0.0; python_version > '3.6' 33 | argparse # -- NEEDED-FOR: toxcmd.py 34 | 35 | # -- RELATED: 36 | -r testing.txt 37 | -r docs.txt 38 | -------------------------------------------------------------------------------- /py.requirements/docs.txt: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # PYTHON PACKAGE REQUIREMENTS: For documentation generation (PREPARED) 3 | # ============================================================================ 4 | 5 | Sphinx >=1.6 6 | sphinx_bootstrap_theme >= 0.6.0 7 | -------------------------------------------------------------------------------- /py.requirements/optional.txt: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # PYTHON PACKAGE REQUIREMENTS FOR: parse_type -- Optional for development 3 | # ============================================================================ 4 | 5 | # -- GIT MULTI-REPO TOOL: wstool 6 | # REQUIRES: wstool >= 0.1.18 (which is not in pypi.org, yet) 7 | https://github.com/vcstools/wstool/archive/0.1.18.zip 8 | -------------------------------------------------------------------------------- /py.requirements/packaging.txt: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # PYTHON PACKAGE REQUIREMENTS: packaging support 3 | # ============================================================================ 4 | # DESCRIPTION: 5 | # pip install -r 6 | # 7 | # SEE ALSO: 8 | # * http://www.pip-installer.org/ 9 | # ============================================================================ 10 | 11 | # -- PACKAGING SUPPORT: 12 | build >= 0.5.1 13 | setuptools 14 | setuptools-scm 15 | wheel 16 | 17 | # -- DISABLED: 18 | # setuptools >= 64.0.0; python_version >= '3.5' 19 | # setuptools < 45.0.0; python_version < '3.5' # DROP: Python2, Python 3.4 support. 20 | # setuptools_scm >= 8.0.0; python_version >= '3.7' 21 | # setuptools_scm < 8.0.0; python_version < '3.7' 22 | -------------------------------------------------------------------------------- /py.requirements/testing.txt: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # PYTHON PACKAGE REQUIREMENTS FOR: parse_type -- For testing only 3 | # ============================================================================ 4 | 5 | pytest < 5.0; python_version < '3.0' 6 | pytest >= 5.0; python_version >= '3.0' 7 | pytest-html >= 1.19.0 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # ============================================================================= 2 | # PACKAGING: parse_type 3 | # ============================================================================= 4 | # SEE ALSO: 5 | # * https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html 6 | # * https://setuptools-scm.readthedocs.io/en/latest/usage/ 7 | # * https://pypi.org/classifiers/ 8 | # ============================================================================= 9 | # PYTHON3: requires = ["setuptools>=64", "setuptools_scm>=8", "wheel"] 10 | [build-system] 11 | requires = ["setuptools", "setuptools_scm", "wheel"] 12 | build-backend = "setuptools.build_meta" 13 | 14 | 15 | [project] 16 | name = "parse_type" 17 | authors = [ 18 | {name = "Jens Engel", email = "jenisys@noreply.github.com"}, 19 | ] 20 | description = "Simplifies to build parse types based on the parse module" 21 | dynamic = ["version"] 22 | readme = "README.rst" 23 | requires-python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 24 | keywords = ["parse", "parsing"] 25 | license = {text = "MIT"} 26 | classifiers = [ 27 | "Development Status :: 5 - Production/Stable", 28 | "Environment :: Console", 29 | "Environment :: Web Environment", 30 | "Intended Audience :: Developers", 31 | "License :: OSI Approved :: MIT License", 32 | "Operating System :: OS Independent", 33 | "Programming Language :: Python :: 2.7", 34 | "Programming Language :: Python :: 3.2", 35 | "Programming Language :: Python :: 3.3", 36 | "Programming Language :: Python :: 3.4", 37 | "Programming Language :: Python :: 3.5", 38 | "Programming Language :: Python :: 3.6", 39 | "Programming Language :: Python :: 3.7", 40 | "Programming Language :: Python :: 3.8", 41 | "Programming Language :: Python :: 3.9", 42 | "Programming Language :: Python :: 3.10", 43 | "Programming Language :: Python :: 3.11", 44 | "Programming Language :: Python :: Implementation :: CPython", 45 | "Programming Language :: Python :: Implementation :: PyPy", 46 | "Topic :: Software Development :: Code Generators", 47 | "Topic :: Software Development :: Libraries :: Python Modules", 48 | ] 49 | dependencies = [ 50 | "parse >= 1.18.0; python_version >= '3.0'", 51 | "parse >= 1.13.1; python_version <= '2.7'", 52 | "enum34; python_version < '3.4'", 53 | "six >= 1.15", 54 | ] 55 | 56 | 57 | [project.urls] 58 | Homepage = "https://github.com/jenisys/parse_type" 59 | Download = "https://pypi.org/project/parse_type/" 60 | "Source Code" = "https://github.com/jenisys/parse_type" 61 | "Issue Tracker" = "https://github.com/jenisys/parse_type/issues/" 62 | 63 | 64 | [project.optional-dependencies] 65 | develop = [ 66 | # -- DISABLED: 67 | # "setuptools >= 64.0.0; python_version >= '3.5'", 68 | # "setuptools < 45.0.0; python_version < '3.5'", # DROP: Python2, Python 3.4 support. 69 | # "setuptools_scm >= 8.0.0; python_version >= '3.7'", 70 | # "setuptools_scm < 8.0.0; python_version < '3.7'", 71 | "setuptools", 72 | "setuptools-scm", 73 | "wheel", 74 | "build >= 0.5.1", 75 | "twine >= 1.13.0", 76 | "coverage >= 4.4", 77 | "pytest < 5.0; python_version < '3.0'", # >= 4.2 78 | "pytest >= 5.0; python_version >= '3.0'", 79 | "pytest-html >= 1.19.0", 80 | "pytest-cov", 81 | "tox >=2.8,<4.0", 82 | "virtualenv < 20.22.0; python_version <= '3.6'", # -- SUPPORT FOR: Python 2.7, Python <= 3.6 83 | "virtualenv >= 20.0.0; python_version > '3.6'", 84 | "ruff; python_version >= '3.7'", 85 | "pylint", 86 | ] 87 | docs = [ 88 | "Sphinx >=1.6", 89 | "sphinx_bootstrap_theme >= 0.6.0" 90 | ] 91 | testing = [ 92 | "pytest < 5.0; python_version < '3.0'", # >= 4.2 93 | "pytest >= 5.0; python_version >= '3.0'", 94 | "pytest-html >= 1.19.0", 95 | ] 96 | 97 | 98 | [tool.distutils.bdist_wheel] 99 | universal = true 100 | 101 | 102 | # ----------------------------------------------------------------------------- 103 | # PACAKING TOOL SPECIFIC PARTS: 104 | # ----------------------------------------------------------------------------- 105 | [tool.setuptools] 106 | platforms = ["any"] 107 | zip-safe = true 108 | 109 | # -- DISABLED: 110 | # [tool.setuptools.dynamic] 111 | # version = {attr = "parse_type._version.version"} 112 | 113 | [tool.setuptools.packages.find] 114 | where = ["."] 115 | include = ["parse_type*"] 116 | exclude = ["tests*"] 117 | namespaces = false 118 | 119 | # -- SETUPTOOLS-SCM: Generate version info from git-tag(s). 120 | [tool.setuptools_scm] 121 | version_file = "parse_type/_version.py" 122 | 123 | 124 | # ============================================================================= 125 | # OTHER TOOLS 126 | # ============================================================================= 127 | [tool.black] 128 | line_length = 100 129 | target-version = ['py38'] 130 | include = '\.pyi?$' 131 | exclude = ''' 132 | ( 133 | /( 134 | \.git 135 | | \.venv 136 | | \.netbox 137 | | \.vscode 138 | | configuration 139 | )/ 140 | ) 141 | ''' 142 | 143 | [tool.isort] 144 | profile = "black" 145 | multi_line_output = 3 146 | line_length = 100 147 | 148 | 149 | # ----------------------------------------------------------------------------- 150 | # PYLINT: 151 | # ----------------------------------------------------------------------------- 152 | [tool.pylint.messages_control] 153 | disable = "C0330, C0326" 154 | 155 | [tool.pylint.format] 156 | max-line-length = "100" 157 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # PYTEST CONFIGURATION FILE: pytest.ini 3 | # ============================================================================ 4 | # SEE ALSO: 5 | # * http://pytest.org/ 6 | # * http://pytest.org/latest/customize.html 7 | # * http://pytest.org/latest/usage.html 8 | # * http://pytest.org/latest/example/pythoncollection.html#change-naming-conventions 9 | # ============================================================================ 10 | # MORE OPTIONS: 11 | # addopts = 12 | # python_classes=*Test 13 | # python_functions=test 14 | # ============================================================================ 15 | 16 | [pytest] 17 | minversion = 4.2 18 | testpaths = tests 19 | python_files = test_*.py 20 | junit_family = xunit2 21 | addopts = --metadata PACKAGE_UNDER_TEST parse_type 22 | --html=build/testing/report.html --self-contained-html 23 | --junit-xml=build/testing/report.xml 24 | 25 | # markers = 26 | # smoke 27 | # slow 28 | 29 | # -- PREPARED: 30 | # filterwarnings = 31 | # ignore:.*invalid escape sequence.*:DeprecationWarning 32 | 33 | # -- BACKWARD COMPATIBILITY: pytest < 2.8 34 | norecursedirs = .git .tox build dist .venv* tmp* _* 35 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [upload_docs] 2 | upload_dir = build/docs/html 3 | 4 | [bdist_wheel] 5 | universal = 1 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Setup script for "parse_type" package. 5 | 6 | USAGE: 7 | pip install . 8 | 9 | SEE ALSO: 10 | 11 | * https://pypi.org/pypi/parse_type 12 | * https://github.com/jenisys/parse_type 13 | 14 | RELATED: 15 | 16 | * https://setuptools.readthedocs.io/en/latest/history.html 17 | * https://setuptools-scm.readthedocs.io/en/latest/usage/ 18 | """ 19 | 20 | import sys 21 | import os.path 22 | sys.path.insert(0, os.curdir) 23 | 24 | # -- USE: setuptools 25 | from setuptools import setup, find_packages 26 | # DISABLED: from setuptools_scm import ScmVersion 27 | 28 | 29 | 30 | # ----------------------------------------------------------------------------- 31 | # PREPARE SETUP: 32 | # ----------------------------------------------------------------------------- 33 | HERE = os.path.dirname(__file__) 34 | README = os.path.join(HERE, "README.rst") 35 | long_description = ''.join(open(README).readlines()[4:]) 36 | 37 | 38 | # ----------------------------------------------------------------------------- 39 | # UTILITY: 40 | # ----------------------------------------------------------------------------- 41 | def find_packages_by_root_package(where): 42 | """Better than excluding everything that is not needed, 43 | collect only what is needed. 44 | """ 45 | root_package = os.path.basename(where) 46 | packages = [ "%s.%s" % (root_package, sub_package) 47 | for sub_package in find_packages(where)] 48 | packages.insert(0, root_package) 49 | return packages 50 | 51 | 52 | # -- SEE: https://setuptools-scm.readthedocs.io/en/latest/customizing/ 53 | # HINT: get_version_func(version: ScmVersion) -> str: 54 | def get_this_package_version(version): 55 | from setuptools_scm.version import guess_next_version 56 | if version.distance is None: 57 | # -- FIX: Python 2.7 problem w/ setuptools-scm v5.0.2 58 | version.distance = 0 59 | return version.format_next_version(guess_next_version, "{guessed}b{distance}") 60 | 61 | 62 | # ----------------------------------------------------------------------------- 63 | # SETUP: 64 | # ----------------------------------------------------------------------------- 65 | setup( 66 | name = "parse_type", 67 | # DISABLED: version = "0.6.3", 68 | use_scm_version={"version_scheme": get_this_package_version}, 69 | author = "Jens Engel", 70 | author_email = "jenisys@noreply.github.com", 71 | url = "https://github.com/jenisys/parse_type", 72 | download_url= "http://pypi.python.org/pypi/parse_type", 73 | description = "Simplifies to build parse types based on the parse module", 74 | long_description = long_description, 75 | keywords= "parse, parsing", 76 | license = "MIT", 77 | packages = find_packages_by_root_package("parse_type"), 78 | include_package_data = True, 79 | 80 | # -- REQUIREMENTS: 81 | python_requires=">=2.7, !=3.0.*, !=3.1.*", 82 | setup_requires=[ 83 | # -- DISABLED: 84 | # "setuptools >= 64.0.0; python_version >= '3.5'", 85 | # "setuptools < 45.0.0; python_version < '3.5'", # DROP: Python2, Python 3.4 support. 86 | # "setuptools_scm >= 8.0.0; python_version >= '3.7'", 87 | # "setuptools_scm < 8.0.0; python_version < '3.7'", 88 | "setuptools", 89 | "setuptools-scm", 90 | "wheel", 91 | ], 92 | install_requires=[ 93 | "parse >= 1.18.0; python_version >= '3.0'", 94 | "parse >= 1.13.1; python_version <= '2.7'", 95 | "enum34; python_version < '3.4'", 96 | "six >= 1.15", 97 | ], 98 | tests_require=[ 99 | "pytest < 5.0; python_version < '3.0'", # >= 4.2 100 | "pytest >= 5.0; python_version >= '3.0'", 101 | "pytest-html >= 1.19.0", 102 | ], 103 | extras_require={ 104 | "docs": [ 105 | "Sphinx >=1.6", 106 | "sphinx_bootstrap_theme >= 0.6.0" 107 | ], 108 | "develop": [ 109 | "build >= 0.5.1", 110 | "twine >= 1.13.0", 111 | "coverage >= 4.4", 112 | "pytest < 5.0; python_version < '3.0'", # >= 4.2 113 | "pytest >= 5.0; python_version >= '3.0'", 114 | "pytest-html >= 1.19.0", 115 | "pytest-cov", 116 | "tox >=2.8,<4.0", 117 | "virtualenv < 20.22.0; python_version <= '3.6'", # -- SUPPORT FOR: Python 2.7, Python <= 3.6 118 | "virtualenv >= 20.0.0; python_version > '3.6'", 119 | "ruff; python_version >= '3.7'", 120 | "pylint", 121 | ], 122 | }, 123 | 124 | test_suite = "tests", 125 | test_loader = "setuptools.command.test:ScanningLoader", 126 | zip_safe = True, 127 | 128 | classifiers = [ 129 | "Development Status :: 5 - Production/Stable", 130 | "Environment :: Console", 131 | "Environment :: Web Environment", 132 | "Intended Audience :: Developers", 133 | "License :: OSI Approved :: MIT License", 134 | "Operating System :: OS Independent", 135 | "Programming Language :: Python :: 2.7", 136 | "Programming Language :: Python :: 3.2", 137 | "Programming Language :: Python :: 3.3", 138 | "Programming Language :: Python :: 3.4", 139 | "Programming Language :: Python :: 3.5", 140 | "Programming Language :: Python :: 3.6", 141 | "Programming Language :: Python :: 3.7", 142 | "Programming Language :: Python :: 3.8", 143 | "Programming Language :: Python :: 3.9", 144 | "Programming Language :: Python :: 3.10", 145 | "Programming Language :: Python :: 3.11", 146 | "Programming Language :: Python :: Implementation :: CPython", 147 | "Programming Language :: Python :: Implementation :: PyPy", 148 | "Topic :: Software Development :: Code Generators", 149 | "Topic :: Software Development :: Libraries :: Python Modules", 150 | ], 151 | platforms = ['any'], 152 | ) 153 | -------------------------------------------------------------------------------- /tasks/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # pylint: disable=wrong-import-position, wrong-import-order 3 | """ 4 | Invoke build script. 5 | Show all tasks with:: 6 | 7 | invoke -l 8 | 9 | .. seealso:: 10 | 11 | * http://pyinvoke.org 12 | * https://github.com/pyinvoke/invoke 13 | """ 14 | 15 | from __future__ import absolute_import, print_function 16 | 17 | # ----------------------------------------------------------------------------- 18 | # IMPORTS: 19 | # ----------------------------------------------------------------------------- 20 | import sys 21 | from invoke import Collection 22 | 23 | # -- TASK-LIBRARY: 24 | import invoke_cleanup as cleanup 25 | from . import test 26 | from . import release 27 | # DISABLED: from . import docs 28 | 29 | # ----------------------------------------------------------------------------- 30 | # TASKS: 31 | # ----------------------------------------------------------------------------- 32 | # None 33 | 34 | 35 | # ----------------------------------------------------------------------------- 36 | # TASK CONFIGURATION: 37 | # ----------------------------------------------------------------------------- 38 | namespace = Collection() 39 | namespace.add_collection(Collection.from_module(cleanup), name="cleanup") 40 | namespace.add_collection(Collection.from_module(test)) 41 | namespace.add_collection(Collection.from_module(release)) 42 | # -- DISABLED: namespace.add_collection(Collection.from_module(docs)) 43 | namespace.configure({ 44 | "tasks": { 45 | "auto_dash_names": False 46 | } 47 | }) 48 | 49 | # -- ENSURE: python cleanup is used for this project. 50 | cleanup.cleanup_tasks.add_task(cleanup.clean_python) 51 | 52 | # -- INJECT: clean configuration into this namespace 53 | namespace.configure(cleanup.namespace.configuration()) 54 | if sys.platform.startswith("win"): 55 | # -- OVERRIDE SETTINGS: For platform=win32, ... (Windows) 56 | from ._compat_shutil import which 57 | run_settings = dict(echo=True, pty=False, shell=which("cmd")) 58 | namespace.configure({"run": run_settings}) 59 | else: 60 | namespace.configure({"run": dict(echo=True, pty=True)}) 61 | -------------------------------------------------------------------------------- /tasks/_compat_shutil.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # pylint: disable=unused-import 3 | # PYTHON VERSION COMPATIBILITY HELPER 4 | 5 | try: 6 | from shutil import which # -- SINCE: Python 3.3 7 | except ImportError: 8 | from backports.shutil_which import which 9 | -------------------------------------------------------------------------------- /tasks/docs.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Provides tasks to build documentation with sphinx, etc. 4 | """ 5 | 6 | from __future__ import absolute_import, print_function 7 | import os 8 | import sys 9 | from invoke import task, Collection 10 | from invoke.util import cd 11 | from path import Path 12 | 13 | # -- TASK-LIBRARY: 14 | from ._tasklet_cleanup import cleanup_tasks, cleanup_dirs 15 | 16 | 17 | # ----------------------------------------------------------------------------- 18 | # CONSTANTS: 19 | # ----------------------------------------------------------------------------- 20 | SPHINX_LANGUAGE_DEFAULT = os.environ.get("SPHINX_LANGUAGE", "en") 21 | 22 | 23 | # ----------------------------------------------------------------------------- 24 | # UTILTITIES: 25 | # ----------------------------------------------------------------------------- 26 | def _sphinxdoc_get_language(ctx, language=None): 27 | language = language or ctx.config.sphinx.language or SPHINX_LANGUAGE_DEFAULT 28 | return language 29 | 30 | 31 | def _sphinxdoc_get_destdir(ctx, builder, language=None): 32 | if builder == "gettext": 33 | # -- CASE: not LANGUAGE-SPECIFIC 34 | destdir = Path(ctx.config.sphinx.destdir or "build")/builder 35 | else: 36 | # -- CASE: LANGUAGE-SPECIFIC: 37 | language = _sphinxdoc_get_language(ctx, language) 38 | destdir = Path(ctx.config.sphinx.destdir or "build")/builder/language 39 | return destdir 40 | 41 | 42 | # ----------------------------------------------------------------------------- 43 | # TASKS: 44 | # ----------------------------------------------------------------------------- 45 | @task 46 | def clean(ctx, dry_run=False): 47 | """Cleanup generated document artifacts.""" 48 | basedir = ctx.sphinx.destdir or "build/docs" 49 | cleanup_dirs([basedir], dry_run=dry_run) 50 | 51 | 52 | @task(help={ 53 | "builder": "Builder to use (html, ...)", 54 | "language": "Language to use (en, ...)", 55 | "options": "Additional options for sphinx-build", 56 | }) 57 | def build(ctx, builder="html", language=None, options=""): 58 | """Build docs with sphinx-build""" 59 | language = _sphinxdoc_get_language(ctx, language) 60 | sourcedir = ctx.config.sphinx.sourcedir 61 | destdir = _sphinxdoc_get_destdir(ctx, builder, language=language) 62 | destdir = destdir.abspath() 63 | with cd(sourcedir): 64 | destdir_relative = Path(".").relpathto(destdir) 65 | command = "sphinx-build {opts} -b {builder} -D language={language} {sourcedir} {destdir}" \ 66 | .format(builder=builder, sourcedir=".", 67 | destdir=destdir_relative, 68 | language=language, 69 | opts=options) 70 | ctx.run(command) 71 | 72 | @task(help={ 73 | "builder": "Builder to use (html, ...)", 74 | "language": "Language to use (en, ...)", 75 | "options": "Additional options for sphinx-build", 76 | }) 77 | def rebuild(ctx, builder="html", language=None, options=""): 78 | """Rebuilds the docs. 79 | Perform the steps: clean, build 80 | """ 81 | clean(ctx) 82 | build(ctx, builder=builder, language=None, options=options) 83 | 84 | @task 85 | def linkcheck(ctx): 86 | """Check if all links are corect.""" 87 | build(ctx, builder="linkcheck") 88 | 89 | @task(help={"language": "Language to use (en, ...)"}) 90 | def browse(ctx, language=None): 91 | """Open documentation in web browser.""" 92 | output_dir = _sphinxdoc_get_destdir(ctx, "html", language=language) 93 | page_html = Path(output_dir)/"index.html" 94 | if not page_html.exists(): 95 | build(ctx, builder="html") 96 | assert page_html.exists() 97 | open_cmd = "open" # -- WORKS ON: MACOSX 98 | if sys.platform.startswith("win"): 99 | open_cmd = "start" 100 | ctx.run("{open} {page_html}".format(open=open_cmd, page_html=page_html)) 101 | # ctx.run('python -m webbrowser -t {page_html}'.format(page_html=page_html)) 102 | # -- DISABLED: 103 | # import webbrowser 104 | # print("Starting webbrowser with page=%s" % page_html) 105 | # webbrowser.open(str(page_html)) 106 | 107 | 108 | @task(help={ 109 | "dest": "Destination directory to save docs", 110 | "format": "Format/Builder to use (html, ...)", 111 | "language": "Language to use (en, ...)", 112 | }) 113 | # pylint: disable=redefined-builtin 114 | def save(ctx, dest="docs.html", format="html", language=None): 115 | """Save/update docs under destination directory.""" 116 | print("STEP: Generate docs in HTML format") 117 | build(ctx, builder=format, language=language) 118 | 119 | print("STEP: Save docs under %s/" % dest) 120 | source_dir = Path(_sphinxdoc_get_destdir(ctx, format, language=language)) 121 | Path(dest).rmtree_p() 122 | source_dir.copytree(dest) 123 | 124 | # -- POST-PROCESSING: Polish up. 125 | for part in [".buildinfo", ".doctrees"]: 126 | partpath = Path(dest)/part 127 | if partpath.isdir(): 128 | partpath.rmtree_p() 129 | elif partpath.exists(): 130 | partpath.remove_p() 131 | 132 | 133 | @task(help={ 134 | "language": 'Language to use, like "en" (default: "all" to build all).', 135 | }) 136 | def update_translation(ctx, language="all"): 137 | """Update sphinx-doc translation(s) messages from the "English" docs. 138 | 139 | * Generates gettext *.po files in "build/docs/gettext/" directory 140 | * Updates/generates gettext *.po per language in "docs/LOCALE/{language}/" 141 | 142 | .. note:: Afterwards, the missing message translations can be filled in. 143 | 144 | :param language: Indicate which language messages to update (or "all"). 145 | 146 | REQUIRES: 147 | 148 | * sphinx 149 | * sphinx-intl >= 0.9 150 | 151 | .. seealso:: https://github.com/sphinx-doc/sphinx-intl 152 | """ 153 | if language == "all": 154 | # -- CASE: Process/update all support languages (translations). 155 | DEFAULT_LANGUAGES = os.environ.get("SPHINXINTL_LANGUAGE", None) 156 | if DEFAULT_LANGUAGES: 157 | # -- EXAMPLE: SPHINXINTL_LANGUAGE="de,ja" 158 | DEFAULT_LANGUAGES = DEFAULT_LANGUAGES.split(",") 159 | languages = ctx.config.sphinx.languages or DEFAULT_LANGUAGES 160 | else: 161 | # -- CASE: Process only one language (translation use case). 162 | languages = [language] 163 | 164 | # -- STEP: Generate *.po/*.pot files w/ sphinx-build -b gettext 165 | build(ctx, builder="gettext") 166 | 167 | # -- STEP: Update *.po/*.pot files w/ sphinx-intl 168 | if languages: 169 | gettext_build_dir = _sphinxdoc_get_destdir(ctx, "gettext").abspath() 170 | docs_sourcedir = ctx.config.sphinx.sourcedir 171 | languages_opts = "-l "+ " -l ".join(languages) 172 | with ctx.cd(docs_sourcedir): 173 | ctx.run("sphinx-intl update -p {gettext_dir} {languages}".format( 174 | gettext_dir=gettext_build_dir.relpath(docs_sourcedir), 175 | languages=languages_opts)) 176 | else: 177 | print("OOPS: No languages specified (use: SPHINXINTL_LANGUAGE=...)") 178 | 179 | 180 | # ----------------------------------------------------------------------------- 181 | # TASK CONFIGURATION: 182 | # ----------------------------------------------------------------------------- 183 | namespace = Collection(clean, rebuild, linkcheck, browse, save, update_translation) 184 | namespace.add_task(build, default=True) 185 | namespace.configure({ 186 | "sphinx": { 187 | # -- FOR TASKS: docs.build, docs.rebuild, docs.clean, ... 188 | "language": SPHINX_LANGUAGE_DEFAULT, 189 | "sourcedir": "docs", 190 | "destdir": "build/docs", 191 | # -- FOR TASK: docs.update_translation 192 | "languages": None, # -- List of language translations, like: de, ja, ... 193 | } 194 | }) 195 | 196 | # -- ADD CLEANUP TASK: 197 | cleanup_tasks.add_task(clean, "clean_docs") 198 | cleanup_tasks.configure(namespace.configuration()) 199 | -------------------------------------------------------------------------------- /tasks/invoke_dry_run.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Basic support to use a --dry-run mode w/ invoke tasks. 4 | 5 | .. code-block:: 6 | 7 | from ._dry_run import DryRunContext 8 | 9 | @task 10 | def destroy_something(ctx, path, dry_run=False): 11 | if dry_run: 12 | ctx = DryRunContext(ctx) 13 | 14 | # -- DRY-RUN MODE: Only echos commands. 15 | ctx.run("rm -rf {}".format(path)) 16 | """ 17 | 18 | from __future__ import print_function 19 | from contextlib import contextmanager 20 | 21 | @contextmanager 22 | def dry_run_mode(ctx): 23 | """Contextmanages/scope-guard that switches into dry-run mode. 24 | Afterwards the original mode is restored. 25 | 26 | .. code-block:: python 27 | 28 | with dry_run_mode(ctx): 29 | ctx.run(...) 30 | """ 31 | # -- SETUP PHASE: 32 | initial_dry_run = ctx.config.run.dry 33 | ctx.config.run.dry = True 34 | yield ctx 35 | # -- CLEANUP PHASE: 36 | ctx.config.run.dry = initial_dry_run 37 | 38 | 39 | class DryRunContext(object): 40 | PREFIX = "DRY-RUN: " 41 | SCHEMA = "{prefix}{command}" 42 | SCHEMA_WITH_KWARGS = "{prefix}{command} (with kwargs={kwargs})" 43 | 44 | def __init__(self, ctx=None, prefix=None, schema=None): 45 | if prefix is None: 46 | prefix = self.PREFIX 47 | if schema is None: 48 | schema = self.SCHEMA 49 | 50 | self.ctx = ctx 51 | self.prefix = prefix 52 | self.schema = schema 53 | self.ctx.config.run.dry = True 54 | 55 | @property 56 | def config(self): 57 | return self.ctx.config 58 | 59 | def run(self, command, **kwargs): 60 | message = self.schema.format(command=command, 61 | prefix=self.prefix, 62 | kwargs=kwargs) 63 | print(message) 64 | 65 | 66 | def sudo(self, command, **kwargs): 67 | command2 = "sudo %s" % command 68 | self.run(command2, **kwargs) 69 | -------------------------------------------------------------------------------- /tasks/py.requirements.txt: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # INVOKE PYTHON PACKAGE REQUIREMENTS: For tasks 3 | # ============================================================================ 4 | # DESCRIPTION: 5 | # pip install -r 6 | # 7 | # SEE ALSO: 8 | # * http://www.pip-installer.org/ 9 | # ============================================================================ 10 | 11 | invoke >=1.7.0,<2.0; python_version < '3.6' 12 | invoke >=1.7.0; python_version >= '3.6' 13 | pycmd 14 | six >= 1.15.0 15 | 16 | # -- HINT, was RENAMED: path.py => path (for python3) 17 | path >= 13.1.0; python_version >= '3.5' 18 | path.py >= 11.5.0; python_version < '3.5' 19 | 20 | # -- PYTHON2 BACKPORTS: 21 | pathlib; python_version <= '3.4' 22 | backports.shutil_which; python_version <= '3.3' 23 | 24 | git+https://github.com/jenisys/invoke-cleanup@v0.3.7 25 | 26 | # -- SECTION: develop 27 | # PREPARED: requests 28 | -------------------------------------------------------------------------------- /tasks/release.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Tasks for releasing this project. 4 | 5 | Normal steps:: 6 | 7 | 8 | python setup.py sdist bdist_wheel 9 | 10 | twine register dist/{project}-{version}.tar.gz 11 | twine upload dist/* 12 | 13 | twine upload --skip-existing dist/* 14 | 15 | python setup.py upload 16 | # -- DEPRECATED: No longer supported -> Use RTD instead 17 | # -- DEPRECATED: python setup.py upload_docs 18 | 19 | pypi repositories: 20 | 21 | * https://pypi.python.org/pypi 22 | * https://testpypi.python.org/pypi (not working anymore) 23 | * https://test.pypi.org/legacy/ (not working anymore) 24 | 25 | Configuration file for pypi repositories: 26 | 27 | .. code-block:: init 28 | 29 | # -- FILE: $HOME/.pypirc 30 | [distutils] 31 | index-servers = 32 | pypi 33 | testpypi 34 | 35 | [pypi] 36 | # DEPRECATED: repository = https://pypi.python.org/pypi 37 | username = __USERNAME_HERE__ 38 | password: 39 | 40 | [testpypi] 41 | # DEPRECATED: repository = https://test.pypi.org/legacy 42 | username = __USERNAME_HERE__ 43 | password: 44 | 45 | .. seealso:: 46 | 47 | * https://packaging.python.org/ 48 | * https://packaging.python.org/guides/ 49 | * https://packaging.python.org/tutorials/distributing-packages/ 50 | """ 51 | 52 | from __future__ import absolute_import, print_function 53 | from invoke import Collection, task 54 | from invoke_cleanup import path_glob 55 | from .invoke_dry_run import DryRunContext 56 | 57 | 58 | # ----------------------------------------------------------------------------- 59 | # TASKS: 60 | # ----------------------------------------------------------------------------- 61 | @task 62 | def checklist(ctx=None): # pylint: disable=unused-argument 63 | """Checklist for releasing this project.""" 64 | checklist_text = """PRE-RELEASE CHECKLIST: 65 | [ ] Everything is checked in 66 | [ ] All tests pass w/ tox 67 | 68 | RELEASE CHECKLIST: 69 | [{x1}] Bump version to new-version by adding tag to the repository 70 | [{x2}] Build packages (sdist, bdist_wheel via prepare) 71 | [{x3}] Register and upload packages to testpypi repository (first) 72 | [{x4}] Verify release is OK and packages from testpypi are usable 73 | [{x5}] Register and upload packages to pypi repository 74 | [{x6}] Push last changes to Github repository 75 | 76 | POST-RELEASE CHECKLIST: 77 | [ ] Bump version to new-develop-version by adding tag to the repository 78 | [ ] Adapt CHANGES (if necessary) 79 | [ ] Commit latest changes to Github repository 80 | """ 81 | steps = dict(x1=None, x2=None, x3=None, x4=None, x5=None, x6=None) 82 | yesno_map = {True: "x", False: "_", None: " "} 83 | answers = {name: yesno_map[value] 84 | for name, value in steps.items()} 85 | print(checklist_text.format(**answers)) 86 | 87 | 88 | @task(name="bump_version") 89 | def bump_version(ctx, new_version, dry_run=False): 90 | """Bump version (to prepare a new release).""" 91 | if not new_version.startswith("v"): 92 | new_version = "v{version}".format(version=new_version) 93 | 94 | if dry_run: 95 | ctx = DryRunContext(ctx) 96 | ctx.run("git tag {version}".format(version=new_version)) 97 | 98 | 99 | @task(name="build", aliases=["build_packages"]) 100 | def build_packages(ctx, hide=False): 101 | """Build packages for this release.""" 102 | print("build_packages:") 103 | ctx.run("python -m build", echo=True, hide=hide) 104 | 105 | 106 | @task 107 | def prepare(ctx, new_version=None, hide=True, 108 | dry_run=False): 109 | """Prepare the release: bump version, build packages, ...""" 110 | if new_version is not None: 111 | bump_version(ctx, new_version, dry_run=dry_run) 112 | build_packages(ctx, hide=hide) 113 | packages = ensure_packages_exist(ctx, check_only=True) 114 | print_packages(packages) 115 | 116 | # -- NOT-NEEDED: 117 | # @task(name="register") 118 | # def register_packages(ctx, repo=None, dry_run=False): 119 | # """Register release (packages) in artifact-store/repository.""" 120 | # original_ctx = ctx 121 | # if repo is None: 122 | # repo = ctx.project.repo or "pypi" 123 | # if dry_run: 124 | # ctx = DryRunContext(ctx) 125 | 126 | # packages = ensure_packages_exist(original_ctx) 127 | # print_packages(packages) 128 | # for artifact in packages: 129 | # ctx.run("twine register --repository={repo} {artifact}".format( 130 | # artifact=artifact, repo=repo)) 131 | 132 | 133 | @task 134 | def upload(ctx, repo=None, repo_url=None, dry_run=False, 135 | skip_existing=False, verbose=False): 136 | """Upload release packages to repository (artifact-store).""" 137 | if repo is None: 138 | repo = ctx.project.repo or "pypi" 139 | if repo_url is None: 140 | repo_url = ctx.project.repo_url or None 141 | original_ctx = ctx 142 | if dry_run: 143 | ctx = DryRunContext(ctx) 144 | 145 | # -- OPTIONS: 146 | opts = [] 147 | if repo_url: 148 | opts.append("--repository-url={0}".format(repo_url)) 149 | elif repo: 150 | opts.append("--repository={0}".format(repo)) 151 | if skip_existing: 152 | opts.append("--skip-existing") 153 | if verbose: 154 | opts.append("--verbose") 155 | 156 | packages = ensure_packages_exist(original_ctx) 157 | print_packages(packages) 158 | ctx.run("twine upload {opts} dist/*".format(opts=" ".join(opts))) 159 | 160 | # ctx.run("twine upload --repository={repo} dist/*".format(repo=repo)) 161 | # 2018-05-05 WORK-AROUND for new https://pypi.org/: 162 | # twine upload --repository-url=https://upload.pypi.org/legacy /dist/* 163 | # NOT-WORKING: repo_url = "https://upload.pypi.org/simple/" 164 | # 165 | # ctx.run("twine upload --repository-url={repo_url} {opts} dist/*".format( 166 | # repo_url=repo_url, opts=" ".join(opts))) 167 | # ctx.run("twine upload --repository={repo} {opts} dist/*".format( 168 | # repo=repo, opts=" ".join(opts))) 169 | 170 | 171 | # -- DEPRECATED: Use RTD instead 172 | # @task(name="upload_docs") 173 | # def upload_docs(ctx, repo=None, dry_run=False): 174 | # """Upload and publish docs. 175 | # 176 | # NOTE: Docs are built first. 177 | # """ 178 | # if repo is None: 179 | # repo = ctx.project.repo or "pypi" 180 | # if dry_run: 181 | # ctx = DryRunContext(ctx) 182 | # 183 | # ctx.run("python setup.py upload_docs") 184 | # 185 | # ----------------------------------------------------------------------------- 186 | # TASK HELPERS: 187 | # ----------------------------------------------------------------------------- 188 | def print_packages(packages): 189 | print("PACKAGES[%d]:" % len(packages)) 190 | for package in packages: 191 | package_size = package.stat().st_size 192 | package_time = package.stat().st_mtime 193 | print(" - %s (size=%s)" % (package, package_size)) 194 | 195 | 196 | def ensure_packages_exist(ctx, pattern=None, check_only=False): 197 | if pattern is None: 198 | project_name = ctx.project.name 199 | project_prefix = project_name.replace("_", "-").split("-")[0] 200 | pattern = "dist/%s*" % project_prefix 201 | 202 | packages = list(path_glob(pattern, current_dir=".")) 203 | if not packages: 204 | if check_only: 205 | message = "No artifacts found: pattern=%s" % pattern 206 | raise RuntimeError(message) 207 | else: 208 | # -- RECURSIVE-SELF-CALL: Once 209 | print("NO-PACKAGES-FOUND: Build packages first ...") 210 | build_packages(ctx, hide=True) 211 | packages = ensure_packages_exist(ctx, pattern, 212 | check_only=True) 213 | return packages 214 | 215 | 216 | # ----------------------------------------------------------------------------- 217 | # TASK CONFIGURATION: 218 | # ----------------------------------------------------------------------------- 219 | # DISABLED: register_packages 220 | namespace = Collection(bump_version, checklist, prepare, build_packages, upload) 221 | namespace.configure({ 222 | "project": { 223 | "repo": "pypi", 224 | "repo_url": None, 225 | } 226 | }) 227 | -------------------------------------------------------------------------------- /tasks/test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Invoke test tasks. 4 | """ 5 | 6 | from __future__ import print_function 7 | import os.path 8 | import sys 9 | from invoke import task, Collection 10 | 11 | # -- TASK-LIBRARY: 12 | from invoke_cleanup import cleanup_tasks, cleanup_dirs, cleanup_files 13 | 14 | 15 | # --------------------------------------------------------------------------- 16 | # CONSTANTS: 17 | # --------------------------------------------------------------------------- 18 | USE_BEHAVE = False 19 | 20 | 21 | # --------------------------------------------------------------------------- 22 | # TASKS 23 | # --------------------------------------------------------------------------- 24 | @task(name="all", help={ 25 | "args": "Command line args for test run.", 26 | }) 27 | def test_all(ctx, args="", options=""): 28 | """Run all tests (default).""" 29 | pytest_args = select_by_prefix(args, ctx.pytest.scopes) 30 | behave_args = None 31 | if USE_BEHAVE: 32 | behave_args = select_by_prefix(args, ctx.behave_test.scopes) 33 | pytest_should_run = not args or (args and pytest_args) 34 | behave_should_run = not args or (args and behave_args) 35 | if pytest_should_run: 36 | pytest(ctx, pytest_args, options=options) 37 | if behave_should_run and USE_BEHAVE: 38 | behave(ctx, behave_args, options=options) 39 | 40 | 41 | @task 42 | def clean(ctx, dry_run=False): 43 | """Cleanup (temporary) test artifacts.""" 44 | directories = ctx.test.clean.directories or [] 45 | files = ctx.test.clean.files or [] 46 | cleanup_dirs(directories, dry_run=dry_run) 47 | cleanup_files(files, dry_run=dry_run) 48 | 49 | 50 | @task(name="unit") 51 | def unittest(ctx, args="", options=""): 52 | """Run unit tests.""" 53 | pytest(ctx, args, options) 54 | 55 | 56 | @task 57 | def pytest(ctx, args="", options=""): 58 | """Run unit tests.""" 59 | args = args or ctx.pytest.args 60 | options = options or ctx.pytest.options 61 | ctx.run("pytest {options} {args}".format(options=options, args=args)) 62 | 63 | 64 | @task(help={ 65 | "args": "Command line args for behave", 66 | "format": "Formatter to use (progress, pretty, ...)", 67 | }) 68 | def behave(ctx, args="", format="", options=""): 69 | """Run behave tests.""" 70 | format = format or ctx.behave_test.format 71 | options = options or ctx.behave_test.options 72 | args = args or ctx.behave_test.args 73 | if os.path.exists("bin/behave"): 74 | behave_cmd = "{python} bin/behave".format(python=sys.executable) 75 | else: 76 | behave_cmd = "{python} -m behave".format(python=sys.executable) 77 | 78 | for group_args in grouped_by_prefix(args, ctx.behave_test.scopes): 79 | ctx.run("{behave} -f {format} {options} {args}".format( 80 | behave=behave_cmd, format=format, options=options, args=group_args)) 81 | 82 | 83 | @task(help={ 84 | "args": "Tests to run (empty: all)", 85 | "report": "Coverage report format to use (report, html, xml)", 86 | }) 87 | def coverage(ctx, args="", report="report", append=False): 88 | """Determine test coverage (run pytest, behave)""" 89 | append = append or ctx.coverage.append 90 | report_formats = ctx.coverage.report_formats or [] 91 | if report not in report_formats: 92 | report_formats.insert(0, report) 93 | opts = [] 94 | if append: 95 | opts.append("--append") 96 | 97 | pytest_args = select_by_prefix(args, ctx.pytest.scopes) 98 | behave_args = select_by_prefix(args, ctx.behave_test.scopes) 99 | pytest_should_run = not args or (args and pytest_args) 100 | behave_should_run = not args or (args and behave_args) and USE_BEHAVE 101 | if not args: 102 | behave_args = ctx.behave_test.args or "features" 103 | if isinstance(pytest_args, list): 104 | pytest_args = " ".join(pytest_args) 105 | if isinstance(behave_args, list): 106 | behave_args = " ".join(behave_args) 107 | 108 | # -- RUN TESTS WITH COVERAGE: 109 | if pytest_should_run: 110 | ctx.run("coverage run {options} -m pytest {args}".format( 111 | args=pytest_args, options=" ".join(opts))) 112 | if behave_should_run and USE_BEHAVE: 113 | behave_options = ctx.behave_test.coverage_options or "" 114 | os.environ["COVERAGE_PROCESS_START"] = os.path.abspath(".coveragerc") 115 | behave(ctx, args=behave_args, options=behave_options) 116 | del os.environ["COVERAGE_PROCESS_START"] 117 | 118 | # -- POST-PROCESSING: 119 | ctx.run("coverage combine") 120 | for report_format in report_formats: 121 | ctx.run("coverage {report_format}".format(report_format=report_format)) 122 | 123 | 124 | # --------------------------------------------------------------------------- 125 | # UTILITIES: 126 | # --------------------------------------------------------------------------- 127 | def select_prefix_for(arg, prefixes): 128 | for prefix in prefixes: 129 | if arg.startswith(prefix): 130 | return prefix 131 | return os.path.dirname(arg) 132 | 133 | 134 | def select_by_prefix(args, prefixes): 135 | selected = [] 136 | for arg in args.strip().split(): 137 | assert not arg.startswith("-"), "REQUIRE: arg, not options" 138 | scope = select_prefix_for(arg, prefixes) 139 | if scope: 140 | selected.append(arg) 141 | return " ".join(selected) 142 | 143 | 144 | def grouped_by_prefix(args, prefixes): 145 | """Group behave args by (directory) scope into multiple test-runs.""" 146 | group_args = [] 147 | current_scope = None 148 | for arg in args.strip().split(): 149 | assert not arg.startswith("-"), "REQUIRE: arg, not options" 150 | scope = select_prefix_for(arg, prefixes) 151 | if scope != current_scope: 152 | if group_args: 153 | # -- DETECTED GROUP-END: 154 | yield " ".join(group_args) 155 | group_args = [] 156 | current_scope = scope 157 | group_args.append(arg) 158 | if group_args: 159 | yield " ".join(group_args) 160 | 161 | 162 | # --------------------------------------------------------------------------- 163 | # TASK MANAGEMENT / CONFIGURATION 164 | # --------------------------------------------------------------------------- 165 | namespace = Collection(clean, unittest, pytest, coverage) 166 | namespace.add_task(test_all, default=True) 167 | if USE_BEHAVE: 168 | namespace.add_task(behave) 169 | 170 | namespace.configure({ 171 | "test": { 172 | "clean": { 173 | "directories": [ 174 | ".cache", "assets", # -- TEST RUNS 175 | # -- BEHAVE-SPECIFIC: 176 | "__WORKDIR__", "reports", "test_results", 177 | ], 178 | "files": [ 179 | ".coverage", ".coverage.*", 180 | # -- BEHAVE-SPECIFIC: 181 | "report.html", 182 | "rerun*.txt", "rerun*.featureset", "testrun*.json", 183 | ], 184 | }, 185 | }, 186 | "pytest": { 187 | "scopes": ["tests"], 188 | "args": "", 189 | "options": "", # -- NOTE: Overide in configfile "invoke.yaml" 190 | }, 191 | # "behave_test": behave.namespace._configuration["behave_test"], 192 | "behave_test": { 193 | "scopes": ["features"], 194 | "args": "features", 195 | "format": "progress", 196 | "options": "", # -- NOTE: Overide in configfile "invoke.yaml" 197 | "coverage_options": "", 198 | }, 199 | "coverage": { 200 | "append": False, 201 | "report_formats": ["report", "html"], 202 | }, 203 | }) 204 | 205 | # -- ADD CLEANUP TASK: 206 | cleanup_tasks.add_task(clean, "clean_test") 207 | cleanup_tasks.configure(namespace.configuration()) 208 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jenisys/parse_type/35096a3f1c8f1d02b2809a69a68f84fc8d39904b/tests/__init__.py -------------------------------------------------------------------------------- /tests/parse_tests/__init__.py: -------------------------------------------------------------------------------- 1 | # COPY TESTSUITE FROM: parse v1.20.2 2 | # SEE: https://github.com/r1chardj0n3s/parse 3 | -------------------------------------------------------------------------------- /tests/parse_tests/test_bugs.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from datetime import datetime 3 | 4 | import parse 5 | 6 | 7 | def test_tz_compare_to_None(): 8 | utc = parse.FixedTzOffset(0, "UTC") 9 | assert utc is not None 10 | assert utc != "spam" 11 | 12 | 13 | def test_named_date_issue7(): 14 | r = parse.parse("on {date:ti}", "on 2012-09-17") 15 | assert r["date"] == datetime(2012, 9, 17, 0, 0, 0) 16 | 17 | # fix introduced regressions 18 | r = parse.parse("a {:ti} b", "a 1997-07-16T19:20 b") 19 | assert r[0] == datetime(1997, 7, 16, 19, 20, 0) 20 | r = parse.parse("a {:ti} b", "a 1997-07-16T19:20Z b") 21 | utc = parse.FixedTzOffset(0, "UTC") 22 | assert r[0] == datetime(1997, 7, 16, 19, 20, tzinfo=utc) 23 | r = parse.parse("a {date:ti} b", "a 1997-07-16T19:20Z b") 24 | assert r["date"] == datetime(1997, 7, 16, 19, 20, tzinfo=utc) 25 | 26 | 27 | def test_dotted_type_conversion_pull_8(): 28 | # test pull request 8 which fixes type conversion related to dotted 29 | # names being applied correctly 30 | r = parse.parse("{a.b:d}", "1") 31 | assert r["a.b"] == 1 32 | r = parse.parse("{a_b:w} {a.b:d}", "1 2") 33 | assert r["a_b"] == "1" 34 | assert r["a.b"] == 2 35 | 36 | 37 | def test_pm_overflow_issue16(): 38 | r = parse.parse("Meet at {:tg}", "Meet at 1/2/2011 12:45 PM") 39 | assert r[0] == datetime(2011, 2, 1, 12, 45) 40 | 41 | 42 | def test_pm_handling_issue57(): 43 | r = parse.parse("Meet at {:tg}", "Meet at 1/2/2011 12:15 PM") 44 | assert r[0] == datetime(2011, 2, 1, 12, 15) 45 | r = parse.parse("Meet at {:tg}", "Meet at 1/2/2011 12:15 AM") 46 | assert r[0] == datetime(2011, 2, 1, 0, 15) 47 | 48 | 49 | def test_user_type_with_group_count_issue60(): 50 | @parse.with_pattern(r"((\w+))", regex_group_count=2) 51 | def parse_word_and_covert_to_uppercase(text): 52 | return text.strip().upper() 53 | 54 | @parse.with_pattern(r"\d+") 55 | def parse_number(text): 56 | return int(text) 57 | 58 | # -- CASE: Use named (OK) 59 | type_map = {"Name": parse_word_and_covert_to_uppercase, "Number": parse_number} 60 | r = parse.parse( 61 | "Hello {name:Name} {number:Number}", "Hello Alice 42", extra_types=type_map 62 | ) 63 | assert r.named == {"name": "ALICE", "number": 42} 64 | 65 | # -- CASE: Use unnamed/fixed (problematic) 66 | r = parse.parse("Hello {:Name} {:Number}", "Hello Alice 42", extra_types=type_map) 67 | assert r[0] == "ALICE" 68 | assert r[1] == 42 69 | 70 | 71 | def test_unmatched_brace_doesnt_match(): 72 | r = parse.parse("{who.txt", "hello") 73 | assert r is None 74 | 75 | 76 | def test_pickling_bug_110(): 77 | p = parse.compile("{a:d}") 78 | # prior to the fix, this would raise an AttributeError 79 | pickle.dumps(p) 80 | 81 | 82 | def test_unused_centered_alignment_bug(): 83 | r = parse.parse("{:^2S}", "foo") 84 | assert r[0] == "foo" 85 | r = parse.search("{:^2S}", "foo") 86 | assert r[0] == "foo" 87 | 88 | # specifically test for the case in issue #118 as well 89 | r = parse.parse("Column {:d}:{:^}", "Column 1: Timestep") 90 | assert r[0] == 1 91 | assert r[1] == "Timestep" 92 | 93 | 94 | def test_unused_left_alignment_bug(): 95 | r = parse.parse("{:<2S}", "foo") 96 | assert r[0] == "foo" 97 | r = parse.search("{:<2S}", "foo") 98 | assert r[0] == "foo" 99 | 100 | 101 | def test_match_trailing_newline(): 102 | r = parse.parse("{}", "test\n") 103 | assert r[0] == "test\n" 104 | -------------------------------------------------------------------------------- /tests/parse_tests/test_findall.py: -------------------------------------------------------------------------------- 1 | import parse 2 | 3 | 4 | def test_findall(): 5 | s = "".join( 6 | r.fixed[0] for r in parse.findall(">{}<", "

    some bold text

    ") 7 | ) 8 | assert s == "some bold text" 9 | 10 | 11 | def test_no_evaluate_result(): 12 | s = "".join( 13 | m.evaluate_result().fixed[0] 14 | for m in parse.findall( 15 | ">{}<", "

    some bold text

    ", evaluate_result=False 16 | ) 17 | ) 18 | assert s == "some bold text" 19 | 20 | 21 | def test_case_sensitivity(): 22 | l = [r.fixed[0] for r in parse.findall("x({})x", "X(hi)X")] 23 | assert l == ["hi"] 24 | 25 | l = [r.fixed[0] for r in parse.findall("x({})x", "X(hi)X", case_sensitive=True)] 26 | assert l == [] 27 | -------------------------------------------------------------------------------- /tests/parse_tests/test_parsetype.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | import pytest 4 | 5 | import parse 6 | 7 | 8 | def assert_match(parser, text, param_name, expected): 9 | result = parser.parse(text) 10 | assert result[param_name] == expected 11 | 12 | 13 | def assert_mismatch(parser, text, param_name): 14 | result = parser.parse(text) 15 | assert result is None 16 | 17 | 18 | def assert_fixed_match(parser, text, expected): 19 | result = parser.parse(text) 20 | assert result.fixed == expected 21 | 22 | 23 | def assert_fixed_mismatch(parser, text): 24 | result = parser.parse(text) 25 | assert result is None 26 | 27 | 28 | def test_pattern_should_be_used(): 29 | def parse_number(text): 30 | return int(text) 31 | 32 | parse_number.pattern = r"\d+" 33 | parse_number.name = "Number" # For testing only. 34 | 35 | extra_types = {parse_number.name: parse_number} 36 | format = "Value is {number:Number} and..." 37 | parser = parse.Parser(format, extra_types) 38 | 39 | assert_match(parser, "Value is 42 and...", "number", 42) 40 | assert_match(parser, "Value is 00123 and...", "number", 123) 41 | assert_mismatch(parser, "Value is ALICE and...", "number") 42 | assert_mismatch(parser, "Value is -123 and...", "number") 43 | 44 | 45 | def test_pattern_should_be_used2(): 46 | def parse_yesno(text): 47 | return parse_yesno.mapping[text.lower()] 48 | 49 | parse_yesno.mapping = { 50 | "yes": True, 51 | "no": False, 52 | "on": True, 53 | "off": False, 54 | "true": True, 55 | "false": False, 56 | } 57 | parse_yesno.pattern = r"|".join(parse_yesno.mapping.keys()) 58 | parse_yesno.name = "YesNo" # For testing only. 59 | 60 | extra_types = {parse_yesno.name: parse_yesno} 61 | format = "Answer: {answer:YesNo}" 62 | parser = parse.Parser(format, extra_types) 63 | 64 | # -- ENSURE: Known enum values are correctly extracted. 65 | for value_name, value in parse_yesno.mapping.items(): 66 | text = "Answer: %s" % value_name 67 | assert_match(parser, text, "answer", value) 68 | 69 | # -- IGNORE-CASE: In parsing, calls type converter function !!! 70 | assert_match(parser, "Answer: YES", "answer", True) 71 | assert_mismatch(parser, "Answer: __YES__", "answer") 72 | 73 | 74 | def test_with_pattern(): 75 | ab_vals = {"a": 1, "b": 2} 76 | 77 | @parse.with_pattern(r"[ab]") 78 | def ab(text): 79 | return ab_vals[text] 80 | 81 | parser = parse.Parser("test {result:ab}", {"ab": ab}) 82 | assert_match(parser, "test a", "result", 1) 83 | assert_match(parser, "test b", "result", 2) 84 | assert_mismatch(parser, "test c", "result") 85 | 86 | 87 | def test_with_pattern_and_regex_group_count(): 88 | # -- SPECIAL-CASE: Regex-grouping is used in user-defined type 89 | # NOTE: Missing or wroung regex_group_counts cause problems 90 | # with parsing following params. 91 | @parse.with_pattern(r"(meter|kilometer)", regex_group_count=1) 92 | def parse_unit(text): 93 | return text.strip() 94 | 95 | @parse.with_pattern(r"\d+") 96 | def parse_number(text): 97 | return int(text) 98 | 99 | type_converters = {"Number": parse_number, "Unit": parse_unit} 100 | # -- CASE: Unnamed-params (affected) 101 | parser = parse.Parser("test {:Unit}-{:Number}", type_converters) 102 | assert_fixed_match(parser, "test meter-10", ("meter", 10)) 103 | assert_fixed_match(parser, "test kilometer-20", ("kilometer", 20)) 104 | assert_fixed_mismatch(parser, "test liter-30") 105 | 106 | # -- CASE: Named-params (uncritical; should not be affected) 107 | # REASON: Named-params have additional, own grouping. 108 | parser2 = parse.Parser("test {unit:Unit}-{value:Number}", type_converters) 109 | assert_match(parser2, "test meter-10", "unit", "meter") 110 | assert_match(parser2, "test meter-10", "value", 10) 111 | assert_match(parser2, "test kilometer-20", "unit", "kilometer") 112 | assert_match(parser2, "test kilometer-20", "value", 20) 113 | assert_mismatch(parser2, "test liter-30", "unit") 114 | 115 | 116 | def test_with_pattern_and_wrong_regex_group_count_raises_error(): 117 | # -- SPECIAL-CASE: 118 | # Regex-grouping is used in user-defined type, but wrong value is provided. 119 | @parse.with_pattern(r"(meter|kilometer)", regex_group_count=1) 120 | def parse_unit(text): 121 | return text.strip() 122 | 123 | @parse.with_pattern(r"\d+") 124 | def parse_number(text): 125 | return int(text) 126 | 127 | # -- CASE: Unnamed-params (affected) 128 | BAD_REGEX_GROUP_COUNTS_AND_ERRORS = [ 129 | (None, ValueError), 130 | (0, ValueError), 131 | (2, IndexError), 132 | ] 133 | for bad_regex_group_count, error_class in BAD_REGEX_GROUP_COUNTS_AND_ERRORS: 134 | parse_unit.regex_group_count = bad_regex_group_count # -- OVERRIDE-HERE 135 | type_converters = {"Number": parse_number, "Unit": parse_unit} 136 | parser = parse.Parser("test {:Unit}-{:Number}", type_converters) 137 | with pytest.raises(error_class): 138 | parser.parse("test meter-10") 139 | 140 | 141 | def test_with_pattern_and_regex_group_count_is_none(): 142 | # -- CORNER-CASE: Increase code-coverage. 143 | data_values = {"a": 1, "b": 2} 144 | 145 | @parse.with_pattern(r"[ab]") 146 | def parse_data(text): 147 | return data_values[text] 148 | 149 | parse_data.regex_group_count = None # ENFORCE: None 150 | 151 | # -- CASE: Unnamed-params 152 | parser = parse.Parser("test {:Data}", {"Data": parse_data}) 153 | assert_fixed_match(parser, "test a", (1,)) 154 | assert_fixed_match(parser, "test b", (2,)) 155 | assert_fixed_mismatch(parser, "test c") 156 | 157 | # -- CASE: Named-params 158 | parser2 = parse.Parser("test {value:Data}", {"Data": parse_data}) 159 | assert_match(parser2, "test a", "value", 1) 160 | assert_match(parser2, "test b", "value", 2) 161 | assert_mismatch(parser2, "test c", "value") 162 | 163 | 164 | def test_case_sensitivity(): 165 | r = parse.parse("SPAM {} SPAM", "spam spam spam") 166 | assert r[0] == "spam" 167 | assert parse.parse("SPAM {} SPAM", "spam spam spam", case_sensitive=True) is None 168 | 169 | 170 | def test_decimal_value(): 171 | value = Decimal("5.5") 172 | str_ = "test {}".format(value) 173 | parser = parse.Parser("test {:F}") 174 | assert parser.parse(str_)[0] == value 175 | 176 | 177 | def test_width_str(): 178 | res = parse.parse("{:.2}{:.2}", "look") 179 | assert res.fixed == ("lo", "ok") 180 | res = parse.parse("{:2}{:2}", "look") 181 | assert res.fixed == ("lo", "ok") 182 | res = parse.parse("{:4}{}", "look at that") 183 | assert res.fixed == ("look", " at that") 184 | 185 | 186 | def test_width_constraints(): 187 | res = parse.parse("{:4}", "looky") 188 | assert res.fixed == ("looky",) 189 | res = parse.parse("{:4.4}", "looky") 190 | assert res is None 191 | res = parse.parse("{:4.4}", "ook") 192 | assert res is None 193 | res = parse.parse("{:4}{:.4}", "look at that") 194 | assert res.fixed == ("look at ", "that") 195 | 196 | 197 | def test_width_multi_int(): 198 | res = parse.parse("{:02d}{:02d}", "0440") 199 | assert res.fixed == (4, 40) 200 | res = parse.parse("{:03d}{:d}", "04404") 201 | assert res.fixed == (44, 4) 202 | 203 | 204 | def test_width_empty_input(): 205 | res = parse.parse("{:.2}", "") 206 | assert res is None 207 | res = parse.parse("{:2}", "l") 208 | assert res is None 209 | res = parse.parse("{:2d}", "") 210 | assert res is None 211 | 212 | 213 | def test_int_convert_stateless_base(): 214 | parser = parse.Parser("{:d}") 215 | assert parser.parse("1234")[0] == 1234 216 | assert parser.parse("0b1011")[0] == 0b1011 217 | -------------------------------------------------------------------------------- /tests/parse_tests/test_pattern.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import parse 4 | 5 | 6 | def _test_expression(format, expression): 7 | assert parse.Parser(format)._expression == expression 8 | 9 | 10 | def test_braces(): 11 | # pull a simple string out of another string 12 | _test_expression("{{ }}", r"\{ \}") 13 | 14 | 15 | def test_fixed(): 16 | # pull a simple string out of another string 17 | _test_expression("{}", r"(.+?)") 18 | _test_expression("{} {}", r"(.+?) (.+?)") 19 | 20 | 21 | def test_named(): 22 | # pull a named string out of another string 23 | _test_expression("{name}", r"(?P.+?)") 24 | _test_expression("{name} {other}", r"(?P.+?) (?P.+?)") 25 | 26 | 27 | def test_named_typed(): 28 | # pull a named string out of another string 29 | _test_expression("{name:w}", r"(?P\w+)") 30 | _test_expression("{name:w} {other:w}", r"(?P\w+) (?P\w+)") 31 | 32 | 33 | def test_numbered(): 34 | _test_expression("{0}", r"(.+?)") 35 | _test_expression("{0} {1}", r"(.+?) (.+?)") 36 | _test_expression("{0:f} {1:f}", r"([-+ ]?\d*\.\d+) ([-+ ]?\d*\.\d+)") 37 | 38 | 39 | def test_bird(): 40 | # skip some trailing whitespace 41 | _test_expression("{:>}", r" *(.+?)") 42 | 43 | 44 | def test_format_variety(): 45 | def _(fmt, matches): 46 | d = parse.extract_format(fmt, {"spam": "spam"}) 47 | for k in matches: 48 | assert d.get(k) == matches[k] 49 | 50 | for t in "%obxegfdDwWsS": 51 | _(t, {"type": t}) 52 | _("10" + t, {"type": t, "width": "10"}) 53 | _("05d", {"type": "d", "width": "5", "zero": True}) 54 | _("<", {"align": "<"}) 55 | _(".<", {"align": "<", "fill": "."}) 56 | _(">", {"align": ">"}) 57 | _(".>", {"align": ">", "fill": "."}) 58 | _("^", {"align": "^"}) 59 | _(".^", {"align": "^", "fill": "."}) 60 | _("x=d", {"type": "d", "align": "=", "fill": "x"}) 61 | _("d", {"type": "d"}) 62 | _("ti", {"type": "ti"}) 63 | _("spam", {"type": "spam"}) 64 | 65 | _(".^010d", {"type": "d", "width": "10", "align": "^", "fill": ".", "zero": True}) 66 | _(".2f", {"type": "f", "precision": "2"}) 67 | _("10.2f", {"type": "f", "width": "10", "precision": "2"}) 68 | 69 | 70 | def test_dot_separated_fields(): 71 | # this should just work and provide the named value 72 | res = parse.parse("{hello.world}_{jojo.foo.baz}_{simple}", "a_b_c") 73 | assert res.named["hello.world"] == "a" 74 | assert res.named["jojo.foo.baz"] == "b" 75 | assert res.named["simple"] == "c" 76 | 77 | 78 | def test_dict_style_fields(): 79 | res = parse.parse("{hello[world]}_{hello[foo][baz]}_{simple}", "a_b_c") 80 | assert res.named["hello"]["world"] == "a" 81 | assert res.named["hello"]["foo"]["baz"] == "b" 82 | assert res.named["simple"] == "c" 83 | 84 | 85 | def test_dot_separated_fields_name_collisions(): 86 | # this should just work and provide the named value 87 | res = parse.parse("{a_.b}_{a__b}_{a._b}_{a___b}", "a_b_c_d") 88 | assert res.named["a_.b"] == "a" 89 | assert res.named["a__b"] == "b" 90 | assert res.named["a._b"] == "c" 91 | assert res.named["a___b"] == "d" 92 | 93 | 94 | def test_invalid_groupnames_are_handled_gracefully(): 95 | with pytest.raises(NotImplementedError): 96 | parse.parse("{hello['world']}", "doesn't work") 97 | -------------------------------------------------------------------------------- /tests/parse_tests/test_result.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import parse 4 | 5 | 6 | def test_fixed_access(): 7 | r = parse.Result((1, 2), {}, None) 8 | assert r[0] == 1 9 | assert r[1] == 2 10 | with pytest.raises(IndexError): 11 | r[2] 12 | with pytest.raises(KeyError): 13 | r["spam"] 14 | 15 | 16 | def test_slice_access(): 17 | r = parse.Result((1, 2, 3, 4), {}, None) 18 | assert r[1:3] == (2, 3) 19 | assert r[-5:5] == (1, 2, 3, 4) 20 | assert r[:4:2] == (1, 3) 21 | assert r[::-2] == (4, 2) 22 | assert r[5:10] == () 23 | 24 | 25 | def test_named_access(): 26 | r = parse.Result((), {"spam": "ham"}, None) 27 | assert r["spam"] == "ham" 28 | with pytest.raises(KeyError): 29 | r["ham"] 30 | with pytest.raises(IndexError): 31 | r[0] 32 | 33 | 34 | def test_contains(): 35 | r = parse.Result(("cat",), {"spam": "ham"}, None) 36 | assert "spam" in r 37 | assert "cat" not in r 38 | assert "ham" not in r 39 | -------------------------------------------------------------------------------- /tests/parse_tests/test_search.py: -------------------------------------------------------------------------------- 1 | import parse 2 | 3 | 4 | def test_basic(): 5 | r = parse.search("a {} c", " a b c ") 6 | assert r.fixed == ("b",) 7 | 8 | 9 | def test_multiline(): 10 | r = parse.search("age: {:d}\n", "name: Rufus\nage: 42\ncolor: red\n") 11 | assert r.fixed == (42,) 12 | 13 | 14 | def test_pos(): 15 | r = parse.search("a {} c", " a b c ", 2) 16 | assert r is None 17 | 18 | 19 | def test_no_evaluate_result(): 20 | match = parse.search( 21 | "age: {:d}\n", "name: Rufus\nage: 42\ncolor: red\n", evaluate_result=False 22 | ) 23 | r = match.evaluate_result() 24 | assert r.fixed == (42,) 25 | -------------------------------------------------------------------------------- /tests/parse_tests_with_parse_type/__init__.py: -------------------------------------------------------------------------------- 1 | # COPY TESTSUITE FROM: parse v1.20.2 2 | # SEE: https://github.com/r1chardj0n3s/parse 3 | # NOTES: 4 | # * Apply testsuite to "parse_type.parse" 5 | -------------------------------------------------------------------------------- /tests/parse_tests_with_parse_type/test_bugs.py: -------------------------------------------------------------------------------- 1 | # -- REPLACE: parse with parse_type.parse 2 | from __future__ import absolute_import, print_function 3 | from parse_type import parse 4 | 5 | 6 | # -- ORGINAL_SOURCE_STARTS_HERE: 7 | import pickle 8 | from datetime import datetime 9 | 10 | # DISABLED: import parse 11 | 12 | def test_tz_compare_to_None(): 13 | utc = parse.FixedTzOffset(0, "UTC") 14 | assert utc is not None 15 | assert utc != "spam" 16 | 17 | 18 | def test_named_date_issue7(): 19 | r = parse.parse("on {date:ti}", "on 2012-09-17") 20 | assert r["date"] == datetime(2012, 9, 17, 0, 0, 0) 21 | 22 | # fix introduced regressions 23 | r = parse.parse("a {:ti} b", "a 1997-07-16T19:20 b") 24 | assert r[0] == datetime(1997, 7, 16, 19, 20, 0) 25 | r = parse.parse("a {:ti} b", "a 1997-07-16T19:20Z b") 26 | utc = parse.FixedTzOffset(0, "UTC") 27 | assert r[0] == datetime(1997, 7, 16, 19, 20, tzinfo=utc) 28 | r = parse.parse("a {date:ti} b", "a 1997-07-16T19:20Z b") 29 | assert r["date"] == datetime(1997, 7, 16, 19, 20, tzinfo=utc) 30 | 31 | 32 | def test_dotted_type_conversion_pull_8(): 33 | # test pull request 8 which fixes type conversion related to dotted 34 | # names being applied correctly 35 | r = parse.parse("{a.b:d}", "1") 36 | assert r["a.b"] == 1 37 | r = parse.parse("{a_b:w} {a.b:d}", "1 2") 38 | assert r["a_b"] == "1" 39 | assert r["a.b"] == 2 40 | 41 | 42 | def test_pm_overflow_issue16(): 43 | r = parse.parse("Meet at {:tg}", "Meet at 1/2/2011 12:45 PM") 44 | assert r[0] == datetime(2011, 2, 1, 12, 45) 45 | 46 | 47 | def test_pm_handling_issue57(): 48 | r = parse.parse("Meet at {:tg}", "Meet at 1/2/2011 12:15 PM") 49 | assert r[0] == datetime(2011, 2, 1, 12, 15) 50 | r = parse.parse("Meet at {:tg}", "Meet at 1/2/2011 12:15 AM") 51 | assert r[0] == datetime(2011, 2, 1, 0, 15) 52 | 53 | 54 | def test_user_type_with_group_count_issue60(): 55 | @parse.with_pattern(r"((\w+))", regex_group_count=2) 56 | def parse_word_and_covert_to_uppercase(text): 57 | return text.strip().upper() 58 | 59 | @parse.with_pattern(r"\d+") 60 | def parse_number(text): 61 | return int(text) 62 | 63 | # -- CASE: Use named (OK) 64 | type_map = {"Name": parse_word_and_covert_to_uppercase, "Number": parse_number} 65 | r = parse.parse( 66 | "Hello {name:Name} {number:Number}", "Hello Alice 42", extra_types=type_map 67 | ) 68 | assert r.named == {"name": "ALICE", "number": 42} 69 | 70 | # -- CASE: Use unnamed/fixed (problematic) 71 | r = parse.parse("Hello {:Name} {:Number}", "Hello Alice 42", extra_types=type_map) 72 | assert r[0] == "ALICE" 73 | assert r[1] == 42 74 | 75 | 76 | def test_unmatched_brace_doesnt_match(): 77 | r = parse.parse("{who.txt", "hello") 78 | assert r is None 79 | 80 | 81 | def test_pickling_bug_110(): 82 | p = parse.compile("{a:d}") 83 | # prior to the fix, this would raise an AttributeError 84 | pickle.dumps(p) 85 | 86 | 87 | def test_unused_centered_alignment_bug(): 88 | r = parse.parse("{:^2S}", "foo") 89 | assert r[0] == "foo" 90 | r = parse.search("{:^2S}", "foo") 91 | assert r[0] == "foo" 92 | 93 | # specifically test for the case in issue #118 as well 94 | r = parse.parse("Column {:d}:{:^}", "Column 1: Timestep") 95 | assert r[0] == 1 96 | assert r[1] == "Timestep" 97 | 98 | 99 | def test_unused_left_alignment_bug(): 100 | r = parse.parse("{:<2S}", "foo") 101 | assert r[0] == "foo" 102 | r = parse.search("{:<2S}", "foo") 103 | assert r[0] == "foo" 104 | 105 | 106 | def test_match_trailing_newline(): 107 | r = parse.parse("{}", "test\n") 108 | assert r[0] == "test\n" 109 | -------------------------------------------------------------------------------- /tests/parse_tests_with_parse_type/test_findall.py: -------------------------------------------------------------------------------- 1 | # -- REPLACE: parse with parse_type.parse 2 | from __future__ import absolute_import, print_function 3 | from parse_type import parse 4 | 5 | # -- ORIGINAL_SOURCE_STARTS_HERE: 6 | # DISABLED: import parse 7 | 8 | def test_findall(): 9 | s = "".join( 10 | r.fixed[0] for r in parse.findall(">{}<", "

    some bold text

    ") 11 | ) 12 | assert s == "some bold text" 13 | 14 | 15 | def test_no_evaluate_result(): 16 | s = "".join( 17 | m.evaluate_result().fixed[0] 18 | for m in parse.findall( 19 | ">{}<", "

    some bold text

    ", evaluate_result=False 20 | ) 21 | ) 22 | assert s == "some bold text" 23 | 24 | 25 | def test_case_sensitivity(): 26 | l = [r.fixed[0] for r in parse.findall("x({})x", "X(hi)X")] 27 | assert l == ["hi"] 28 | 29 | l = [r.fixed[0] for r in parse.findall("x({})x", "X(hi)X", case_sensitive=True)] 30 | assert l == [] 31 | -------------------------------------------------------------------------------- /tests/parse_tests_with_parse_type/test_parsetype.py: -------------------------------------------------------------------------------- 1 | # -- REPLACE: parse with parse_type.parse 2 | from __future__ import absolute_import, print_function 3 | from parse_type import parse 4 | 5 | # -- ORIGINAL_SOURCE_STARTS_HERE: 6 | from decimal import Decimal 7 | 8 | import pytest 9 | 10 | # DISABLED: import parse 11 | 12 | 13 | def assert_match(parser, text, param_name, expected): 14 | result = parser.parse(text) 15 | assert result[param_name] == expected 16 | 17 | 18 | def assert_mismatch(parser, text, param_name): 19 | result = parser.parse(text) 20 | assert result is None 21 | 22 | 23 | def assert_fixed_match(parser, text, expected): 24 | result = parser.parse(text) 25 | assert result.fixed == expected 26 | 27 | 28 | def assert_fixed_mismatch(parser, text): 29 | result = parser.parse(text) 30 | assert result is None 31 | 32 | 33 | def test_pattern_should_be_used(): 34 | def parse_number(text): 35 | return int(text) 36 | 37 | parse_number.pattern = r"\d+" 38 | parse_number.name = "Number" # For testing only. 39 | 40 | extra_types = {parse_number.name: parse_number} 41 | format = "Value is {number:Number} and..." 42 | parser = parse.Parser(format, extra_types) 43 | 44 | assert_match(parser, "Value is 42 and...", "number", 42) 45 | assert_match(parser, "Value is 00123 and...", "number", 123) 46 | assert_mismatch(parser, "Value is ALICE and...", "number") 47 | assert_mismatch(parser, "Value is -123 and...", "number") 48 | 49 | 50 | def test_pattern_should_be_used2(): 51 | def parse_yesno(text): 52 | return parse_yesno.mapping[text.lower()] 53 | 54 | parse_yesno.mapping = { 55 | "yes": True, 56 | "no": False, 57 | "on": True, 58 | "off": False, 59 | "true": True, 60 | "false": False, 61 | } 62 | parse_yesno.pattern = r"|".join(parse_yesno.mapping.keys()) 63 | parse_yesno.name = "YesNo" # For testing only. 64 | 65 | extra_types = {parse_yesno.name: parse_yesno} 66 | format = "Answer: {answer:YesNo}" 67 | parser = parse.Parser(format, extra_types) 68 | 69 | # -- ENSURE: Known enum values are correctly extracted. 70 | for value_name, value in parse_yesno.mapping.items(): 71 | text = "Answer: %s" % value_name 72 | assert_match(parser, text, "answer", value) 73 | 74 | # -- IGNORE-CASE: In parsing, calls type converter function !!! 75 | assert_match(parser, "Answer: YES", "answer", True) 76 | assert_mismatch(parser, "Answer: __YES__", "answer") 77 | 78 | 79 | def test_with_pattern(): 80 | ab_vals = {"a": 1, "b": 2} 81 | 82 | @parse.with_pattern(r"[ab]") 83 | def ab(text): 84 | return ab_vals[text] 85 | 86 | parser = parse.Parser("test {result:ab}", {"ab": ab}) 87 | assert_match(parser, "test a", "result", 1) 88 | assert_match(parser, "test b", "result", 2) 89 | assert_mismatch(parser, "test c", "result") 90 | 91 | 92 | def test_with_pattern_and_regex_group_count(): 93 | # -- SPECIAL-CASE: Regex-grouping is used in user-defined type 94 | # NOTE: Missing or wroung regex_group_counts cause problems 95 | # with parsing following params. 96 | @parse.with_pattern(r"(meter|kilometer)", regex_group_count=1) 97 | def parse_unit(text): 98 | return text.strip() 99 | 100 | @parse.with_pattern(r"\d+") 101 | def parse_number(text): 102 | return int(text) 103 | 104 | type_converters = {"Number": parse_number, "Unit": parse_unit} 105 | # -- CASE: Unnamed-params (affected) 106 | parser = parse.Parser("test {:Unit}-{:Number}", type_converters) 107 | assert_fixed_match(parser, "test meter-10", ("meter", 10)) 108 | assert_fixed_match(parser, "test kilometer-20", ("kilometer", 20)) 109 | assert_fixed_mismatch(parser, "test liter-30") 110 | 111 | # -- CASE: Named-params (uncritical; should not be affected) 112 | # REASON: Named-params have additional, own grouping. 113 | parser2 = parse.Parser("test {unit:Unit}-{value:Number}", type_converters) 114 | assert_match(parser2, "test meter-10", "unit", "meter") 115 | assert_match(parser2, "test meter-10", "value", 10) 116 | assert_match(parser2, "test kilometer-20", "unit", "kilometer") 117 | assert_match(parser2, "test kilometer-20", "value", 20) 118 | assert_mismatch(parser2, "test liter-30", "unit") 119 | 120 | 121 | def test_with_pattern_and_wrong_regex_group_count_raises_error(): 122 | # -- SPECIAL-CASE: 123 | # Regex-grouping is used in user-defined type, but wrong value is provided. 124 | @parse.with_pattern(r"(meter|kilometer)", regex_group_count=1) 125 | def parse_unit(text): 126 | return text.strip() 127 | 128 | @parse.with_pattern(r"\d+") 129 | def parse_number(text): 130 | return int(text) 131 | 132 | # -- CASE: Unnamed-params (affected) 133 | BAD_REGEX_GROUP_COUNTS_AND_ERRORS = [ 134 | (None, ValueError), 135 | (0, ValueError), 136 | (2, IndexError), 137 | ] 138 | for bad_regex_group_count, error_class in BAD_REGEX_GROUP_COUNTS_AND_ERRORS: 139 | parse_unit.regex_group_count = bad_regex_group_count # -- OVERRIDE-HERE 140 | type_converters = {"Number": parse_number, "Unit": parse_unit} 141 | parser = parse.Parser("test {:Unit}-{:Number}", type_converters) 142 | with pytest.raises(error_class): 143 | parser.parse("test meter-10") 144 | 145 | 146 | def test_with_pattern_and_regex_group_count_is_none(): 147 | # -- CORNER-CASE: Increase code-coverage. 148 | data_values = {"a": 1, "b": 2} 149 | 150 | @parse.with_pattern(r"[ab]") 151 | def parse_data(text): 152 | return data_values[text] 153 | 154 | parse_data.regex_group_count = None # ENFORCE: None 155 | 156 | # -- CASE: Unnamed-params 157 | parser = parse.Parser("test {:Data}", {"Data": parse_data}) 158 | assert_fixed_match(parser, "test a", (1,)) 159 | assert_fixed_match(parser, "test b", (2,)) 160 | assert_fixed_mismatch(parser, "test c") 161 | 162 | # -- CASE: Named-params 163 | parser2 = parse.Parser("test {value:Data}", {"Data": parse_data}) 164 | assert_match(parser2, "test a", "value", 1) 165 | assert_match(parser2, "test b", "value", 2) 166 | assert_mismatch(parser2, "test c", "value") 167 | 168 | 169 | def test_case_sensitivity(): 170 | r = parse.parse("SPAM {} SPAM", "spam spam spam") 171 | assert r[0] == "spam" 172 | assert parse.parse("SPAM {} SPAM", "spam spam spam", case_sensitive=True) is None 173 | 174 | 175 | def test_decimal_value(): 176 | value = Decimal("5.5") 177 | str_ = "test {}".format(value) 178 | parser = parse.Parser("test {:F}") 179 | assert parser.parse(str_)[0] == value 180 | 181 | 182 | def test_width_str(): 183 | res = parse.parse("{:.2}{:.2}", "look") 184 | assert res.fixed == ("lo", "ok") 185 | res = parse.parse("{:2}{:2}", "look") 186 | assert res.fixed == ("lo", "ok") 187 | res = parse.parse("{:4}{}", "look at that") 188 | assert res.fixed == ("look", " at that") 189 | 190 | 191 | def test_width_constraints(): 192 | res = parse.parse("{:4}", "looky") 193 | assert res.fixed == ("looky",) 194 | res = parse.parse("{:4.4}", "looky") 195 | assert res is None 196 | res = parse.parse("{:4.4}", "ook") 197 | assert res is None 198 | res = parse.parse("{:4}{:.4}", "look at that") 199 | assert res.fixed == ("look at ", "that") 200 | 201 | 202 | def test_width_multi_int(): 203 | res = parse.parse("{:02d}{:02d}", "0440") 204 | assert res.fixed == (4, 40) 205 | res = parse.parse("{:03d}{:d}", "04404") 206 | assert res.fixed == (44, 4) 207 | 208 | 209 | def test_width_empty_input(): 210 | res = parse.parse("{:.2}", "") 211 | assert res is None 212 | res = parse.parse("{:2}", "l") 213 | assert res is None 214 | res = parse.parse("{:2d}", "") 215 | assert res is None 216 | 217 | 218 | def test_int_convert_stateless_base(): 219 | parser = parse.Parser("{:d}") 220 | assert parser.parse("1234")[0] == 1234 221 | assert parser.parse("0b1011")[0] == 0b1011 222 | -------------------------------------------------------------------------------- /tests/parse_tests_with_parse_type/test_pattern.py: -------------------------------------------------------------------------------- 1 | # -- REPLACE: parse with parse_type.parse 2 | from __future__ import absolute_import, print_function 3 | from parse_type import parse 4 | 5 | # -- ORIGINAL_SOURCE_STARTS_HERE: 6 | import pytest 7 | 8 | # DISABLED: import parse 9 | 10 | 11 | def _test_expression(format, expression): 12 | assert parse.Parser(format)._expression == expression 13 | 14 | 15 | def test_braces(): 16 | # pull a simple string out of another string 17 | _test_expression("{{ }}", r"\{ \}") 18 | 19 | 20 | def test_fixed(): 21 | # pull a simple string out of another string 22 | _test_expression("{}", r"(.+?)") 23 | _test_expression("{} {}", r"(.+?) (.+?)") 24 | 25 | 26 | def test_named(): 27 | # pull a named string out of another string 28 | _test_expression("{name}", r"(?P.+?)") 29 | _test_expression("{name} {other}", r"(?P.+?) (?P.+?)") 30 | 31 | 32 | def test_named_typed(): 33 | # pull a named string out of another string 34 | _test_expression("{name:w}", r"(?P\w+)") 35 | _test_expression("{name:w} {other:w}", r"(?P\w+) (?P\w+)") 36 | 37 | 38 | def test_numbered(): 39 | _test_expression("{0}", r"(.+?)") 40 | _test_expression("{0} {1}", r"(.+?) (.+?)") 41 | _test_expression("{0:f} {1:f}", r"([-+ ]?\d*\.\d+) ([-+ ]?\d*\.\d+)") 42 | 43 | 44 | def test_bird(): 45 | # skip some trailing whitespace 46 | _test_expression("{:>}", r" *(.+?)") 47 | 48 | 49 | def test_format_variety(): 50 | def _(fmt, matches): 51 | d = parse.extract_format(fmt, {"spam": "spam"}) 52 | for k in matches: 53 | assert d.get(k) == matches[k] 54 | 55 | for t in "%obxegfdDwWsS": 56 | _(t, {"type": t}) 57 | _("10" + t, {"type": t, "width": "10"}) 58 | _("05d", {"type": "d", "width": "5", "zero": True}) 59 | _("<", {"align": "<"}) 60 | _(".<", {"align": "<", "fill": "."}) 61 | _(">", {"align": ">"}) 62 | _(".>", {"align": ">", "fill": "."}) 63 | _("^", {"align": "^"}) 64 | _(".^", {"align": "^", "fill": "."}) 65 | _("x=d", {"type": "d", "align": "=", "fill": "x"}) 66 | _("d", {"type": "d"}) 67 | _("ti", {"type": "ti"}) 68 | _("spam", {"type": "spam"}) 69 | 70 | _(".^010d", {"type": "d", "width": "10", "align": "^", "fill": ".", "zero": True}) 71 | _(".2f", {"type": "f", "precision": "2"}) 72 | _("10.2f", {"type": "f", "width": "10", "precision": "2"}) 73 | 74 | 75 | def test_dot_separated_fields(): 76 | # this should just work and provide the named value 77 | res = parse.parse("{hello.world}_{jojo.foo.baz}_{simple}", "a_b_c") 78 | assert res.named["hello.world"] == "a" 79 | assert res.named["jojo.foo.baz"] == "b" 80 | assert res.named["simple"] == "c" 81 | 82 | 83 | def test_dict_style_fields(): 84 | res = parse.parse("{hello[world]}_{hello[foo][baz]}_{simple}", "a_b_c") 85 | assert res.named["hello"]["world"] == "a" 86 | assert res.named["hello"]["foo"]["baz"] == "b" 87 | assert res.named["simple"] == "c" 88 | 89 | 90 | def test_dot_separated_fields_name_collisions(): 91 | # this should just work and provide the named value 92 | res = parse.parse("{a_.b}_{a__b}_{a._b}_{a___b}", "a_b_c_d") 93 | assert res.named["a_.b"] == "a" 94 | assert res.named["a__b"] == "b" 95 | assert res.named["a._b"] == "c" 96 | assert res.named["a___b"] == "d" 97 | 98 | 99 | def test_invalid_groupnames_are_handled_gracefully(): 100 | with pytest.raises(NotImplementedError): 101 | parse.parse("{hello['world']}", "doesn't work") 102 | -------------------------------------------------------------------------------- /tests/parse_tests_with_parse_type/test_result.py: -------------------------------------------------------------------------------- 1 | # -- REPLACE: parse with parse_type.parse 2 | from __future__ import absolute_import, print_function 3 | from parse_type import parse 4 | 5 | # -- ORIGINAL_SOURCE_STARTS_HERE: 6 | import pytest 7 | 8 | # DISABLED: import parse 9 | 10 | 11 | def test_fixed_access(): 12 | r = parse.Result((1, 2), {}, None) 13 | assert r[0] == 1 14 | assert r[1] == 2 15 | with pytest.raises(IndexError): 16 | r[2] 17 | with pytest.raises(KeyError): 18 | r["spam"] 19 | 20 | 21 | def test_slice_access(): 22 | r = parse.Result((1, 2, 3, 4), {}, None) 23 | assert r[1:3] == (2, 3) 24 | assert r[-5:5] == (1, 2, 3, 4) 25 | assert r[:4:2] == (1, 3) 26 | assert r[::-2] == (4, 2) 27 | assert r[5:10] == () 28 | 29 | 30 | def test_named_access(): 31 | r = parse.Result((), {"spam": "ham"}, None) 32 | assert r["spam"] == "ham" 33 | with pytest.raises(KeyError): 34 | r["ham"] 35 | with pytest.raises(IndexError): 36 | r[0] 37 | 38 | 39 | def test_contains(): 40 | r = parse.Result(("cat",), {"spam": "ham"}, None) 41 | assert "spam" in r 42 | assert "cat" not in r 43 | assert "ham" not in r 44 | -------------------------------------------------------------------------------- /tests/parse_tests_with_parse_type/test_search.py: -------------------------------------------------------------------------------- 1 | # -- REPLACE: parse with parse_type.parse 2 | from __future__ import absolute_import, print_function 3 | from parse_type import parse 4 | 5 | # -- ORIGINAL_SOURCE_STARTS_HERE: 6 | # import parse 7 | 8 | def test_basic(): 9 | r = parse.search("a {} c", " a b c ") 10 | assert r.fixed == ("b",) 11 | 12 | 13 | def test_multiline(): 14 | r = parse.search("age: {:d}\n", "name: Rufus\nage: 42\ncolor: red\n") 15 | assert r.fixed == (42,) 16 | 17 | 18 | def test_pos(): 19 | r = parse.search("a {} c", " a b c ", 2) 20 | assert r is None 21 | 22 | 23 | def test_no_evaluate_result(): 24 | match = parse.search( 25 | "age: {:d}\n", "name: Rufus\nage: 42\ncolor: red\n", evaluate_result=False 26 | ) 27 | r = match.evaluate_result() 28 | assert r.fixed == (42,) 29 | -------------------------------------------------------------------------------- /tests/parse_type_test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | from parse_type import TypeBuilder 5 | from enum import Enum 6 | try: 7 | import unittest2 as unittest 8 | except ImportError: 9 | import unittest 10 | 11 | 12 | # ----------------------------------------------------------------------------- 13 | # TEST SUPPORT FOR: TypeBuilder Tests 14 | # ----------------------------------------------------------------------------- 15 | # -- PROOF-OF-CONCEPT DATATYPE: 16 | def parse_number(text): 17 | return int(text) 18 | parse_number.pattern = r"\d+" # Provide better regexp pattern than default. 19 | parse_number.name = "Number" # For testing only. 20 | 21 | # -- ENUM DATATYPE: 22 | parse_yesno = TypeBuilder.make_enum({ 23 | "yes": True, "no": False, 24 | "on": True, "off": False, 25 | "true": True, "false": False, 26 | }) 27 | parse_yesno.name = "YesNo" # For testing only. 28 | 29 | # -- ENUM CLASS: 30 | class Color(Enum): 31 | red = 1 32 | green = 2 33 | blue = 3 34 | 35 | parse_color = TypeBuilder.make_enum(Color) 36 | parse_color.name = "Color" 37 | 38 | # -- CHOICE DATATYPE: 39 | parse_person_choice = TypeBuilder.make_choice(["Alice", "Bob", "Charly"]) 40 | parse_person_choice.name = "PersonChoice" # For testing only. 41 | 42 | 43 | # ----------------------------------------------------------------------------- 44 | # ABSTRACT TEST CASE: 45 | # ----------------------------------------------------------------------------- 46 | class TestCase(unittest.TestCase): 47 | 48 | # -- PYTHON VERSION BACKWARD-COMPATIBILTY: 49 | if not hasattr(unittest.TestCase, "assertIsNone"): 50 | def assertIsNone(self, obj, msg=None): 51 | self.assert_(obj is None, msg) 52 | 53 | def assertIsNotNone(self, obj, msg=None): 54 | self.assert_(obj is not None, msg) 55 | 56 | 57 | class ParseTypeTestCase(TestCase): 58 | """ 59 | Common test case base class for :mod:`parse_type` tests. 60 | """ 61 | 62 | def assert_match(self, parser, text, param_name, expected): 63 | """ 64 | Check that a parser can parse the provided text and extracts the 65 | expected value for a parameter. 66 | 67 | :param parser: Parser to use 68 | :param text: Text to parse 69 | :param param_name: Name of parameter 70 | :param expected: Expected value of parameter. 71 | :raise: AssertionError on failures. 72 | """ 73 | result = parser.parse(text) 74 | self.assertIsNotNone(result) 75 | self.assertEqual(result[param_name], expected) 76 | 77 | def assert_mismatch(self, parser, text, param_name=None): 78 | """ 79 | Check that a parser cannot extract the parameter from the provided text. 80 | A parse mismatch has occured. 81 | 82 | :param parser: Parser to use 83 | :param text: Text to parse 84 | :param param_name: Name of parameter 85 | :raise: AssertionError on failures. 86 | """ 87 | result = parser.parse(text) 88 | self.assertIsNone(result) 89 | 90 | def ensure_can_parse_all_enum_values(self, parser, type_converter, 91 | schema, name): 92 | # -- ENSURE: Known enum values are correctly extracted. 93 | for value_name, value in type_converter.mappings.items(): 94 | text = schema % value_name 95 | self.assert_match(parser, text, name, value) 96 | 97 | def ensure_can_parse_all_choices(self, parser, type_converter, schema, name): 98 | transform = getattr(type_converter, "transform", None) 99 | for choice_value in type_converter.choices: 100 | text = schema % choice_value 101 | expected_value = choice_value 102 | if transform: 103 | assert callable(transform) 104 | expected_value = transform(choice_value) 105 | self.assert_match(parser, text, name, expected_value) 106 | 107 | def ensure_can_parse_all_choices2(self, parser, type_converter, schema, name): 108 | transform = getattr(type_converter, "transform", None) 109 | for index, choice_value in enumerate(type_converter.choices): 110 | text = schema % choice_value 111 | if transform: 112 | assert callable(transform) 113 | expected_value = (index, transform(choice_value)) 114 | else: 115 | expected_value = (index, choice_value) 116 | self.assert_match(parser, text, name, expected_value) 117 | 118 | 119 | 120 | # Copyright (c) 2012-2013 by Jens Engel (https://github/jenisys/parse_type) 121 | # 122 | # Permission is hereby granted, free of charge, to any person obtaining a copy 123 | # of this software and associated documentation files (the "Software"), to deal 124 | # in the Software without restriction, including without limitation the rights 125 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 126 | # copies of the Software, and to permit persons to whom the Software is 127 | # furnished to do so, subject to the following conditions: 128 | # 129 | # The above copyright notice and this permission notice shall be included in 130 | # all copies or substantial portions of the Software. 131 | # 132 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 133 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 134 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 135 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 136 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 137 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 138 | # SOFTWARE. 139 | -------------------------------------------------------------------------------- /tests/test_cardinality_field0.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Test experiment for parse. 5 | Add cardinality format field after type: 6 | 7 | "... {person:Person?} ..." -- CARDINALITY: Zero or one, 0..1 (optional) 8 | "... {persons:Person*} ..." -- CARDINALITY: Zero or more, 0..N (many0) 9 | "... {persons:Person+} ..." -- CARDINALITY: One or more, 1..N (many) 10 | 11 | 12 | REQUIRES: 13 | parse >= 1.5.3.1 ('pattern' attribute support and further extensions) 14 | 15 | STATUS: 16 | IDEA, working prototype with patched parse module, but not accepted. 17 | """ 18 | 19 | from __future__ import absolute_import 20 | from .parse_type_test import ParseTypeTestCase 21 | from parse_type import TypeBuilder, build_type_dict 22 | import parse 23 | import unittest 24 | 25 | ENABLED = False 26 | if ENABLED: 27 | # ------------------------------------------------------------------------- 28 | # TEST CASE: TestParseTypeWithCardinalityField 29 | # ------------------------------------------------------------------------- 30 | class TestParseTypeWithCardinalityField(ParseTypeTestCase): 31 | """ 32 | Test cardinality field part in parse type expressions, ala: 33 | 34 | "... {person:Person?} ..." -- OPTIONAL: cardinality is zero or one. 35 | "... {persons:Person*} ..." -- MANY0: cardinality is zero or more. 36 | "... {persons:Person+} ..." -- MANY: cardinality is one or more. 37 | 38 | NOTE: 39 | * TypeBuilder has a similar and slightly more flexible feature. 40 | * Cardinality field part works currently only for user-defined types. 41 | """ 42 | 43 | def test_without_cardinality_field(self): 44 | # -- IMPLCIT CARDINALITY: one 45 | # -- SETUP: 46 | parse_person = TypeBuilder.make_choice(["Alice", "Bob", "Charly"]) 47 | parse_person.name = "Person" # For testing only. 48 | extra_types = build_type_dict([ parse_person ]) 49 | schema = "One: {person:Person}" 50 | parser = parse.Parser(schema, extra_types) 51 | 52 | # -- PERFORM TESTS: 53 | self.assert_match(parser, "One: Alice", "person", "Alice") 54 | self.assert_match(parser, "One: Bob", "person", "Bob") 55 | 56 | # -- PARSE MISMATCH: 57 | self.assert_mismatch(parser, "One: ", "person") # Missing. 58 | self.assert_mismatch(parser, "One: BAlice", "person") # Similar1. 59 | self.assert_mismatch(parser, "One: Boby", "person") # Similar2. 60 | self.assert_mismatch(parser, "One: a", "person") # INVALID ... 61 | 62 | def test_cardinality_field_with_zero_or_one(self): 63 | # -- SETUP: 64 | parse_person = TypeBuilder.make_choice(["Alice", "Bob", "Charly"]) 65 | parse_person.name = "Person" # For testing only. 66 | extra_types = build_type_dict([ parse_person ]) 67 | schema = "Optional: {person:Person?}" 68 | parser = parse.Parser(schema, extra_types) 69 | 70 | # -- PERFORM TESTS: 71 | self.assert_match(parser, "Optional: ", "person", None) 72 | self.assert_match(parser, "Optional: Alice", "person", "Alice") 73 | self.assert_match(parser, "Optional: Bob", "person", "Bob") 74 | 75 | # -- PARSE MISMATCH: 76 | self.assert_mismatch(parser, "Optional: Anna", "person") # Similar1. 77 | self.assert_mismatch(parser, "Optional: Boby", "person") # Similar2. 78 | self.assert_mismatch(parser, "Optional: a", "person") # INVALID ... 79 | 80 | def test_cardinality_field_with_one_or_more(self): 81 | # -- SETUP: 82 | parse_person = TypeBuilder.make_choice(["Alice", "Bob", "Charly"]) 83 | parse_person.name = "Person" # For testing only. 84 | extra_types = build_type_dict([ parse_person ]) 85 | schema = "List: {persons:Person+}" 86 | parser = parse.Parser(schema, extra_types) 87 | 88 | # -- PERFORM TESTS: 89 | self.assert_match(parser, "List: Alice", "persons", [ "Alice" ]) 90 | self.assert_match(parser, "List: Bob", "persons", [ "Bob" ]) 91 | self.assert_match(parser, "List: Bob, Alice", 92 | "persons", [ "Bob", "Alice" ]) 93 | 94 | # -- PARSE MISMATCH: 95 | self.assert_mismatch(parser, "List: ", "persons") # Zero items. 96 | self.assert_mismatch(parser, "List: BAlice", "persons") # Unknown1. 97 | self.assert_mismatch(parser, "List: Boby", "persons") # Unknown2. 98 | self.assert_mismatch(parser, "List: Alice,", "persons") # Trailing, 99 | self.assert_mismatch(parser, "List: a, b", "persons") # List of... 100 | 101 | def test_cardinality_field_with_zero_or_more(self): 102 | # -- SETUP: 103 | parse_person = TypeBuilder.make_choice(["Alice", "Bob", "Charly"]) 104 | parse_person.name = "Person" # For testing only. 105 | extra_types = build_type_dict([ parse_person ]) 106 | schema = "List: {persons:Person*}" 107 | parser = parse.Parser(schema, extra_types) 108 | 109 | # -- PERFORM TESTS: 110 | self.assert_match(parser, "List: ", "persons", [ ]) 111 | self.assert_match(parser, "List: Alice", "persons", [ "Alice" ]) 112 | self.assert_match(parser, "List: Bob", "persons", [ "Bob" ]) 113 | self.assert_match(parser, "List: Bob, Alice", 114 | "persons", [ "Bob", "Alice" ]) 115 | 116 | # -- PARSE MISMATCH: 117 | self.assert_mismatch(parser, "List:", "persons") # Too short. 118 | self.assert_mismatch(parser, "List: BAlice", "persons") # Unknown1. 119 | self.assert_mismatch(parser, "List: Boby", "persons") # Unknown2. 120 | self.assert_mismatch(parser, "List: Alice,", "persons") # Trailing, 121 | self.assert_mismatch(parser, "List: a, b", "persons") # List of... 122 | 123 | # ----------------------------------------------------------------------------- 124 | # MAIN: 125 | # ----------------------------------------------------------------------------- 126 | if __name__ == '__main__': 127 | unittest.main() 128 | 129 | 130 | # Copyright (c) 2012-2013 by Jens Engel (https://github/jenisys/parse_type) 131 | # 132 | # Permission is hereby granted, free of charge, to any person obtaining a copy 133 | # of this software and associated documentation files (the "Software"), to deal 134 | # in the Software without restriction, including without limitation the rights 135 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 136 | # copies of the Software, and to permit persons to whom the Software is 137 | # furnished to do so, subject to the following conditions: 138 | # 139 | # The above copyright notice and this permission notice shall be included in 140 | # all copies or substantial portions of the Software. 141 | # 142 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 143 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 144 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 145 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 146 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 147 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 148 | # SOFTWARE. 149 | -------------------------------------------------------------------------------- /tests/test_cfparse.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | #!/usr/bin/env python 3 | # -*- coding: utf-8 -*- 4 | """ 5 | Test suite to test the :mod:`parse_type.cfparse` module. 6 | """ 7 | 8 | from __future__ import absolute_import 9 | from .parse_type_test import ParseTypeTestCase, parse_number, unittest 10 | from parse_type.cfparse import Parser 11 | from parse_type.cardinality_field \ 12 | import MissingTypeError, CardinalityFieldTypeBuilder 13 | 14 | 15 | # ----------------------------------------------------------------------------- 16 | # TEST CASE: 17 | # ----------------------------------------------------------------------------- 18 | class TestParser(ParseTypeTestCase): 19 | """ 20 | Test :class:`parse_type.cfparse.Parser`. 21 | Ensure that: 22 | 23 | * parser can parse fields with CardinalityField part 24 | even when these special type variants are not provided. 25 | 26 | * parser creates missing type converter variants for CardinalityFields 27 | as long as the primary type converter for cardinality=1 is provided. 28 | """ 29 | SPECIAL_FIELD_TYPES_DATA = [ 30 | ("{number1:Number?}", ["Number?"]), 31 | ("{number2:Number+}", ["Number+"]), 32 | ("{number3:Number*}", ["Number*"]), 33 | ("{number1:Number?} {number2:Number+} {number3:Number*}", 34 | ["Number?", "Number+", "Number*"]), 35 | ] 36 | 37 | def test_parser__can_parse_normal_fields(self): 38 | existing_types = dict(Number=parse_number) 39 | schema = "Number: {number:Number}" 40 | parser = Parser(schema, existing_types) 41 | self.assert_match(parser, "Number: 42", "number", 42) 42 | self.assert_match(parser, "Number: 123", "number", 123) 43 | self.assert_mismatch(parser, "Number: ") 44 | self.assert_mismatch(parser, "Number: XXX") 45 | self.assert_mismatch(parser, "Number: -123") 46 | 47 | def test_parser__can_parse_cardinality_field_optional(self): 48 | # -- CARDINALITY: 0..1 = zero_or_one = optional 49 | existing_types = dict(Number=parse_number) 50 | self.assertFalse("Number?" in existing_types) 51 | 52 | # -- ENSURE: Missing type variant is created. 53 | schema = "OptionalNumber: {number:Number?}" 54 | parser = Parser(schema, existing_types) 55 | self.assertTrue("Number?" in existing_types) 56 | 57 | # -- ENSURE: Newly created type variant is usable. 58 | self.assert_match(parser, "OptionalNumber: 42", "number", 42) 59 | self.assert_match(parser, "OptionalNumber: 123", "number", 123) 60 | self.assert_match(parser, "OptionalNumber: ", "number", None) 61 | self.assert_mismatch(parser, "OptionalNumber:") 62 | self.assert_mismatch(parser, "OptionalNumber: XXX") 63 | self.assert_mismatch(parser, "OptionalNumber: -123") 64 | 65 | def test_parser__can_parse_cardinality_field_many(self): 66 | # -- CARDINALITY: 1..* = one_or_more = many 67 | existing_types = dict(Number=parse_number) 68 | self.assertFalse("Number+" in existing_types) 69 | 70 | # -- ENSURE: Missing type variant is created. 71 | schema = "List: {numbers:Number+}" 72 | parser = Parser(schema, existing_types) 73 | self.assertTrue("Number+" in existing_types) 74 | 75 | # -- ENSURE: Newly created type variant is usable. 76 | self.assert_match(parser, "List: 42", "numbers", [42]) 77 | self.assert_match(parser, "List: 1, 2, 3", "numbers", [1, 2, 3]) 78 | self.assert_match(parser, "List: 4,5,6", "numbers", [4, 5, 6]) 79 | self.assert_mismatch(parser, "List: ") 80 | self.assert_mismatch(parser, "List:") 81 | self.assert_mismatch(parser, "List: XXX") 82 | self.assert_mismatch(parser, "List: -123") 83 | 84 | def test_parser__can_parse_cardinality_field_many_with_own_type_builder(self): 85 | # -- CARDINALITY: 1..* = one_or_more = many 86 | class MyCardinalityFieldTypeBuilder(CardinalityFieldTypeBuilder): 87 | listsep = ';' 88 | 89 | type_builder = MyCardinalityFieldTypeBuilder 90 | existing_types = dict(Number=parse_number) 91 | self.assertFalse("Number+" in existing_types) 92 | 93 | # -- ENSURE: Missing type variant is created. 94 | schema = "List: {numbers:Number+}" 95 | parser = Parser(schema, existing_types, type_builder=type_builder) 96 | self.assertTrue("Number+" in existing_types) 97 | 98 | # -- ENSURE: Newly created type variant is usable. 99 | # NOTE: Use other list separator. 100 | self.assert_match(parser, "List: 42", "numbers", [42]) 101 | self.assert_match(parser, "List: 1; 2; 3", "numbers", [1, 2, 3]) 102 | self.assert_match(parser, "List: 4;5;6", "numbers", [4, 5, 6]) 103 | self.assert_mismatch(parser, "List: ") 104 | self.assert_mismatch(parser, "List:") 105 | self.assert_mismatch(parser, "List: XXX") 106 | self.assert_mismatch(parser, "List: -123") 107 | 108 | def test_parser__can_parse_cardinality_field_many0(self): 109 | # -- CARDINALITY: 0..* = zero_or_more = many0 110 | existing_types = dict(Number=parse_number) 111 | self.assertFalse("Number*" in existing_types) 112 | 113 | # -- ENSURE: Missing type variant is created. 114 | schema = "List0: {numbers:Number*}" 115 | parser = Parser(schema, existing_types) 116 | self.assertTrue("Number*" in existing_types) 117 | 118 | # -- ENSURE: Newly created type variant is usable. 119 | self.assert_match(parser, "List0: 42", "numbers", [42]) 120 | self.assert_match(parser, "List0: 1, 2, 3", "numbers", [1, 2, 3]) 121 | self.assert_match(parser, "List0: ", "numbers", []) 122 | self.assert_mismatch(parser, "List0:") 123 | self.assert_mismatch(parser, "List0: XXX") 124 | self.assert_mismatch(parser, "List0: -123") 125 | 126 | 127 | def test_create_missing_types__without_cardinality_fields_in_schema(self): 128 | schemas = ["{}", "{:Number}", "{number3}", "{number4:Number}", "XXX"] 129 | existing_types = {} 130 | for schema in schemas: 131 | new_types = Parser.create_missing_types(schema, existing_types) 132 | self.assertEqual(len(new_types), 0) 133 | self.assertEqual(new_types, {}) 134 | 135 | def test_create_missing_types__raises_error_if_primary_type_is_missing(self): 136 | # -- HINT: primary type is not provided in type_dict (existing_types) 137 | existing_types = {} 138 | for schema, missing_types in self.SPECIAL_FIELD_TYPES_DATA: 139 | with self.assertRaises(MissingTypeError): 140 | Parser.create_missing_types(schema, existing_types) 141 | 142 | def test_create_missing_types__if_special_types_are_missing(self): 143 | existing_types = dict(Number=parse_number) 144 | for schema, missing_types in self.SPECIAL_FIELD_TYPES_DATA: 145 | new_types = Parser.create_missing_types(schema, existing_types) 146 | self.assertSequenceEqual(set(new_types.keys()), set(missing_types)) 147 | 148 | def test_create_missing_types__if_special_types_exist(self): 149 | existing_types = dict(Number=parse_number) 150 | for schema, missing_types in self.SPECIAL_FIELD_TYPES_DATA: 151 | # -- FIRST STEP: Prepare 152 | new_types = Parser.create_missing_types(schema, existing_types) 153 | self.assertGreater(len(new_types), 0) 154 | 155 | # -- SECOND STEP: Now all needed special types should exist. 156 | existing_types2 = existing_types.copy() 157 | existing_types2.update(new_types) 158 | new_types2 = Parser.create_missing_types(schema, existing_types2) 159 | self.assertEqual(len(new_types2), 0) 160 | 161 | 162 | # ----------------------------------------------------------------------------- 163 | # MAIN: 164 | # ----------------------------------------------------------------------------- 165 | if __name__ == '__main__': 166 | unittest.main() 167 | 168 | 169 | # Copyright (c) 2012-2013 by Jens Engel (https://github/jenisys/parse_type) 170 | # 171 | # Permission is hereby granted, free of charge, to any person obtaining a copy 172 | # of this software and associated documentation files (the "Software"), to deal 173 | # in the Software without restriction, including without limitation the rights 174 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 175 | # copies of the Software, and to permit persons to whom the Software is 176 | # furnished to do so, subject to the following conditions: 177 | # 178 | # The above copyright notice and this permission notice shall be included in 179 | # all copies or substantial portions of the Software. 180 | # 181 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 182 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 183 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 184 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 185 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 186 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 187 | # SOFTWARE. 188 | -------------------------------------------------------------------------------- /tests/test_parse_decorator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # pylint: disable=invalid-name, missing-docstring, too-few-public-methods 4 | """ 5 | Integrated into :mod:`parse` module. 6 | """ 7 | 8 | from __future__ import absolute_import 9 | import unittest 10 | import parse 11 | from parse_type import build_type_dict 12 | from .parse_type_test import ParseTypeTestCase 13 | 14 | 15 | # ----------------------------------------------------------------------------- 16 | # TEST CASE: TestParseTypeWithPatternDecorator 17 | # ----------------------------------------------------------------------------- 18 | class TestParseTypeWithPatternDecorator(ParseTypeTestCase): 19 | r""" 20 | Test the pattern decorator for type-converter (parse_type) functions. 21 | 22 | >>> def parse_number(text): 23 | ... return int(text) 24 | >>> parse_number.pattern = r"\d+" 25 | 26 | is equivalent to: 27 | 28 | >>> import parse 29 | >>> @parse.with_pattern(r"\d+") 30 | ... def parse_number(text): 31 | ... return int(text) 32 | 33 | >>> assert hasattr(parse_number, "pattern") 34 | >>> assert parse_number.pattern == r"\d+" 35 | """ 36 | 37 | def assert_decorated_with_pattern(self, func, expected_pattern): 38 | self.assertTrue(callable(func)) 39 | self.assertTrue(hasattr(func, "pattern")) 40 | self.assertEqual(func.pattern, expected_pattern) 41 | 42 | def assert_converter_call(self, func, text, expected_value): 43 | value = func(text) 44 | self.assertEqual(value, expected_value) 45 | 46 | # -- TESTS: 47 | def test_function_with_pattern_decorator(self): 48 | @parse.with_pattern(r"\d+") 49 | def parse_number(text): 50 | return int(text) 51 | 52 | self.assert_decorated_with_pattern(parse_number, r"\d+") 53 | self.assert_converter_call(parse_number, "123", 123) 54 | 55 | def test_classmethod_with_pattern_decorator(self): 56 | choice_pattern = r"Alice|Bob|Charly" 57 | class C(object): 58 | @classmethod 59 | @parse.with_pattern(choice_pattern) 60 | def parse_choice(cls, text): 61 | return text 62 | 63 | self.assert_decorated_with_pattern(C.parse_choice, choice_pattern) 64 | self.assert_converter_call(C.parse_choice, "Alice", "Alice") 65 | 66 | def test_staticmethod_with_pattern_decorator(self): 67 | choice_pattern = r"Alice|Bob|Charly" 68 | class S(object): 69 | @staticmethod 70 | @parse.with_pattern(choice_pattern) 71 | def parse_choice(text): 72 | return text 73 | 74 | self.assert_decorated_with_pattern(S.parse_choice, choice_pattern) 75 | self.assert_converter_call(S.parse_choice, "Bob", "Bob") 76 | 77 | def test_decorated_function_with_parser(self): 78 | # -- SETUP: 79 | @parse.with_pattern(r"\d+") 80 | def parse_number(text): 81 | return int(text) 82 | 83 | parse_number.name = "Number" #< For test automation. 84 | more_types = build_type_dict([parse_number]) 85 | schema = "Test: {number:Number}" 86 | parser = parse.Parser(schema, more_types) 87 | 88 | # -- PERFORM TESTS: 89 | # pylint: disable=bad-whitespace 90 | self.assert_match(parser, "Test: 1", "number", 1) 91 | self.assert_match(parser, "Test: 42", "number", 42) 92 | self.assert_match(parser, "Test: 123", "number", 123) 93 | 94 | # -- PARSE MISMATCH: 95 | self.assert_mismatch(parser, "Test: x", "number") # Not a Number. 96 | self.assert_mismatch(parser, "Test: -1", "number") # Negative. 97 | self.assert_mismatch(parser, "Test: a, b", "number") # List of ... 98 | 99 | def test_decorated_classmethod_with_parser(self): 100 | # -- SETUP: 101 | class C(object): 102 | @classmethod 103 | @parse.with_pattern(r"Alice|Bob|Charly") 104 | def parse_person(cls, text): 105 | return text 106 | 107 | more_types = {"Person": C.parse_person} 108 | schema = "Test: {person:Person}" 109 | parser = parse.Parser(schema, more_types) 110 | 111 | # -- PERFORM TESTS: 112 | # pylint: disable=bad-whitespace 113 | self.assert_match(parser, "Test: Alice", "person", "Alice") 114 | self.assert_match(parser, "Test: Bob", "person", "Bob") 115 | 116 | # -- PARSE MISMATCH: 117 | self.assert_mismatch(parser, "Test: ", "person") # Missing. 118 | self.assert_mismatch(parser, "Test: BAlice", "person") # Similar1. 119 | self.assert_mismatch(parser, "Test: Boby", "person") # Similar2. 120 | self.assert_mismatch(parser, "Test: a", "person") # INVALID ... 121 | 122 | # ----------------------------------------------------------------------------- 123 | # MAIN: 124 | # ----------------------------------------------------------------------------- 125 | if __name__ == '__main__': 126 | unittest.main() 127 | 128 | 129 | # Copyright (c) 2012-2013 by Jens Engel (https://github/jenisys/parse_type) 130 | # 131 | # Permission is hereby granted, free of charge, to any person obtaining a copy 132 | # of this software and associated documentation files (the "Software"), to deal 133 | # in the Software without restriction, including without limitation the rights 134 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 135 | # copies of the Software, and to permit persons to whom the Software is 136 | # furnished to do so, subject to the following conditions: 137 | # 138 | # The above copyright notice and this permission notice shall be included in 139 | # all copies or substantial portions of the Software. 140 | # 141 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 142 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 143 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 144 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 145 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 146 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 147 | # SOFTWARE. 148 | -------------------------------------------------------------------------------- /tests/test_parse_number.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Additional unit tests for the :mod`parse` module. 4 | Related to auto-detection of number base (base=10, 2, 8, 16). 5 | """ 6 | 7 | from __future__ import absolute_import, print_function 8 | import pytest 9 | import parse 10 | 11 | parse_version = parse.__version__ 12 | print("USING: parse-%s" % parse_version) 13 | if parse_version in ("1.17.0", "1.16.0"): 14 | # -- REQUIRES: parse >= 1.18.0 -- WORKAROUND HERE 15 | print("USING: parse_type.parse (INSTEAD)") 16 | from parse_type import parse 17 | 18 | def assert_parse_number_with_format_d(text, expected): 19 | parser = parse.Parser("{value:d}") 20 | result = parser.parse(text) 21 | assert result.named == dict(value=expected) 22 | 23 | @pytest.mark.parametrize("text, expected", [ 24 | ("123", 123) 25 | ]) 26 | def test_parse_number_with_base10(text, expected): 27 | assert_parse_number_with_format_d(text, expected) 28 | 29 | @pytest.mark.parametrize("text, expected", [ 30 | ("0b0", 0), 31 | ("0b1011", 11), 32 | ]) 33 | def test_parse_number_with_base2(text, expected): 34 | assert_parse_number_with_format_d(text, expected) 35 | 36 | @pytest.mark.parametrize("text, expected", [ 37 | ("0o0", 0), 38 | ("0o10", 8), 39 | ("0o12", 10), 40 | ]) 41 | def test_parse_number_with_base8(text, expected): 42 | assert_parse_number_with_format_d(text, expected) 43 | 44 | @pytest.mark.parametrize("text, expected", [ 45 | ("0x0", 0), 46 | ("0x01", 1), 47 | ("0x12", 18), 48 | ]) 49 | def test_parse_number_with_base16(text, expected): 50 | assert_parse_number_with_format_d(text, expected) 51 | 52 | 53 | @pytest.mark.parametrize("text1, expected1, text2, expected2", [ 54 | ("0x12", 18, "12", 12) 55 | ]) 56 | def test_parse_number_twice(text1, expected1, text2, expected2): 57 | """ENSURE: Issue #121 int_convert memory effect is fixed.""" 58 | parser = parse.Parser("{:d}") 59 | result1 = parser.parse(text1) 60 | result2 = parser.parse(text2) 61 | assert result1.fixed[0] == expected1 62 | assert result2.fixed[0] == expected2 63 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # ============================================================================ 2 | # TOX CONFIGURATION: parse_type 3 | # ============================================================================ 4 | # DESCRIPTION: 5 | # Use tox to run tasks (tests, ...) in a clean virtual environment. 6 | # Tox is configured by default for online usage. 7 | # 8 | # Run tox, like: 9 | # 10 | # tox -e py27 # Runs tox with python 2.7 11 | # tox -e py39 # Runs tox with python 3.9 12 | # tox # Runs tox with all installed python versions. 13 | # tox --parallel # Runs tox in parallel mode w/ all envs. 14 | # 15 | # SEE ALSO: 16 | # * https://tox.readthedocs.io/en/latest/config.html 17 | # ============================================================================ 18 | # -- ONLINE USAGE: 19 | # PIP_INDEX_URL = https://pypi.org/simple 20 | 21 | [tox] 22 | minversion = 3.10.0 23 | envlist = py312, py311, py310, py39, doctest, pypy3 24 | skip_missing_interpreters = True 25 | isolated_build = True 26 | # DISABLED: sitepackages = False 27 | 28 | 29 | # ----------------------------------------------------------------------------- 30 | # TEST ENVIRONMENTS: 31 | # ----------------------------------------------------------------------------- 32 | # install_command = pip install -U {opts} {packages} 33 | [testenv] 34 | install_command = pip install -U {opts} {packages} 35 | changedir = {toxinidir} 36 | commands = 37 | pytest {posargs:tests} 38 | deps = 39 | -r py.requirements/basic.txt 40 | -r py.requirements/testing.txt 41 | setenv = 42 | PYTHONPATH={toxinidir} 43 | TOXRUN = yes 44 | PYSETUP_BOOTSTRAP = no 45 | 46 | 47 | # -- SPECIAL CASE: 48 | # RELATED: https://github.com/pypa/virtualenv/issues/2284 -- macOS 12 Monterey related 49 | # NOTES: 50 | # * pip-install seems to need "--user" option. 51 | # * Script(s) do not seem to be installed any more (actually to $HOME/User area). 52 | [testenv:py27] 53 | # DISABLED: install_command = pip install --user -U {opts} {packages} 54 | install_command = pip install -U {opts} {packages} 55 | changedir = {toxinidir} 56 | commands= 57 | python -m pytest {posargs:tests} 58 | deps= 59 | {[testenv]deps} 60 | passenv = 61 | PYTHONPATH = {toxinidir} 62 | # MAYBE: allowlist_externals = curl 63 | 64 | 65 | # -- VIRTUAL-ENVIRONMENT SETUP PROCEDURE: For python 2.7 66 | # virtualenv -p python2.7 .venv_py27 67 | # source .venv_py27 68 | # scripts/ensurepip_python27.sh 69 | # python -m pip install -r py.requirements/basic.txt 70 | # python -m pip install -r py.requirements/testing.txt 71 | 72 | [testenv:doctest] 73 | basepython = python3 74 | commands = 75 | pytest --doctest-modules -v parse_type 76 | setenv = 77 | PYTHONPATH={toxinidir} 78 | 79 | 80 | # ----------------------------------------------------------------------------- 81 | # MORE TEST ENVIRONMENTS: 82 | # ----------------------------------------------------------------------------- 83 | [testenv:coverage] 84 | basepython = python3 85 | commands = 86 | pytest --cov=parse_type {posargs:tests} 87 | coverage combine 88 | coverage html 89 | coverage xml 90 | deps = 91 | {[testenv]deps} 92 | pytest-cov 93 | coverage>=4.0 94 | setenv = 95 | PYTHONPATH={toxinidir} 96 | 97 | 98 | [testenv:install] 99 | basepython = python3 100 | changedir = {envdir} 101 | commands = 102 | python ../../setup.py install -q 103 | {toxinidir}/bin/toxcmd.py copytree ../../tests . 104 | pytest {posargs:tests} 105 | deps = 106 | {[testenv]deps} 107 | setenv = 108 | PYTHONPATH={toxinidir} 109 | 110 | 111 | # ----------------------------------------------------------------------------- 112 | # SELDOM USED TEST ENVIRONMENTS: 113 | # ----------------------------------------------------------------------------- 114 | # -- ENSURE: README.rst is well-formed. 115 | # python setup.py --long-description | rst2html.py >output.html 116 | [testenv:check_setup] 117 | changedir = {toxinidir} 118 | commands= 119 | python setup.py --long-description > output.tmp 120 | rst2html.py output.tmp output.html 121 | deps = 122 | docutils 123 | --------------------------------------------------------------------------------