diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..ce2f455f --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +relative_files = True \ No newline at end of file diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 00000000..3113e504 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,67 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '27 19 * * 5' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: [ 'javascript', 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 00000000..fe0ea032 --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,63 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python package + +on: + push: + branches: [master, main, develop, feature/*, bugfix/*] + pull_request: + branches: [master, main, develop, feature/*, bugfix/*] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v3 + with: + path: .venv + key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + run: poetry install --no-interaction --no-root --with dev + + - name: Install project + run: poetry install --no-interaction + + - name: Run tests + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: poetry run pytest + + - name: Run linting + continue-on-error: true + run: | + poetry run flake8 gitinspector tests --count --select=E9,F63,F7,F82 --show-source --statistics --builtins="_" + poetry run pylint --rcfile=.pylintrc gitinspector + + - name: Run type checking + continue-on-error: true + run: poetry run mypy gitinspector diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..9e720ce0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip wheel twine + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Test + run: make dist + + - name: Release + id: release + uses: softprops/action-gh-release@v1 + with: + files: dist/* + fail_on_unmatched_files: true + prerelease: ${{ endsWith(github.ref, 'dev') || endsWith(github.ref, 'pre') }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index b6f142c9..b43e5d88 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ node_modules *.egg-info *.pyc *.tgz +.DS_Store +.coverage \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..d438b32f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,329 @@ +# Contributing to GitInspector + +Thank you for your interest in contributing to GitInspector! This document provides guidelines and instructions for setting up your development environment and contributing to the project. + +## Table of Contents + +- [Development Setup](#development-setup) +- [Project Structure](#project-structure) +- [Development Workflow](#development-workflow) +- [Code Quality](#code-quality) +- [Testing](#testing) +- [Submitting Changes](#submitting-changes) +- [Release Process](#release-process) + +## Development Setup + +### Prerequisites + +- **Python 3.10 or higher** - GitInspector requires Python 3.10+ +- **Poetry** - We use Poetry for dependency management +- **Git** - For version control + +### Installing Poetry + +If you don't have Poetry installed, you can install it using: + +```bash +# On macOS/Linux +curl -sSL https://install.python-poetry.org | python3 - + +# On Windows (PowerShell) +(Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | python - + +# Alternative: using pip +pip install poetry +``` + +### Setting Up the Development Environment + +1. **Clone the repository:** + ```bash + git clone https://github.com/ejwa/gitinspector.git + cd gitinspector + ``` + +2. **Install dependencies:** + ```bash + # Install all dependencies including development tools + poetry install --with dev + + # Activate the virtual environment + poetry shell + ``` + +3. **Verify the installation:** + ```bash + # Run tests to ensure everything is working + poetry run pytest + + # Run the application + poetry run gitinspector --help + ``` + +## Project Structure + +``` +gitinspector/ +├── gitinspector/ # Main package +│ ├── __init__.py +│ ├── gitinspector.py # Main entry point +│ ├── blame.py # Blame analysis +│ ├── changes.py # Change tracking +│ ├── output/ # Output formatters +│ └── ... +├── tests/ # Test suite +├── docs/ # Documentation +├── pyproject.toml # Poetry configuration +├── Makefile # Development commands +└── CONTRIBUTING.md # This file +``` + +## Development Workflow + +### Available Make Commands + +We provide a Makefile with common development tasks: + +```bash +# Show all available commands +make help + +# Install development dependencies +make dev-install + +# Run tests +make test + +# Run tests with coverage +make test-coverage + +# Run linting +make lint + +# Format code +make format + +# Run type checking +make type-check + +# Build the package +make dist + +# Clean build artifacts +make clean +``` + +### Using Poetry Directly + +You can also use Poetry commands directly: + +```bash +# Install dependencies +poetry install --with dev + +# Run tests +poetry run pytest + +# Run linting +poetry run flake8 gitinspector tests +poetry run pylint gitinspector + +# Format code +poetry run black gitinspector tests +poetry run isort gitinspector tests + +# Type checking +poetry run mypy gitinspector + +# Build package +poetry build + +# Update dependencies +poetry update +``` + +## Code Quality + +We maintain high code quality standards using several tools: + +### Code Formatting + +- **Black**: Code formatter with 120 character line length +- **isort**: Import sorting + +Run formatting with: +```bash +make format +# or +poetry run black gitinspector tests +poetry run isort gitinspector tests +``` + +### Linting + +- **flake8**: Style guide enforcement +- **pylint**: Static code analysis + +Run linting with: +```bash +make lint +# or +poetry run flake8 gitinspector tests +poetry run pylint gitinspector +``` + +### Type Checking + +- **mypy**: Static type checking + +Run type checking with: +```bash +make type-check +# or +poetry run mypy gitinspector +``` + +### Code Style Guidelines + +1. **Python Version**: Target Python 3.10+ features +2. **Line Length**: Maximum 120 characters +3. **Type Hints**: Use type hints for all public functions and methods +4. **Docstrings**: Use Google-style docstrings for all public APIs +5. **F-strings**: Use f-strings for string formatting (no % formatting) +6. **Modern Python**: Use modern Python idioms and features + +## Testing + +### Running Tests + +```bash +# Run all tests +make test + +# Run with coverage +make test-coverage + +# Run specific test file +poetry run pytest tests/test_specific.py + +# Run tests with verbose output +poetry run pytest -v + +# Run tests matching a pattern +poetry run pytest -k "test_pattern" +``` + +### Writing Tests + +1. Place tests in the `tests/` directory +2. Name test files with `test_` prefix +3. Use pytest fixtures for setup/teardown +4. Aim for high test coverage +5. Write both unit and integration tests + +### Test Categories + +- **Unit Tests**: Test individual functions/classes +- **Integration Tests**: Test component interactions +- **Slow Tests**: Mark with `@pytest.mark.slow` for long-running tests + +## Submitting Changes + +### Before Submitting + +1. **Run the full test suite:** + ```bash + make test + ``` + +2. **Check code quality:** + ```bash + make lint + make type-check + ``` + +3. **Format your code:** + ```bash + make format + ``` + +4. **Update documentation** if needed + +### Pull Request Process + +1. **Fork the repository** on GitHub +2. **Create a feature branch** from `master`: + ```bash + git checkout -b feature/your-feature-name + ``` +3. **Make your changes** following the guidelines above +4. **Add tests** for new functionality +5. **Update documentation** as needed +6. **Commit your changes** with clear, descriptive messages +7. **Push to your fork** and create a pull request + +### Commit Message Guidelines + +- Use the present tense ("Add feature" not "Added feature") +- Use the imperative mood ("Move cursor to..." not "Moves cursor to...") +- Limit the first line to 72 characters or less +- Reference issues and pull requests liberally after the first line + +## Release Process + +### Version Management + +We use semantic versioning (SemVer): +- **MAJOR**: Incompatible API changes +- **MINOR**: New functionality (backward compatible) +- **PATCH**: Bug fixes (backward compatible) + +### Creating a Release + +1. **Update version** in `pyproject.toml` +2. **Update CHANGES.txt** with release notes +3. **Create and push tag:** + ```bash + make tag-version + make push-tagged-version + ``` +4. **Build and publish:** + ```bash + make dist + make release + ``` + +## Getting Help + +- **Issues**: Report bugs and request features on [GitHub Issues](https://github.com/ejwa/gitinspector/issues) +- **Discussions**: Join discussions on [GitHub Discussions](https://github.com/ejwa/gitinspector/discussions) +- **Email**: Contact the maintainers at gitinspector@ejwa.se + +## Development Tips + +### IDE Setup + +For the best development experience: + +1. **Configure your IDE** to use the Poetry virtual environment +2. **Enable type checking** with mypy +3. **Set up code formatting** to run on save +4. **Configure linting** to show errors inline + +### Common Issues + +1. **Poetry not found**: Make sure Poetry is in your PATH +2. **Python version issues**: Ensure you have Python 3.10+ installed +3. **Virtual environment issues**: Try `poetry env remove python` and `poetry install` + +### Performance Testing + +When making performance-related changes: + +1. **Benchmark before and after** your changes +2. **Test with large repositories** to ensure scalability +3. **Profile your code** to identify bottlenecks +4. **Consider memory usage** as well as execution time + +Thank you for contributing to GitInspector! 🎉 diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..c8deeffd --- /dev/null +++ b/Makefile @@ -0,0 +1,84 @@ +.PHONY: clean clean-test clean-pyc clean-build docs help +.DEFAULT_GOAL := help + +define PRINT_HELP_PYSCRIPT +import re, sys + +for line in sys.stdin: + match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) + if match: + target, help = match.groups() + print("%-20s %s" % (target, help)) +endef +export PRINT_HELP_PYSCRIPT + +help: + @poetry run python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) + +clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts + +clean-build: ## remove build artifacts + rm -fr build/ + rm -fr dist/ + rm -fr .eggs/ + find . -name '*.egg-info' -exec rm -fr {} + + find . -name '*.egg' -exec rm -f {} + + +clean-pyc: ## remove Python file artifacts + find . -name '*.pyc' -exec rm -f {} + + find . -name '*.pyo' -exec rm -f {} + + find . -name '*~' -exec rm -f {} + + find . -name '__pycache__' -exec rm -fr {} + + +clean-test: ## remove test and coverage artifacts + rm -f .coverage + rm -fr .pytest_cache + +lint: ## check style with flake8 and pylint + # stop the build if there are Python syntax errors or undefined names + poetry run flake8 gitinspector tests --count --select=E9,F63,F7,F82 --show-source --statistics --builtins="_" + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + poetry run flake8 gitinspector tests --count --ignore=E203,E722,W503,E401,C901,W191 --exit-zero --max-complexity=10 --max-line-length=127 --statistics --builtins="_" + poetry run pylint --rcfile=.pylintrc gitinspector + +test: ## run tests quickly with the default Python + poetry run pytest + +test-coverage: ## check code coverage quickly with the default Python + poetry run coverage run --source gitinspector -m pytest + poetry run coverage report -m + +release: dist ## package and upload a release + poetry publish + +tag-version: + @export VERSION_TAG=`poetry run python3 -c "from gitinspector.version import __version__; print(__version__)"` \ + && git tag v$$VERSION_TAG + +untag-version: + @export VERSION_TAG=`poetry run python3 -c "from gitinspector.version import __version__; print(__version__)"` \ + && git tag -d v$$VERSION_TAG + +push-tagged-version: tag-version + @export VERSION_TAG=`poetry run python3 -c "from gitinspector.version import __version__; print(__version__)"` \ + && git push origin v$$VERSION_TAG + +dist: clean ## builds source and wheel package + poetry build + ls -l dist + +install: clean ## install the package to the active Python's site-packages + poetry install + +format: ## format code with black and isort + poetry run black gitinspector tests + poetry run isort gitinspector tests + +type-check: ## run type checking with mypy + poetry run mypy gitinspector + +dev-install: ## install development dependencies + poetry install --with dev + +update-deps: ## update dependencies + poetry update \ No newline at end of file diff --git a/Pipfile b/Pipfile new file mode 100644 index 00000000..a1578441 --- /dev/null +++ b/Pipfile @@ -0,0 +1,22 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +black-but-with-tabs-instead-of-spaces = "*" +pytest = "*" +pylint = "*" +flake8 = "*" +coverage = "*" +twine = "*" + +[dev-packages] +pytest = "*" +flake8 = "*" +twine = "*" +coverage = "*" +coveralls = "*" + +[pipenv] +allow_prereleases = true diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 00000000..89e8cbdd --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,1164 @@ +{ + "_meta": { + "hash": { + "sha256": "62ec83a0cee621b78af2365fb831541abe30a428f13f682149bd94067b11cb23" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "appdirs": { + "hashes": [ + "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", + "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" + ], + "version": "==1.4.4" + }, + "astroid": { + "hashes": [ + "sha256:078e5212f9885fa85fbb0cf0101978a336190aadea6e13305409d099f71b2324", + "sha256:1039262575027b441137ab4a62a793a9b43defb42c32d5670f38686207cd780f" + ], + "markers": "python_full_version >= '3.7.2'", + "version": "==2.15.5" + }, + "attrs": { + "hashes": [ + "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04", + "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015" + ], + "markers": "python_version >= '3.7'", + "version": "==23.1.0" + }, + "black-but-with-tabs-instead-of-spaces": { + "hashes": [ + "sha256:01b00ac677000874b86c6f22efc965ab2cc16645a27b86b01bac2fed68a5a12e", + "sha256:bd5dd0842cef0a2c6714bd7381c8ead9106f68c64c64c706679a6a7fabb7ba48" + ], + "index": "pypi", + "version": "==19.11" + }, + "bleach": { + "hashes": [ + "sha256:1a1a85c1595e07d8db14c5f09f09e6433502c51c595970edc090551f0db99414", + "sha256:33c16e3353dbd13028ab4799a0f89a83f113405c766e9c122df8a06f5b85b3f4" + ], + "markers": "python_version >= '3.7'", + "version": "==6.0.0" + }, + "certifi": { + "hashes": [ + "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7", + "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716" + ], + "markers": "python_version >= '3.6'", + "version": "==2023.5.7" + }, + "charset-normalizer": { + "hashes": [ + "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6", + "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1", + "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e", + "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373", + "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62", + "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230", + "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be", + "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c", + "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0", + "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448", + "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f", + "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649", + "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d", + "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0", + "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706", + "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a", + "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59", + "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23", + "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5", + "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb", + "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e", + "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e", + "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c", + "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28", + "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d", + "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41", + "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974", + "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce", + "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f", + "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1", + "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d", + "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8", + "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017", + "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31", + "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7", + "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8", + "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e", + "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14", + "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd", + "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d", + "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795", + "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b", + "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b", + "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b", + "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203", + "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f", + "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19", + "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1", + "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a", + "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac", + "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9", + "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0", + "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137", + "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f", + "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6", + "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5", + "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909", + "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f", + "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0", + "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324", + "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755", + "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb", + "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854", + "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c", + "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60", + "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84", + "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0", + "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b", + "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1", + "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531", + "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1", + "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11", + "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326", + "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df", + "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==3.1.0" + }, + "click": { + "hashes": [ + "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", + "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.3" + }, + "coverage": { + "hashes": [ + "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f", + "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2", + "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a", + "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a", + "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01", + "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6", + "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7", + "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f", + "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02", + "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c", + "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063", + "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a", + "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5", + "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959", + "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97", + "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6", + "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f", + "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9", + "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5", + "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f", + "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562", + "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe", + "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9", + "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f", + "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb", + "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb", + "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1", + "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb", + "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250", + "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e", + "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511", + "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5", + "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59", + "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2", + "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d", + "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3", + "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4", + "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de", + "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9", + "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833", + "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0", + "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9", + "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d", + "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050", + "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d", + "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6", + "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353", + "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb", + "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e", + "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8", + "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495", + "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2", + "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd", + "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27", + "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1", + "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818", + "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4", + "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e", + "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850", + "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3" + ], + "index": "pypi", + "version": "==7.2.7" + }, + "dill": { + "hashes": [ + "sha256:a07ffd2351b8c678dfc4a856a3005f8067aea51d6ba6c700796a4d9e280f39f0", + "sha256:e5db55f3687856d8fbdab002ed78544e1c4559a130302693d839dfe8f93f2373" + ], + "markers": "python_version >= '3.11'", + "version": "==0.3.6" + }, + "docutils": { + "hashes": [ + "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", + "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b" + ], + "markers": "python_version >= '3.7'", + "version": "==0.20.1" + }, + "flake8": { + "hashes": [ + "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7", + "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181" + ], + "index": "pypi", + "version": "==6.0.0" + }, + "idna": { + "hashes": [ + "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", + "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" + ], + "markers": "python_version >= '3.5'", + "version": "==3.4" + }, + "importlib-metadata": { + "hashes": [ + "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed", + "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705" + ], + "markers": "python_version >= '3.7'", + "version": "==6.6.0" + }, + "iniconfig": { + "hashes": [ + "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", + "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "isort": { + "hashes": [ + "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504", + "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==5.12.0" + }, + "jaraco.classes": { + "hashes": [ + "sha256:2353de3288bc6b82120752201c6b1c1a14b058267fa424ed5ce5984e3b922158", + "sha256:89559fa5c1d3c34eff6f631ad80bb21f378dbcbb35dd161fd2c6b93f5be2f98a" + ], + "markers": "python_version >= '3.7'", + "version": "==3.2.3" + }, + "keyring": { + "hashes": [ + "sha256:771ed2a91909389ed6148631de678f82ddc73737d85a927f382a8a1b157898cd", + "sha256:ba2e15a9b35e21908d0aaf4e0a47acc52d6ae33444df0da2b49d41a46ef6d678" + ], + "markers": "python_version >= '3.7'", + "version": "==23.13.1" + }, + "lazy-object-proxy": { + "hashes": [ + "sha256:09763491ce220c0299688940f8dc2c5d05fd1f45af1e42e636b2e8b2303e4382", + "sha256:0a891e4e41b54fd5b8313b96399f8b0e173bbbfc03c7631f01efbe29bb0bcf82", + "sha256:189bbd5d41ae7a498397287c408617fe5c48633e7755287b21d741f7db2706a9", + "sha256:18b78ec83edbbeb69efdc0e9c1cb41a3b1b1ed11ddd8ded602464c3fc6020494", + "sha256:1aa3de4088c89a1b69f8ec0dcc169aa725b0ff017899ac568fe44ddc1396df46", + "sha256:212774e4dfa851e74d393a2370871e174d7ff0ebc980907723bb67d25c8a7c30", + "sha256:2d0daa332786cf3bb49e10dc6a17a52f6a8f9601b4cf5c295a4f85854d61de63", + "sha256:5f83ac4d83ef0ab017683d715ed356e30dd48a93746309c8f3517e1287523ef4", + "sha256:659fb5809fa4629b8a1ac5106f669cfc7bef26fbb389dda53b3e010d1ac4ebae", + "sha256:660c94ea760b3ce47d1855a30984c78327500493d396eac4dfd8bd82041b22be", + "sha256:66a3de4a3ec06cd8af3f61b8e1ec67614fbb7c995d02fa224813cb7afefee701", + "sha256:721532711daa7db0d8b779b0bb0318fa87af1c10d7fe5e52ef30f8eff254d0cd", + "sha256:7322c3d6f1766d4ef1e51a465f47955f1e8123caee67dd641e67d539a534d006", + "sha256:79a31b086e7e68b24b99b23d57723ef7e2c6d81ed21007b6281ebcd1688acb0a", + "sha256:81fc4d08b062b535d95c9ea70dbe8a335c45c04029878e62d744bdced5141586", + "sha256:8fa02eaab317b1e9e03f69aab1f91e120e7899b392c4fc19807a8278a07a97e8", + "sha256:9090d8e53235aa280fc9239a86ae3ea8ac58eff66a705fa6aa2ec4968b95c821", + "sha256:946d27deaff6cf8452ed0dba83ba38839a87f4f7a9732e8f9fd4107b21e6ff07", + "sha256:9990d8e71b9f6488e91ad25f322898c136b008d87bf852ff65391b004da5e17b", + "sha256:9cd077f3d04a58e83d04b20e334f678c2b0ff9879b9375ed107d5d07ff160171", + "sha256:9e7551208b2aded9c1447453ee366f1c4070602b3d932ace044715d89666899b", + "sha256:9f5fa4a61ce2438267163891961cfd5e32ec97a2c444e5b842d574251ade27d2", + "sha256:b40387277b0ed2d0602b8293b94d7257e17d1479e257b4de114ea11a8cb7f2d7", + "sha256:bfb38f9ffb53b942f2b5954e0f610f1e721ccebe9cce9025a38c8ccf4a5183a4", + "sha256:cbf9b082426036e19c6924a9ce90c740a9861e2bdc27a4834fd0a910742ac1e8", + "sha256:d9e25ef10a39e8afe59a5c348a4dbf29b4868ab76269f81ce1674494e2565a6e", + "sha256:db1c1722726f47e10e0b5fdbf15ac3b8adb58c091d12b3ab713965795036985f", + "sha256:e7c21c95cae3c05c14aafffe2865bbd5e377cfc1348c4f7751d9dc9a48ca4bda", + "sha256:e8c6cfb338b133fbdbc5cfaa10fe3c6aeea827db80c978dbd13bc9dd8526b7d4", + "sha256:ea806fd4c37bf7e7ad82537b0757999264d5f70c45468447bb2b91afdbe73a6e", + "sha256:edd20c5a55acb67c7ed471fa2b5fb66cb17f61430b7a6b9c3b4a1e40293b1671", + "sha256:f0117049dd1d5635bbff65444496c90e0baa48ea405125c088e93d9cf4525b11", + "sha256:f0705c376533ed2a9e5e97aacdbfe04cecd71e0aa84c7c0595d02ef93b6e4455", + "sha256:f12ad7126ae0c98d601a7ee504c1122bcef553d1d5e0c3bfa77b16b3968d2734", + "sha256:f2457189d8257dd41ae9b434ba33298aec198e30adf2dcdaaa3a28b9994f6adb", + "sha256:f699ac1c768270c9e384e4cbd268d6e67aebcfae6cd623b4d7c3bfde5a35db59" + ], + "markers": "python_version >= '3.7'", + "version": "==1.9.0" + }, + "markdown-it-py": { + "hashes": [ + "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", + "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" + ], + "markers": "python_version >= '3.8'", + "version": "==3.0.0" + }, + "mccabe": { + "hashes": [ + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, + "mdurl": { + "hashes": [ + "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.1.2" + }, + "more-itertools": { + "hashes": [ + "sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d", + "sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3" + ], + "markers": "python_version >= '3.7'", + "version": "==9.1.0" + }, + "mypy-extensions": { + "hashes": [ + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.0" + }, + "packaging": { + "hashes": [ + "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", + "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" + ], + "markers": "python_version >= '3.7'", + "version": "==23.1" + }, + "pathspec": { + "hashes": [ + "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687", + "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293" + ], + "markers": "python_version >= '3.7'", + "version": "==0.11.1" + }, + "pkginfo": { + "hashes": [ + "sha256:4b7a555a6d5a22169fcc9cf7bfd78d296b0361adad412a346c1226849af5e546", + "sha256:8fd5896e8718a4372f0ea9cc9d96f6417c9b986e23a4d116dda26b62cc29d046" + ], + "markers": "python_version >= '3.6'", + "version": "==1.9.6" + }, + "platformdirs": { + "hashes": [ + "sha256:0ade98a4895e87dc51d47151f7d2ec290365a585151d97b4d8d6312ed6132fed", + "sha256:e48fabd87db8f3a7df7150a4a5ea22c546ee8bc39bc2473244730d4b56d2cc4e" + ], + "markers": "python_version >= '3.7'", + "version": "==3.5.3" + }, + "pluggy": { + "hashes": [ + "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", + "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" + ], + "markers": "python_version >= '3.6'", + "version": "==1.0.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053", + "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610" + ], + "markers": "python_version >= '3.6'", + "version": "==2.10.0" + }, + "pyflakes": { + "hashes": [ + "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf", + "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd" + ], + "markers": "python_version >= '3.6'", + "version": "==3.0.1" + }, + "pygments": { + "hashes": [ + "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c", + "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1" + ], + "markers": "python_version >= '3.7'", + "version": "==2.15.1" + }, + "pylint": { + "hashes": [ + "sha256:eb035800b371862e783f27067b3fc00c6b726880cacfed101b619f366bb813f6", + "sha256:f0b0857f6fba90527a30f39937a9f66858b59c5dcbb8c062821ad665637bb742" + ], + "index": "pypi", + "version": "==3.0.0a6" + }, + "pytest": { + "hashes": [ + "sha256:cdcbd012c9312258922f8cd3f1b62a6580fdced17db6014896053d47cddf9295", + "sha256:ee990a3cc55ba808b80795a79944756f315c67c12b56abd3ac993a7b8c17030b" + ], + "index": "pypi", + "version": "==7.3.2" + }, + "readme-renderer": { + "hashes": [ + "sha256:cd653186dfc73055656f090f227f5cb22a046d7f71a841dfa305f55c9a513273", + "sha256:f67a16caedfa71eef48a31b39708637a6f4664c4394801a7b0d6432d13907343" + ], + "markers": "python_version >= '3.7'", + "version": "==37.3" + }, + "regex": { + "hashes": [ + "sha256:0385e73da22363778ef2324950e08b689abdf0b108a7d8decb403ad7f5191938", + "sha256:051da80e6eeb6e239e394ae60704d2b566aa6a7aed6f2890a7967307267a5dc6", + "sha256:05ed27acdf4465c95826962528f9e8d41dbf9b1aa8531a387dee6ed215a3e9ef", + "sha256:0654bca0cdf28a5956c83839162692725159f4cda8d63e0911a2c0dc76166525", + "sha256:09e4a1a6acc39294a36b7338819b10baceb227f7f7dbbea0506d419b5a1dd8af", + "sha256:0b49c764f88a79160fa64f9a7b425620e87c9f46095ef9c9920542ab2495c8bc", + "sha256:0b71e63226e393b534105fcbdd8740410dc6b0854c2bfa39bbda6b0d40e59a54", + "sha256:0c29ca1bd61b16b67be247be87390ef1d1ef702800f91fbd1991f5c4421ebae8", + "sha256:10590510780b7541969287512d1b43f19f965c2ece6c9b1c00fc367b29d8dce7", + "sha256:10cb847aeb1728412c666ab2e2000ba6f174f25b2bdc7292e7dd71b16db07568", + "sha256:12b74fbbf6cbbf9dbce20eb9b5879469e97aeeaa874145517563cca4029db65c", + "sha256:20326216cc2afe69b6e98528160b225d72f85ab080cbdf0b11528cbbaba2248f", + "sha256:2239d95d8e243658b8dbb36b12bd10c33ad6e6933a54d36ff053713f129aa536", + "sha256:25be746a8ec7bc7b082783216de8e9473803706723b3f6bef34b3d0ed03d57e2", + "sha256:271f0bdba3c70b58e6f500b205d10a36fb4b58bd06ac61381b68de66442efddb", + "sha256:29cdd471ebf9e0f2fb3cac165efedc3c58db841d83a518b082077e612d3ee5df", + "sha256:2d44dc13229905ae96dd2ae2dd7cebf824ee92bc52e8cf03dcead37d926da019", + "sha256:3676f1dd082be28b1266c93f618ee07741b704ab7b68501a173ce7d8d0d0ca18", + "sha256:36efeba71c6539d23c4643be88295ce8c82c88bbd7c65e8a24081d2ca123da3f", + "sha256:3e5219bf9e75993d73ab3d25985c857c77e614525fac9ae02b1bebd92f7cecac", + "sha256:43e1dd9d12df9004246bacb79a0e5886b3b6071b32e41f83b0acbf293f820ee8", + "sha256:457b6cce21bee41ac292d6753d5e94dcbc5c9e3e3a834da285b0bde7aa4a11e9", + "sha256:463b6a3ceb5ca952e66550a4532cef94c9a0c80dc156c4cc343041951aec1697", + "sha256:4959e8bcbfda5146477d21c3a8ad81b185cd252f3d0d6e4724a5ef11c012fb06", + "sha256:4d3850beab9f527f06ccc94b446c864059c57651b3f911fddb8d9d3ec1d1b25d", + "sha256:5708089ed5b40a7b2dc561e0c8baa9535b77771b64a8330b684823cfd5116036", + "sha256:5c6b48d0fa50d8f4df3daf451be7f9689c2bde1a52b1225c5926e3f54b6a9ed1", + "sha256:61474f0b41fe1a80e8dfa70f70ea1e047387b7cd01c85ec88fa44f5d7561d787", + "sha256:6343c6928282c1f6a9db41f5fd551662310e8774c0e5ebccb767002fcf663ca9", + "sha256:65ba8603753cec91c71de423a943ba506363b0e5c3fdb913ef8f9caa14b2c7e0", + "sha256:687ea9d78a4b1cf82f8479cab23678aff723108df3edeac098e5b2498879f4a7", + "sha256:6b2675068c8b56f6bfd5a2bda55b8accbb96c02fd563704732fd1c95e2083461", + "sha256:7117d10690c38a622e54c432dfbbd3cbd92f09401d622902c32f6d377e2300ee", + "sha256:7178bbc1b2ec40eaca599d13c092079bf529679bf0371c602edaa555e10b41c3", + "sha256:72d1a25bf36d2050ceb35b517afe13864865268dfb45910e2e17a84be6cbfeb0", + "sha256:742e19a90d9bb2f4a6cf2862b8b06dea5e09b96c9f2df1779e53432d7275331f", + "sha256:74390d18c75054947e4194019077e243c06fbb62e541d8817a0fa822ea310c14", + "sha256:74419d2b50ecb98360cfaa2974da8689cb3b45b9deff0dcf489c0d333bcc1477", + "sha256:824bf3ac11001849aec3fa1d69abcb67aac3e150a933963fb12bda5151fe1bfd", + "sha256:83320a09188e0e6c39088355d423aa9d056ad57a0b6c6381b300ec1a04ec3d16", + "sha256:837328d14cde912af625d5f303ec29f7e28cdab588674897baafaf505341f2fc", + "sha256:841d6e0e5663d4c7b4c8099c9997be748677d46cbf43f9f471150e560791f7ff", + "sha256:87b2a5bb5e78ee0ad1de71c664d6eb536dc3947a46a69182a90f4410f5e3f7dd", + "sha256:890e5a11c97cf0d0c550eb661b937a1e45431ffa79803b942a057c4fb12a2da2", + "sha256:8abbc5d54ea0ee80e37fef009e3cec5dafd722ed3c829126253d3e22f3846f1e", + "sha256:8e3f1316c2293e5469f8f09dc2d76efb6c3982d3da91ba95061a7e69489a14ef", + "sha256:8f56fcb7ff7bf7404becdfc60b1e81a6d0561807051fd2f1860b0d0348156a07", + "sha256:9427a399501818a7564f8c90eced1e9e20709ece36be701f394ada99890ea4b3", + "sha256:976d7a304b59ede34ca2921305b57356694f9e6879db323fd90a80f865d355a3", + "sha256:9a5bfb3004f2144a084a16ce19ca56b8ac46e6fd0651f54269fc9e230edb5e4a", + "sha256:9beb322958aaca059f34975b0df135181f2e5d7a13b84d3e0e45434749cb20f7", + "sha256:9edcbad1f8a407e450fbac88d89e04e0b99a08473f666a3f3de0fd292badb6aa", + "sha256:9edce5281f965cf135e19840f4d93d55b3835122aa76ccacfd389e880ba4cf82", + "sha256:a4c3b7fa4cdaa69268748665a1a6ff70c014d39bb69c50fda64b396c9116cf77", + "sha256:a8105e9af3b029f243ab11ad47c19b566482c150c754e4c717900a798806b222", + "sha256:a99b50300df5add73d307cf66abea093304a07eb017bce94f01e795090dea87c", + "sha256:aad51907d74fc183033ad796dd4c2e080d1adcc4fd3c0fd4fd499f30c03011cd", + "sha256:af4dd387354dc83a3bff67127a124c21116feb0d2ef536805c454721c5d7993d", + "sha256:b28f5024a3a041009eb4c333863d7894d191215b39576535c6734cd88b0fcb68", + "sha256:b4598b1897837067a57b08147a68ac026c1e73b31ef6e36deeeb1fa60b2933c9", + "sha256:b6192d5af2ccd2a38877bfef086d35e6659566a335b1492786ff254c168b1693", + "sha256:b862c2b9d5ae38a68b92e215b93f98d4c5e9454fa36aae4450f61dd33ff48487", + "sha256:b956231ebdc45f5b7a2e1f90f66a12be9610ce775fe1b1d50414aac1e9206c06", + "sha256:bb60b503ec8a6e4e3e03a681072fa3a5adcbfa5479fa2d898ae2b4a8e24c4591", + "sha256:bbb02fd4462f37060122e5acacec78e49c0fbb303c30dd49c7f493cf21fc5b27", + "sha256:bdff5eab10e59cf26bc479f565e25ed71a7d041d1ded04ccf9aee1d9f208487a", + "sha256:c123f662be8ec5ab4ea72ea300359023a5d1df095b7ead76fedcd8babbedf969", + "sha256:c2b867c17a7a7ae44c43ebbeb1b5ff406b3e8d5b3e14662683e5e66e6cc868d3", + "sha256:c5f8037000eb21e4823aa485149f2299eb589f8d1fe4b448036d230c3f4e68e0", + "sha256:c6a57b742133830eec44d9b2290daf5cbe0a2f1d6acee1b3c7b1c7b2f3606df7", + "sha256:ccf91346b7bd20c790310c4147eee6ed495a54ddb6737162a36ce9dbef3e4751", + "sha256:cf67ca618b4fd34aee78740bea954d7c69fdda419eb208c2c0c7060bb822d747", + "sha256:d2da3abc88711bce7557412310dfa50327d5769a31d1c894b58eb256459dc289", + "sha256:d4f03bb71d482f979bda92e1427f3ec9b220e62a7dd337af0aa6b47bf4498f72", + "sha256:d54af539295392611e7efbe94e827311eb8b29668e2b3f4cadcfe6f46df9c777", + "sha256:d77f09bc4b55d4bf7cc5eba785d87001d6757b7c9eec237fe2af57aba1a071d9", + "sha256:d831c2f8ff278179705ca59f7e8524069c1a989e716a1874d6d1aab6119d91d1", + "sha256:dbbbfce33cd98f97f6bffb17801b0576e653f4fdb1d399b2ea89638bc8d08ae1", + "sha256:dcba6dae7de533c876255317c11f3abe4907ba7d9aa15d13e3d9710d4315ec0e", + "sha256:e0bb18053dfcfed432cc3ac632b5e5e5c5b7e55fb3f8090e867bfd9b054dbcbf", + "sha256:e2fbd6236aae3b7f9d514312cdb58e6494ee1c76a9948adde6eba33eb1c4264f", + "sha256:e5087a3c59eef624a4591ef9eaa6e9a8d8a94c779dade95d27c0bc24650261cd", + "sha256:e8915cc96abeb8983cea1df3c939e3c6e1ac778340c17732eb63bb96247b91d2", + "sha256:ea353ecb6ab5f7e7d2f4372b1e779796ebd7b37352d290096978fea83c4dba0c", + "sha256:ee2d1a9a253b1729bb2de27d41f696ae893507c7db224436abe83ee25356f5c1", + "sha256:f415f802fbcafed5dcc694c13b1292f07fe0befdb94aa8a52905bd115ff41e88", + "sha256:fb5ec16523dc573a4b277663a2b5a364e2099902d3944c9419a40ebd56a118f9", + "sha256:fea75c3710d4f31389eed3c02f62d0b66a9da282521075061ce875eb5300cf23" + ], + "markers": "python_version >= '3.6'", + "version": "==2023.6.3" + }, + "requests": { + "hashes": [ + "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", + "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" + ], + "markers": "python_version >= '3.7'", + "version": "==2.31.0" + }, + "requests-toolbelt": { + "hashes": [ + "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", + "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.0.0" + }, + "rfc3986": { + "hashes": [ + "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", + "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "rich": { + "hashes": [ + "sha256:8f87bc7ee54675732fa66a05ebfe489e27264caeeff3728c945d25971b6485ec", + "sha256:d653d6bccede5844304c605d5aac802c7cf9621efd700b46c7ec2b51ea914898" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==13.4.2" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + }, + "toml": { + "hashes": [ + "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", + "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" + ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.10.2" + }, + "tomlkit": { + "hashes": [ + "sha256:8c726c4c202bdb148667835f68d68780b9a003a9ec34167b6c673b38eff2a171", + "sha256:9330fc7faa1db67b541b28e62018c17d20be733177d290a13b24c62d1614e0c3" + ], + "markers": "python_version >= '3.7'", + "version": "==0.11.8" + }, + "twine": { + "hashes": [ + "sha256:929bc3c280033347a00f847236564d1c52a3e61b1ac2516c97c48f3ceab756d8", + "sha256:9e102ef5fdd5a20661eb88fad46338806c3bd32cf1db729603fe3697b1bc83c8" + ], + "index": "pypi", + "version": "==4.0.2" + }, + "typed-ast": { + "hashes": [ + "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2", + "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1", + "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6", + "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62", + "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac", + "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d", + "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc", + "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2", + "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97", + "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35", + "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6", + "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1", + "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4", + "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c", + "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e", + "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec", + "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f", + "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72", + "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47", + "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72", + "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe", + "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6", + "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3", + "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66" + ], + "markers": "python_version >= '3.6'", + "version": "==1.5.4" + }, + "typing-extensions": { + "hashes": [ + "sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26", + "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5" + ], + "markers": "python_version >= '3.7'", + "version": "==4.6.3" + }, + "urllib3": { + "hashes": [ + "sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1", + "sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.3" + }, + "webencodings": { + "hashes": [ + "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", + "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" + ], + "version": "==0.5.1" + }, + "wrapt": { + "hashes": [ + "sha256:02fce1852f755f44f95af51f69d22e45080102e9d00258053b79367d07af39c0", + "sha256:077ff0d1f9d9e4ce6476c1a924a3332452c1406e59d90a2cf24aeb29eeac9420", + "sha256:078e2a1a86544e644a68422f881c48b84fef6d18f8c7a957ffd3f2e0a74a0d4a", + "sha256:0970ddb69bba00670e58955f8019bec4a42d1785db3faa043c33d81de2bf843c", + "sha256:1286eb30261894e4c70d124d44b7fd07825340869945c79d05bda53a40caa079", + "sha256:21f6d9a0d5b3a207cdf7acf8e58d7d13d463e639f0c7e01d82cdb671e6cb7923", + "sha256:230ae493696a371f1dbffaad3dafbb742a4d27a0afd2b1aecebe52b740167e7f", + "sha256:26458da5653aa5b3d8dc8b24192f574a58984c749401f98fff994d41d3f08da1", + "sha256:2cf56d0e237280baed46f0b5316661da892565ff58309d4d2ed7dba763d984b8", + "sha256:2e51de54d4fb8fb50d6ee8327f9828306a959ae394d3e01a1ba8b2f937747d86", + "sha256:2fbfbca668dd15b744418265a9607baa970c347eefd0db6a518aaf0cfbd153c0", + "sha256:38adf7198f8f154502883242f9fe7333ab05a5b02de7d83aa2d88ea621f13364", + "sha256:3a8564f283394634a7a7054b7983e47dbf39c07712d7b177b37e03f2467a024e", + "sha256:3abbe948c3cbde2689370a262a8d04e32ec2dd4f27103669a45c6929bcdbfe7c", + "sha256:3bbe623731d03b186b3d6b0d6f51865bf598587c38d6f7b0be2e27414f7f214e", + "sha256:40737a081d7497efea35ab9304b829b857f21558acfc7b3272f908d33b0d9d4c", + "sha256:41d07d029dd4157ae27beab04d22b8e261eddfc6ecd64ff7000b10dc8b3a5727", + "sha256:46ed616d5fb42f98630ed70c3529541408166c22cdfd4540b88d5f21006b0eff", + "sha256:493d389a2b63c88ad56cdc35d0fa5752daac56ca755805b1b0c530f785767d5e", + "sha256:4ff0d20f2e670800d3ed2b220d40984162089a6e2c9646fdb09b85e6f9a8fc29", + "sha256:54accd4b8bc202966bafafd16e69da9d5640ff92389d33d28555c5fd4f25ccb7", + "sha256:56374914b132c702aa9aa9959c550004b8847148f95e1b824772d453ac204a72", + "sha256:578383d740457fa790fdf85e6d346fda1416a40549fe8db08e5e9bd281c6a475", + "sha256:58d7a75d731e8c63614222bcb21dd992b4ab01a399f1f09dd82af17bbfc2368a", + "sha256:5c5aa28df055697d7c37d2099a7bc09f559d5053c3349b1ad0c39000e611d317", + "sha256:5fc8e02f5984a55d2c653f5fea93531e9836abbd84342c1d1e17abc4a15084c2", + "sha256:63424c681923b9f3bfbc5e3205aafe790904053d42ddcc08542181a30a7a51bd", + "sha256:64b1df0f83706b4ef4cfb4fb0e4c2669100fd7ecacfb59e091fad300d4e04640", + "sha256:74934ebd71950e3db69960a7da29204f89624dde411afbfb3b4858c1409b1e98", + "sha256:75669d77bb2c071333417617a235324a1618dba66f82a750362eccbe5b61d248", + "sha256:75760a47c06b5974aa5e01949bf7e66d2af4d08cb8c1d6516af5e39595397f5e", + "sha256:76407ab327158c510f44ded207e2f76b657303e17cb7a572ffe2f5a8a48aa04d", + "sha256:76e9c727a874b4856d11a32fb0b389afc61ce8aaf281ada613713ddeadd1cfec", + "sha256:77d4c1b881076c3ba173484dfa53d3582c1c8ff1f914c6461ab70c8428b796c1", + "sha256:780c82a41dc493b62fc5884fb1d3a3b81106642c5c5c78d6a0d4cbe96d62ba7e", + "sha256:7dc0713bf81287a00516ef43137273b23ee414fe41a3c14be10dd95ed98a2df9", + "sha256:7eebcdbe3677e58dd4c0e03b4f2cfa346ed4049687d839adad68cc38bb559c92", + "sha256:896689fddba4f23ef7c718279e42f8834041a21342d95e56922e1c10c0cc7afb", + "sha256:96177eb5645b1c6985f5c11d03fc2dbda9ad24ec0f3a46dcce91445747e15094", + "sha256:96e25c8603a155559231c19c0349245eeb4ac0096fe3c1d0be5c47e075bd4f46", + "sha256:9d37ac69edc5614b90516807de32d08cb8e7b12260a285ee330955604ed9dd29", + "sha256:9ed6aa0726b9b60911f4aed8ec5b8dd7bf3491476015819f56473ffaef8959bd", + "sha256:a487f72a25904e2b4bbc0817ce7a8de94363bd7e79890510174da9d901c38705", + "sha256:a4cbb9ff5795cd66f0066bdf5947f170f5d63a9274f99bdbca02fd973adcf2a8", + "sha256:a74d56552ddbde46c246b5b89199cb3fd182f9c346c784e1a93e4dc3f5ec9975", + "sha256:a89ce3fd220ff144bd9d54da333ec0de0399b52c9ac3d2ce34b569cf1a5748fb", + "sha256:abd52a09d03adf9c763d706df707c343293d5d106aea53483e0ec8d9e310ad5e", + "sha256:abd8f36c99512755b8456047b7be10372fca271bf1467a1caa88db991e7c421b", + "sha256:af5bd9ccb188f6a5fdda9f1f09d9f4c86cc8a539bd48a0bfdc97723970348418", + "sha256:b02f21c1e2074943312d03d243ac4388319f2456576b2c6023041c4d57cd7019", + "sha256:b06fa97478a5f478fb05e1980980a7cdf2712015493b44d0c87606c1513ed5b1", + "sha256:b0724f05c396b0a4c36a3226c31648385deb6a65d8992644c12a4963c70326ba", + "sha256:b130fe77361d6771ecf5a219d8e0817d61b236b7d8b37cc045172e574ed219e6", + "sha256:b56d5519e470d3f2fe4aa7585f0632b060d532d0696c5bdfb5e8319e1d0f69a2", + "sha256:b67b819628e3b748fd3c2192c15fb951f549d0f47c0449af0764d7647302fda3", + "sha256:ba1711cda2d30634a7e452fc79eabcadaffedf241ff206db2ee93dd2c89a60e7", + "sha256:bbeccb1aa40ab88cd29e6c7d8585582c99548f55f9b2581dfc5ba68c59a85752", + "sha256:bd84395aab8e4d36263cd1b9308cd504f6cf713b7d6d3ce25ea55670baec5416", + "sha256:c99f4309f5145b93eca6e35ac1a988f0dc0a7ccf9ccdcd78d3c0adf57224e62f", + "sha256:ca1cccf838cd28d5a0883b342474c630ac48cac5df0ee6eacc9c7290f76b11c1", + "sha256:cd525e0e52a5ff16653a3fc9e3dd827981917d34996600bbc34c05d048ca35cc", + "sha256:cdb4f085756c96a3af04e6eca7f08b1345e94b53af8921b25c72f096e704e145", + "sha256:ce42618f67741d4697684e501ef02f29e758a123aa2d669e2d964ff734ee00ee", + "sha256:d06730c6aed78cee4126234cf2d071e01b44b915e725a6cb439a879ec9754a3a", + "sha256:d5fe3e099cf07d0fb5a1e23d399e5d4d1ca3e6dfcbe5c8570ccff3e9208274f7", + "sha256:d6bcbfc99f55655c3d93feb7ef3800bd5bbe963a755687cbf1f490a71fb7794b", + "sha256:d787272ed958a05b2c86311d3a4135d3c2aeea4fc655705f074130aa57d71653", + "sha256:e169e957c33576f47e21864cf3fc9ff47c223a4ebca8960079b8bd36cb014fd0", + "sha256:e20076a211cd6f9b44a6be58f7eeafa7ab5720eb796975d0c03f05b47d89eb90", + "sha256:e826aadda3cae59295b95343db8f3d965fb31059da7de01ee8d1c40a60398b29", + "sha256:eef4d64c650f33347c1f9266fa5ae001440b232ad9b98f1f43dfe7a79435c0a6", + "sha256:f2e69b3ed24544b0d3dbe2c5c0ba5153ce50dcebb576fdc4696d52aa22db6034", + "sha256:f87ec75864c37c4c6cb908d282e1969e79763e0d9becdfe9fe5473b7bb1e5f09", + "sha256:fbec11614dba0424ca72f4e8ba3c420dba07b4a7c206c8c8e4e73f2e98f4c559", + "sha256:fd69666217b62fa5d7c6aa88e507493a34dec4fa20c5bd925e4bc12fce586639" + ], + "markers": "python_version >= '3.11'", + "version": "==1.15.0" + }, + "zipp": { + "hashes": [ + "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b", + "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556" + ], + "markers": "python_version >= '3.7'", + "version": "==3.15.0" + } + }, + "develop": { + "bleach": { + "hashes": [ + "sha256:1a1a85c1595e07d8db14c5f09f09e6433502c51c595970edc090551f0db99414", + "sha256:33c16e3353dbd13028ab4799a0f89a83f113405c766e9c122df8a06f5b85b3f4" + ], + "markers": "python_version >= '3.7'", + "version": "==6.0.0" + }, + "certifi": { + "hashes": [ + "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7", + "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716" + ], + "markers": "python_version >= '3.6'", + "version": "==2023.5.7" + }, + "charset-normalizer": { + "hashes": [ + "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6", + "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1", + "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e", + "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373", + "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62", + "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230", + "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be", + "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c", + "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0", + "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448", + "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f", + "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649", + "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d", + "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0", + "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706", + "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a", + "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59", + "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23", + "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5", + "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb", + "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e", + "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e", + "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c", + "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28", + "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d", + "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41", + "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974", + "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce", + "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f", + "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1", + "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d", + "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8", + "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017", + "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31", + "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7", + "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8", + "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e", + "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14", + "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd", + "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d", + "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795", + "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b", + "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b", + "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b", + "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203", + "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f", + "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19", + "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1", + "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a", + "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac", + "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9", + "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0", + "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137", + "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f", + "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6", + "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5", + "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909", + "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f", + "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0", + "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324", + "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755", + "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb", + "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854", + "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c", + "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60", + "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84", + "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0", + "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b", + "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1", + "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531", + "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1", + "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11", + "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326", + "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df", + "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==3.1.0" + }, + "coverage": { + "hashes": [ + "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f", + "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2", + "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a", + "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a", + "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01", + "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6", + "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7", + "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f", + "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02", + "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c", + "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063", + "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a", + "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5", + "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959", + "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97", + "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6", + "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f", + "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9", + "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5", + "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f", + "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562", + "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe", + "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9", + "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f", + "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb", + "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb", + "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1", + "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb", + "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250", + "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e", + "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511", + "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5", + "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59", + "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2", + "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d", + "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3", + "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4", + "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de", + "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9", + "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833", + "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0", + "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9", + "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d", + "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050", + "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d", + "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6", + "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353", + "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb", + "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e", + "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8", + "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495", + "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2", + "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd", + "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27", + "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1", + "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818", + "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4", + "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e", + "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850", + "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3" + ], + "index": "pypi", + "version": "==7.2.7" + }, + "coveralls": { + "hashes": [ + "sha256:b32a8bb5d2df585207c119d6c01567b81fba690c9c10a753bfe27a335bfc43ea", + "sha256:f42015f31d386b351d4226389b387ae173207058832fbf5c8ec4b40e27b16026" + ], + "index": "pypi", + "version": "==3.3.1" + }, + "docopt": { + "hashes": [ + "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" + ], + "version": "==0.6.2" + }, + "docutils": { + "hashes": [ + "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", + "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b" + ], + "markers": "python_version >= '3.7'", + "version": "==0.20.1" + }, + "flake8": { + "hashes": [ + "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7", + "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181" + ], + "index": "pypi", + "version": "==6.0.0" + }, + "idna": { + "hashes": [ + "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", + "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" + ], + "markers": "python_version >= '3.5'", + "version": "==3.4" + }, + "importlib-metadata": { + "hashes": [ + "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed", + "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705" + ], + "markers": "python_version >= '3.7'", + "version": "==6.6.0" + }, + "iniconfig": { + "hashes": [ + "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", + "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "jaraco.classes": { + "hashes": [ + "sha256:2353de3288bc6b82120752201c6b1c1a14b058267fa424ed5ce5984e3b922158", + "sha256:89559fa5c1d3c34eff6f631ad80bb21f378dbcbb35dd161fd2c6b93f5be2f98a" + ], + "markers": "python_version >= '3.7'", + "version": "==3.2.3" + }, + "keyring": { + "hashes": [ + "sha256:771ed2a91909389ed6148631de678f82ddc73737d85a927f382a8a1b157898cd", + "sha256:ba2e15a9b35e21908d0aaf4e0a47acc52d6ae33444df0da2b49d41a46ef6d678" + ], + "markers": "python_version >= '3.7'", + "version": "==23.13.1" + }, + "markdown-it-py": { + "hashes": [ + "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", + "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" + ], + "markers": "python_version >= '3.8'", + "version": "==3.0.0" + }, + "mccabe": { + "hashes": [ + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, + "mdurl": { + "hashes": [ + "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.1.2" + }, + "more-itertools": { + "hashes": [ + "sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d", + "sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3" + ], + "markers": "python_version >= '3.7'", + "version": "==9.1.0" + }, + "packaging": { + "hashes": [ + "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", + "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" + ], + "markers": "python_version >= '3.7'", + "version": "==23.1" + }, + "pkginfo": { + "hashes": [ + "sha256:4b7a555a6d5a22169fcc9cf7bfd78d296b0361adad412a346c1226849af5e546", + "sha256:8fd5896e8718a4372f0ea9cc9d96f6417c9b986e23a4d116dda26b62cc29d046" + ], + "markers": "python_version >= '3.6'", + "version": "==1.9.6" + }, + "pluggy": { + "hashes": [ + "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", + "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" + ], + "markers": "python_version >= '3.6'", + "version": "==1.0.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053", + "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610" + ], + "markers": "python_version >= '3.6'", + "version": "==2.10.0" + }, + "pyflakes": { + "hashes": [ + "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf", + "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd" + ], + "markers": "python_version >= '3.6'", + "version": "==3.0.1" + }, + "pygments": { + "hashes": [ + "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c", + "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1" + ], + "markers": "python_version >= '3.7'", + "version": "==2.15.1" + }, + "pytest": { + "hashes": [ + "sha256:cdcbd012c9312258922f8cd3f1b62a6580fdced17db6014896053d47cddf9295", + "sha256:ee990a3cc55ba808b80795a79944756f315c67c12b56abd3ac993a7b8c17030b" + ], + "index": "pypi", + "version": "==7.3.2" + }, + "readme-renderer": { + "hashes": [ + "sha256:cd653186dfc73055656f090f227f5cb22a046d7f71a841dfa305f55c9a513273", + "sha256:f67a16caedfa71eef48a31b39708637a6f4664c4394801a7b0d6432d13907343" + ], + "markers": "python_version >= '3.7'", + "version": "==37.3" + }, + "requests": { + "hashes": [ + "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", + "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" + ], + "markers": "python_version >= '3.7'", + "version": "==2.31.0" + }, + "requests-toolbelt": { + "hashes": [ + "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", + "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.0.0" + }, + "rfc3986": { + "hashes": [ + "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", + "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "rich": { + "hashes": [ + "sha256:8f87bc7ee54675732fa66a05ebfe489e27264caeeff3728c945d25971b6485ec", + "sha256:d653d6bccede5844304c605d5aac802c7cf9621efd700b46c7ec2b51ea914898" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==13.4.2" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + }, + "twine": { + "hashes": [ + "sha256:929bc3c280033347a00f847236564d1c52a3e61b1ac2516c97c48f3ceab756d8", + "sha256:9e102ef5fdd5a20661eb88fad46338806c3bd32cf1db729603fe3697b1bc83c8" + ], + "index": "pypi", + "version": "==4.0.2" + }, + "urllib3": { + "hashes": [ + "sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1", + "sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.3" + }, + "webencodings": { + "hashes": [ + "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", + "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" + ], + "version": "==0.5.1" + }, + "zipp": { + "hashes": [ + "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b", + "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556" + ], + "markers": "python_version >= '3.7'", + "version": "==3.15.0" + } + } +} diff --git a/README.md b/README.md index 65e19460..6592034f 100644 --- a/README.md +++ b/README.md @@ -53,4 +53,4 @@ The Debian packages offered with releases of gitinspector are unofficial and ver An [npm](https://npmjs.com) package is provided for convenience as well. To install it globally, execute `npm i -g gitinspector`. ### License -gitinspector is licensed under the *GNU GPL v3*. The gitinspector logo is partly based on the git logo; based on the work of Jason Long. The logo is licensed under the *Creative Commons Attribution 3.0 Unported License*. +gitinspector is licensed under the *GNU GPL v3*. The gitinspector logo is partly based on the git logo; based on the work of Jason Long. The logo is licensed under the *Creative Commons Attribution 3.0 Unported License*. \ No newline at end of file diff --git a/gitinspector/basedir.py b/gitinspector/basedir.py index ae3619b2..a4c46c55 100644 --- a/gitinspector/basedir.py +++ b/gitinspector/basedir.py @@ -1,6 +1,6 @@ # coding: utf-8 # -# Copyright Š 2012-2015 Ejwa Software. All rights reserved. +# Copyright 2012-2015 Ejwa Software. All rights reserved. # # This file is part of gitinspector. # @@ -17,47 +17,42 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -import os -import subprocess -import sys - -def get_basedir(): - if hasattr(sys, "frozen"): # exists when running via py2exe - return sys.prefix - else: - return os.path.dirname(os.path.realpath(__file__)) - -def get_basedir_git(path=None): - previous_directory = None - - if path != None: - previous_directory = os.getcwd() - os.chdir(path) - - bare_command = subprocess.Popen(["git", "rev-parse", "--is-bare-repository"], bufsize=1, - stdout=subprocess.PIPE, stderr=open(os.devnull, "w")) - - isbare = bare_command.stdout.readlines() - bare_command.wait() +from __future__ import annotations - if bare_command.returncode != 0: - sys.exit(_("Error processing git repository at \"%s\"." % os.getcwd())) - - isbare = (isbare[0].decode("utf-8", "replace").strip() == "true") - absolute_path = None - - if isbare: - absolute_path = subprocess.Popen(["git", "rev-parse", "--git-dir"], bufsize=1, stdout=subprocess.PIPE).stdout - else: - absolute_path = subprocess.Popen(["git", "rev-parse", "--show-toplevel"], bufsize=1, - stdout=subprocess.PIPE).stdout - - absolute_path = absolute_path.readlines() - - if len(absolute_path) == 0: - sys.exit(_("Unable to determine absolute path of git repository.")) +import sys +from pathlib import Path +from typing import Optional, Union +from .git_utils import GitCommandError, get_git_repository_root, is_bare_repository, get_git_dir - if path != None: - os.chdir(previous_directory) - return absolute_path[0].decode("utf-8", "replace").strip() +def get_basedir() -> str: + """Get the base directory of the gitinspector package.""" + if hasattr(sys, "frozen"): # exists when running via py2exe + return sys.prefix + return str(Path(__file__).parent.resolve()) + + +def get_basedir_git(path: Optional[Union[str, Path]] = None) -> str: + """ + Get the base directory of a git repository. + + Args: + path: Optional path to check (defaults to current directory) + + Returns: + str: Absolute path to the git repository base directory + + Raises: + SystemExit: If not in a git repository or git command fails + """ + try: + if is_bare_repository(path): + # For bare repositories, return the git directory path + git_dir = get_git_dir(path) + return str(git_dir.resolve()) + # For regular repositories, return the working tree root + repo_root = get_git_repository_root(path) + return str(repo_root.resolve()) + except GitCommandError as e: + current_path = Path(path).resolve() if path else Path.cwd() + sys.exit(f'Error processing git repository at "{current_path}": {e}') diff --git a/gitinspector/blame.py b/gitinspector/blame.py index 317d3f9d..8a6f8a82 100644 --- a/gitinspector/blame.py +++ b/gitinspector/blame.py @@ -17,8 +17,7 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import print_function -from __future__ import unicode_literals + import datetime import multiprocessing import re @@ -30,19 +29,22 @@ NUM_THREADS = multiprocessing.cpu_count() -class BlameEntry(object): + +class BlameEntry(): rows = 0 - skew = 0 # Used when calculating average code age. + skew = 0 # Used when calculating average code age. comments = 0 + __thread_lock__ = threading.BoundedSemaphore(NUM_THREADS) __blame_lock__ = threading.Lock() AVG_DAYS_PER_MONTH = 30.4167 + class BlameThread(threading.Thread): def __init__(self, useweeks, changes, blame_command, extension, blames, filename): - __thread_lock__.acquire() # Lock controlling the number of threads running + __thread_lock__.acquire() # Lock controlling the number of threads running threading.Thread.__init__(self) self.useweeks = useweeks @@ -72,32 +74,35 @@ def __handle_blamechunk_content__(self, content): except KeyError: return - if not filtering.set_filtered(author, "author") and not \ - filtering.set_filtered(self.blamechunk_email, "email") and not \ - filtering.set_filtered(self.blamechunk_revision, "revision"): + if ( + not filtering.set_filtered(author, "author") + and not filtering.set_filtered(self.blamechunk_email, "email") + and not filtering.set_filtered(self.blamechunk_revision, "revision") + ): - __blame_lock__.acquire() # Global lock used to protect calls from here... + __blame_lock__.acquire() # Global lock used to protect calls from here... - if self.blames.get((author, self.filename), None) == None: + if self.blames.get((author, self.filename), None) is None: self.blames[(author, self.filename)] = BlameEntry() self.blames[(author, self.filename)].comments += comments self.blames[(author, self.filename)].rows += 1 if (self.blamechunk_time - self.changes.first_commit_date).days > 0: - self.blames[(author, self.filename)].skew += ((self.changes.last_commit_date - self.blamechunk_time).days / - (7.0 if self.useweeks else AVG_DAYS_PER_MONTH)) + self.blames[(author, self.filename)].skew += (self.changes.last_commit_date - self.blamechunk_time).days / ( + 7.0 if self.useweeks else AVG_DAYS_PER_MONTH + ) - __blame_lock__.release() # ...to here. + __blame_lock__.release() # ...to here. def run(self): - git_blame_r = subprocess.Popen(self.blame_command, bufsize=1, stdout=subprocess.PIPE).stdout + git_blame_r = subprocess.Popen(self.blame_command, stdout=subprocess.PIPE).stdout rows = git_blame_r.readlines() git_blame_r.close() self.__clear_blamechunk_info__() - #pylint: disable=W0201 + # pylint: disable=W0201 for j in range(0, len(rows)): row = rows[j].decode("utf-8", "replace").strip() keyval = row.split(" ", 2) @@ -116,36 +121,45 @@ def run(self): elif Blame.is_revision(keyval[0]): self.blamechunk_revision = keyval[0] - __thread_lock__.release() # Lock controlling the number of threads running + __thread_lock__.release() # Lock controlling the number of threads running + PROGRESS_TEXT = N_("Checking how many rows belong to each author (2 of 2): {0:.0f}%") -class Blame(object): + +class Blame(): def __init__(self, repo, hard, useweeks, changes): self.blames = {} - ls_tree_p = subprocess.Popen(["git", "ls-tree", "--name-only", "-r", interval.get_ref()], bufsize=1, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + ls_tree_p = subprocess.Popen( + ["git", "ls-tree", "--name-only", "-r", interval.get_ref()], stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) lines = ls_tree_p.communicate()[0].splitlines() ls_tree_p.stdout.close() if ls_tree_p.returncode == 0: progress_text = _(PROGRESS_TEXT) - if repo != None: - progress_text = "[%s] " % repo.name + progress_text + if repo is not None: + progress_text = f"[{repo.name}] " + progress_text for i, row in enumerate(lines): row = row.strip().decode("unicode_escape", "ignore") row = row.encode("latin-1", "replace") - row = row.decode("utf-8", "replace").strip("\"").strip("'").strip() - - if FileDiff.get_extension(row) in extensions.get_located() and \ - FileDiff.is_valid_extension(row) and not filtering.set_filtered(FileDiff.get_filename(row)): - blame_command = filter(None, ["git", "blame", "--line-porcelain", "-w"] + \ - (["-C", "-C", "-M"] if hard else []) + - [interval.get_since(), interval.get_ref(), "--", row]) - thread = BlameThread(useweeks, changes, blame_command, FileDiff.get_extension(row), - self.blames, row.strip()) + row = row.decode("utf-8", "replace").strip('"').strip("'").strip() + + if ( + FileDiff.get_extension(row) in extensions.get_located() + and FileDiff.is_valid_extension(row) + and not filtering.set_filtered(FileDiff.get_filename(row)) + ): + blame_command = [ + _f + for _f in ["git", "blame", "--line-porcelain", "-w"] + + (["-C", "-C", "-M"] if hard else []) + + [interval.get_since(), interval.get_ref(), "--", row] + if _f + ] + thread = BlameThread(useweeks, changes, blame_command, FileDiff.get_extension(row), self.blames, row.strip()) thread.daemon = True thread.start() @@ -163,15 +177,15 @@ def __init__(self, repo, hard, useweeks, changes): def __iadd__(self, other): try: self.blames.update(other.blames) - return self; + return self except AttributeError: - return other; + return other @staticmethod def is_revision(string): revision = re.search("([0-9a-f]{40})", string) - if revision == None: + if revision is None: return False return revision.group(1).strip() @@ -190,8 +204,8 @@ def get_time(string): def get_summed_blames(self): summed_blames = {} - for i in self.blames.items(): - if summed_blames.get(i[0][0], None) == None: + for i in list(self.blames.items()): + if summed_blames.get(i[0][0], None) is None: summed_blames[i[0][0]] = BlameEntry() summed_blames[i[0][0]].rows += i[1].rows diff --git a/gitinspector/changes.py b/gitinspector/changes.py index f1b39ff8..034fbc8c 100644 --- a/gitinspector/changes.py +++ b/gitinspector/changes.py @@ -17,14 +17,15 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import division -from __future__ import unicode_literals +from __future__ import annotations + import bisect import datetime import multiprocessing import os import subprocess import threading +from typing import List, Optional, Tuple from .localization import N_ from . import extensions, filtering, format, interval, terminal @@ -34,8 +35,9 @@ __thread_lock__ = threading.BoundedSemaphore(NUM_THREADS) __changes_lock__ = threading.Lock() -class FileDiff(object): - def __init__(self, string): + +class FileDiff: + def __init__(self, string: str) -> None: commit_line = string.split("|") if commit_line.__len__() == 2: @@ -44,29 +46,30 @@ def __init__(self, string): self.deletions = commit_line[1].count("-") @staticmethod - def is_filediff_line(string): + def is_filediff_line(string: str) -> bool: string = string.split("|") - return string.__len__() == 2 and string[1].find("Bin") == -1 and ('+' in string[1] or '-' in string[1]) + return len(string) == 2 and string[1].find("Bin") == -1 and ("+" in string[1] or "-" in string[1]) @staticmethod - def get_extension(string): - string = string.split("|")[0].strip().strip("{}").strip("\"").strip("'") + def get_extension(string: str) -> str: + string = string.split("|")[0].strip().strip("{}").strip('"').strip("'") return os.path.splitext(string)[1][1:] @staticmethod - def get_filename(string): - return string.split("|")[0].strip().strip("{}").strip("\"").strip("'") + def get_filename(string: str) -> str: + return string.split("|")[0].strip().strip("{}").strip('"').strip("'") @staticmethod - def is_valid_extension(string): + def is_valid_extension(string: str) -> bool: extension = FileDiff.get_extension(string) for i in extensions.get(): - if (extension == "" and i == "*") or extension == i or i == '**': + if (extension == "" and i == "*") or extension == i or i == "**": return True return False -class Commit(object): + +class Commit: def __init__(self, string): self.filediffs = [] commit_line = string.split("|") @@ -78,35 +81,37 @@ def __init__(self, string): self.author = commit_line[3].strip() self.email = commit_line[4].strip() - def __lt__(self, other): - return self.timestamp.__lt__(other.timestamp) # only used for sorting; we just consider the timestamp. + def __lt__(self, other: Commit) -> bool: + return self.timestamp.__lt__(other.timestamp) # only used for sorting; we just consider the timestamp. - def add_filediff(self, filediff): + def add_filediff(self, filediff: FileDiff) -> None: self.filediffs.append(filediff) - def get_filediffs(self): + def get_filediffs(self) -> List[FileDiff]: return self.filediffs @staticmethod - def get_author_and_email(string): + def get_author_and_email(string: str) -> Optional[Tuple[str, str]]: commit_line = string.split("|") - if commit_line.__len__() == 5: + if len(commit_line) == 5: return (commit_line[3].strip(), commit_line[4].strip()) @staticmethod - def is_commit_line(string): - return string.split("|").__len__() == 5 + def is_commit_line(string: str) -> bool: + return len(string.split("|")) == 5 -class AuthorInfo(object): + +class AuthorInfo(): email = None insertions = 0 deletions = 0 commits = 0 + class ChangesThread(threading.Thread): def __init__(self, hard, changes, first_hash, second_hash, offset): - __thread_lock__.acquire() # Lock controlling the number of threads running + __thread_lock__.acquire() # Lock controlling the number of threads running threading.Thread.__init__(self) self.hard = hard @@ -122,10 +127,27 @@ def create(hard, changes, first_hash, second_hash, offset): thread.start() def run(self): - git_log_r = subprocess.Popen(filter(None, ["git", "log", "--reverse", "--pretty=%ct|%cd|%H|%aN|%aE", - "--stat=100000,8192", "--no-merges", "-w", interval.get_since(), - interval.get_until(), "--date=short"] + (["-C", "-C", "-M"] if self.hard else []) + - [self.first_hash + self.second_hash]), bufsize=1, stdout=subprocess.PIPE).stdout + git_log_r = subprocess.Popen( + [ + _f + for _f in [ + "git", + "log", + "--reverse", + "--pretty=%ct|%cd|%H|%aN|%aE", + "--stat=100000,8192", + "--no-merges", + "-w", + interval.get_since(), + interval.get_until(), + "--date=short", + ] + + (["-C", "-C", "-M"] if self.hard else []) + + [self.first_hash + self.second_hash] + if _f + ], + stdout=subprocess.PIPE, + ).stdout lines = git_log_r.readlines() git_log_r.close() @@ -134,7 +156,7 @@ def run(self): is_filtered = False commits = [] - __changes_lock__.acquire() # Global lock used to protect calls from here... + __changes_lock__.acquire() # Global lock used to protect calls from here... for i in lines: j = i.strip().decode("unicode_escape", "ignore") @@ -154,15 +176,15 @@ def run(self): is_filtered = False commit = Commit(j) - if Commit.is_commit_line(j) and \ - (filtering.set_filtered(commit.author, "author") or \ - filtering.set_filtered(commit.email, "email") or \ - filtering.set_filtered(commit.sha, "revision") or \ - filtering.set_filtered(commit.sha, "message")): + if Commit.is_commit_line(j) and ( + filtering.set_filtered(commit.author, "author") + or filtering.set_filtered(commit.email, "email") + or filtering.set_filtered(commit.sha, "revision") + or filtering.set_filtered(commit.sha, "message") + ): is_filtered = True - if FileDiff.is_filediff_line(j) and not \ - filtering.set_filtered(FileDiff.get_filename(j)) and not is_filtered: + if FileDiff.is_filediff_line(j) and not filtering.set_filtered(FileDiff.get_filename(j)) and not is_filtered: extensions.add_located(FileDiff.get_extension(j)) if FileDiff.is_valid_extension(j): @@ -171,12 +193,14 @@ def run(self): commit.add_filediff(filediff) self.changes.commits[self.offset // CHANGES_PER_THREAD] = commits - __changes_lock__.release() # ...to here. - __thread_lock__.release() # Lock controlling the number of threads running + __changes_lock__.release() # ...to here. + __thread_lock__.release() # Lock controlling the number of threads running + PROGRESS_TEXT = N_("Fetching and calculating primary statistics (1 of 2): {0:.0f}%") -class Changes(object): + +class Changes(): authors = {} authors_dateinfo = {} authors_by_email = {} @@ -184,17 +208,19 @@ class Changes(object): def __init__(self, repo, hard): self.commits = [] - interval.set_ref("HEAD"); - git_rev_list_p = subprocess.Popen(filter(None, ["git", "rev-list", "--reverse", "--no-merges", - interval.get_since(), interval.get_until(), "HEAD"]), bufsize=1, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + interval.set_ref("HEAD") + git_rev_list_p = subprocess.Popen( + [_f for _f in ["git", "rev-list", "--reverse", "--no-merges", interval.get_since(), interval.get_until(), "HEAD"] if _f], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) lines = git_rev_list_p.communicate()[0].splitlines() git_rev_list_p.stdout.close() if git_rev_list_p.returncode == 0 and len(lines) > 0: progress_text = _(PROGRESS_TEXT) - if repo != None: - progress_text = "[%s] " % repo.name + progress_text + if repo is not None: + progress_text = f"[{repo.name}] " + progress_text chunks = len(lines) // CHANGES_PER_THREAD self.commits = [None] * (chunks if len(lines) % CHANGES_PER_THREAD == 0 else chunks + 1) @@ -229,10 +255,12 @@ def __init__(self, repo, hard): if interval.has_interval(): interval.set_ref(self.commits[-1].sha) - self.first_commit_date = datetime.date(int(self.commits[0].date[0:4]), int(self.commits[0].date[5:7]), - int(self.commits[0].date[8:10])) - self.last_commit_date = datetime.date(int(self.commits[-1].date[0:4]), int(self.commits[-1].date[5:7]), - int(self.commits[-1].date[8:10])) + self.first_commit_date = datetime.date( + int(self.commits[0].date[0:4]), int(self.commits[0].date[5:7]), int(self.commits[0].date[8:10]) + ) + self.last_commit_date = datetime.date( + int(self.commits[-1].date[0:4]), int(self.commits[-1].date[5:7]), int(self.commits[-1].date[8:10]) + ) def __iadd__(self, other): try: @@ -255,7 +283,7 @@ def get_commits(self): @staticmethod def modify_authorinfo(authors, key, commit): - if authors.get(key, None) == None: + if authors.get(key, None) is None: authors[key] = AuthorInfo() if commit.get_filediffs(): diff --git a/gitinspector/clone.py b/gitinspector/clone.py index 4fe858d4..3700fd07 100644 --- a/gitinspector/clone.py +++ b/gitinspector/clone.py @@ -17,42 +17,74 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import unicode_literals -import os +from __future__ import annotations + import shutil -import subprocess import sys import tempfile +from pathlib import Path +from typing import List, Optional +from urllib.parse import urlparse + +from .git_utils import run_git_command, GitCommandError, GitNotFoundError + +__cloned_paths__: List[Path] = [] + + +class Repository: + """Represents a git repository with name and location.""" -try: - from urllib.parse import urlparse -except: - from urlparse import urlparse + def __init__(self, name: Optional[str], location: str) -> None: + self.name = name + self.location = location -__cloned_paths__ = [] -def create(url): - class Repository(object): - def __init__(self, name, location): - self.name = name - self.location = location +def create(url: str) -> Repository: + """ + Create a Repository object, cloning remote URLs or using local paths. + Args: + url: URL or path to the repository + + Returns: + Repository: Repository object with name and location + + Raises: + SystemExit: If git clone fails + GitNotFoundError: If git command is not found + """ parsed_url = urlparse(url) - if parsed_url.scheme == "file" or parsed_url.scheme == "git" or parsed_url.scheme == "http" or \ - parsed_url.scheme == "https" or parsed_url.scheme == "ssh": - path = tempfile.mkdtemp(suffix=".gitinspector") - git_clone = subprocess.Popen(["git", "clone", url, path], bufsize=1, stdout=sys.stderr) - git_clone.wait() + # Check if this is a remote URL that needs cloning + if parsed_url.scheme in ("file", "git", "http", "https", "ssh"): + try: + # Create temporary directory for cloning + temp_dir = Path(tempfile.mkdtemp(suffix=".gitinspector")) + + # Clone the repository using our improved git command detection + run_git_command( + ["clone", url, str(temp_dir)], + capture_output=False, # Let git output go to stderr + check=True + ) + + __cloned_paths__.append(temp_dir) + return Repository(Path(parsed_url.path).name, str(temp_dir)) - if git_clone.returncode != 0: - sys.exit(git_clone.returncode) + except (GitCommandError, GitNotFoundError) as e: + print(f"Error cloning repository: {e}", file=sys.stderr) + sys.exit(1) - __cloned_paths__.append(path) - return Repository(os.path.basename(parsed_url.path), path) + # For local paths, just return as-is + local_path = Path(url).resolve() + return Repository(None, str(local_path)) - return Repository(None, os.path.abspath(url)) -def delete(): +def delete() -> None: + """ + Clean up all cloned repositories by removing their temporary directories. + """ for path in __cloned_paths__: - shutil.rmtree(path, ignore_errors=True) + if path.exists(): + shutil.rmtree(path, ignore_errors=True) + __cloned_paths__.clear() diff --git a/gitinspector/comment.py b/gitinspector/comment.py index c80c9e32..47c4a4cc 100644 --- a/gitinspector/comment.py +++ b/gitinspector/comment.py @@ -17,51 +17,129 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import unicode_literals -__comment_begining__ = {"java": "/*", "c": "/*", "cc": "/*", "cpp": "/*", "cs": "/*", "h": "/*", "hh": "/*", "hpp": "/*", - "hs": "{-", "html": "", "php": "*/", "py": "\"\"\"", "glsl": "*/", "rb": "=end", "js": "*/", "jspx": "-->", - "scala": "*/", "sql": "*/", "tex": "\\end{comment}", "xhtml": "-->", "xml": "-->", "ml": "*)", "mli": "*)", - "go": "*/", "ly": "%}", "ily": "%}"} +__comment_end__ = { + "java": "*/", + "c": "*/", + "cc": "*/", + "cpp": "*/", + "cs": "*/", + "h": "*/", + "hh": "*/", + "hpp": "*/", + "hs": "-}", + "html": "-->", + "php": "*/", + "py": '"""', + "glsl": "*/", + "rb": "=end", + "js": "*/", + "jspx": "-->", + "scala": "*/", + "sql": "*/", + "tex": "\\end{comment}", + "xhtml": "-->", + "xml": "-->", + "ml": "*)", + "mli": "*)", + "go": "*/", + "ly": "%}", + "ily": "%}", +} -__comment__ = {"java": "//", "c": "//", "cc": "//", "cpp": "//", "cs": "//", "h": "//", "hh": "//", "hpp": "//", "hs": "--", - "pl": "#", "php": "//", "py": "#", "glsl": "//", "rb": "#", "robot": "#", "rs": "//", "rlib": "//", "js": "//", - "scala": "//", "sql": "--", "tex": "%", "ada": "--", "ads": "--", "adb": "--", "pot": "#", "po": "#", "go": "//", - "ly": "%", "ily": "%"} +__comment__ = { + "java": "//", + "c": "//", + "cc": "//", + "cpp": "//", + "cs": "//", + "h": "//", + "hh": "//", + "hpp": "//", + "hs": "--", + "pl": "#", + "php": "//", + "py": "#", + "glsl": "//", + "rb": "#", + "robot": "#", + "rs": "//", + "rlib": "//", + "js": "//", + "scala": "//", + "sql": "--", + "tex": "%", + "ada": "--", + "ads": "--", + "adb": "--", + "pot": "#", + "po": "#", + "go": "//", + "ly": "%", + "ily": "%", +} __comment_markers_must_be_at_begining__ = {"tex": True} + def __has_comment_begining__(extension, string): - if __comment_markers_must_be_at_begining__.get(extension, None) == True: + if __comment_markers_must_be_at_begining__.get(extension, None): return string.find(__comment_begining__[extension]) == 0 - elif __comment_begining__.get(extension, None) != None and string.find(__comment_end__[extension], 2) == -1: + if __comment_begining__.get(extension, None) is not None and string.find(__comment_end__[extension], 2) == -1: return string.find(__comment_begining__[extension]) != -1 return False + def __has_comment_end__(extension, string): - if __comment_markers_must_be_at_begining__.get(extension, None) == True: + if __comment_markers_must_be_at_begining__.get(extension, None): return string.find(__comment_end__[extension]) == 0 - elif __comment_end__.get(extension, None) != None: + if __comment_end__.get(extension, None) is not None: return string.find(__comment_end__[extension]) != -1 return False + def is_comment(extension, string): - if __comment_begining__.get(extension, None) != None and string.strip().startswith(__comment_begining__[extension]): + if __comment_begining__.get(extension, None) is not None and string.strip().startswith(__comment_begining__[extension]): return True - if __comment_end__.get(extension, None) != None and string.strip().endswith(__comment_end__[extension]): + if __comment_end__.get(extension, None) is not None and string.strip().endswith(__comment_end__[extension]): return True - if __comment__.get(extension, None) != None and string.strip().startswith(__comment__[extension]): + if __comment__.get(extension, None) is not None and string.strip().startswith(__comment__[extension]): return True return False + def handle_comment_block(is_inside_comment, extension, content): comments = 0 diff --git a/gitinspector/config.py b/gitinspector/config.py index 4eaf15c6..6fc1a7a1 100644 --- a/gitinspector/config.py +++ b/gitinspector/config.py @@ -17,12 +17,13 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import unicode_literals + import os import subprocess from . import extensions, filtering, format, interval, optval -class GitConfig(object): + +class GitConfig(): def __init__(self, run, repo, global_only=False): self.run = run self.repo = repo @@ -31,8 +32,10 @@ def __init__(self, run, repo, global_only=False): def __read_git_config__(self, variable): previous_directory = os.getcwd() os.chdir(self.repo) - setting = subprocess.Popen(filter(None, ["git", "config", "--global" if self.global_only else "", - "inspector." + variable]), bufsize=1, stdout=subprocess.PIPE).stdout + setting = subprocess.Popen( + [_f for _f in ["git", "config", "--global" if self.global_only else "", "inspector." + variable] if _f], + stdout=subprocess.PIPE, + ).stdout os.chdir(previous_directory) try: diff --git a/gitinspector/extensions.py b/gitinspector/extensions.py index 37647358..882883c2 100644 --- a/gitinspector/extensions.py +++ b/gitinspector/extensions.py @@ -17,25 +17,28 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import unicode_literals -DEFAULT_EXTENSIONS = ["java", "c", "cc", "cpp", "h", "hh", "hpp", "py", "glsl", "rb", "js", "sql"] +DEFAULT_EXTENSIONS = ["java", "c", "cc", "cpp", "h", "hh", "hpp", "py", "glsl", "rb", "js", "sql", "go"] __extensions__ = DEFAULT_EXTENSIONS __located_extensions__ = set() + def get(): return __extensions__ + def define(string): global __extensions__ __extensions__ = string.split(",") + def add_located(string): if len(string) == 0: __located_extensions__.add("*") else: __located_extensions__.add(string) + def get_located(): return __located_extensions__ diff --git a/gitinspector/filtering.py b/gitinspector/filtering.py index 41ed4de2..8cf20abc 100644 --- a/gitinspector/filtering.py +++ b/gitinspector/filtering.py @@ -17,49 +17,63 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import unicode_literals + import re import subprocess -__filters__ = {"file": [set(), set()], "author": [set(), set()], "email": [set(), set()], "revision": [set(), set()], - "message" : [set(), None]} +__filters__ = { + "file": [set(), set()], + "author": [set(), set()], + "email": [set(), set()], + "revision": [set(), set()], + "message": [set(), None], +} + class InvalidRegExpError(ValueError): def __init__(self, msg): super(InvalidRegExpError, self).__init__(msg) self.msg = msg + def get(): return __filters__ + def __add_one__(string): for i in __filters__: - if (i + ":").lower() == string[0:len(i) + 1].lower(): - __filters__[i][0].add(string[len(i) + 1:]) + if (i + ":").lower() == string[0 : len(i) + 1].lower(): + __filters__[i][0].add(string[len(i) + 1 :]) return __filters__["file"][0].add(string) + def add(string): rules = string.split(",") for rule in rules: __add_one__(rule) + def clear(): for i in __filters__: __filters__[i][0] = set() + def get_filered(filter_type="file"): return __filters__[filter_type][1] + def has_filtered(): for i in __filters__: if __filters__[i][1]: return True return False + def __find_commit_message__(sha): - git_show_r = subprocess.Popen(filter(None, ["git", "show", "-s", "--pretty=%B", "-w", sha]), bufsize=1, - stdout=subprocess.PIPE).stdout + git_show_r = subprocess.Popen( + [_f for _f in ["git", "show", "-s", "--pretty=%B", "-w", sha] if _f], stdout=subprocess.PIPE + ).stdout commit_message = git_show_r.read() git_show_r.close() @@ -68,6 +82,7 @@ def __find_commit_message__(sha): commit_message = commit_message.encode("latin-1", "replace") return commit_message.decode("utf-8", "replace") + def set_filtered(string, filter_type="file"): string = string.strip() @@ -78,7 +93,7 @@ def set_filtered(string, filter_type="file"): if filter_type == "message": search_for = __find_commit_message__(string) try: - if re.search(i, search_for) != None: + if re.search(i, search_for) is not None: if filter_type == "message": __add_one__("revision:" + string) else: diff --git a/gitinspector/format.py b/gitinspector/format.py index 505da033..ca8f9125 100644 --- a/gitinspector/format.py +++ b/gitinspector/format.py @@ -17,8 +17,7 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import print_function -from __future__ import unicode_literals + import base64 import os import textwrap @@ -33,23 +32,28 @@ __selected_format__ = DEFAULT_FORMAT + class InvalidFormatError(Exception): def __init__(self, msg): super(InvalidFormatError, self).__init__(msg) self.msg = msg + def select(format): global __selected_format__ __selected_format__ = format return format in __available_formats__ + def get_selected(): return __selected_format__ + def is_interactive_format(): return __selected_format__ == "text" + def __output_html_template__(name): template_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), name) file_r = open(template_path, "rb") @@ -58,6 +62,7 @@ def __output_html_template__(name): file_r.close() return template + def __get_zip_file_content__(name, file_name="/html/flot.zip"): zip_file = zipfile.ZipFile(basedir.get_basedir() + file_name, "r") content = zip_file.read(name) @@ -65,17 +70,20 @@ def __get_zip_file_content__(name, file_name="/html/flot.zip"): zip_file.close() return content.decode("utf-8", "replace") + INFO_ONE_REPOSITORY = N_("Statistical information for the repository '{0}' was gathered on {1}.") INFO_MANY_REPOSITORIES = N_("Statistical information for the repositories '{0}' was gathered on {1}.") + def output_header(repos): repos_string = ", ".join([repo.name for repo in repos]) if __selected_format__ == "html" or __selected_format__ == "htmlembedded": base = basedir.get_basedir() html_header = __output_html_template__(base + "/html/html.header") - tablesorter_js = __get_zip_file_content__("jquery.tablesorter.min.js", - "/html/jquery.tablesorter.min.js.zip").encode("latin-1", "replace") + tablesorter_js = __get_zip_file_content__("jquery.tablesorter.min.js", "/html/jquery.tablesorter.min.js.zip").encode( + "latin-1", "replace" + ) tablesorter_js = tablesorter_js.decode("utf-8", "ignore") flot_js = __get_zip_file_content__("jquery.flot.js") pie_js = __get_zip_file_content__("jquery.flot.pie.js") @@ -89,40 +97,44 @@ def output_header(repos): if __selected_format__ == "htmlembedded": jquery_js = ">" + __get_zip_file_content__("jquery.js") else: - jquery_js = " src=\"https://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js\">" - - print(html_header.format(title=_("Repository statistics for '{0}'").format(repos_string), - jquery=jquery_js, - jquery_tablesorter=tablesorter_js, - jquery_flot=flot_js, - jquery_flot_pie=pie_js, - jquery_flot_resize=resize_js, - logo=logo.decode("utf-8", "replace"), - logo_text=_("The output has been generated by {0} {1}. The statistical analysis tool" - " for git repositories.").format( - "gitinspector", - version.__version__), - repo_text=_(INFO_ONE_REPOSITORY if len(repos) <= 1 else INFO_MANY_REPOSITORIES).format( - repos_string, localization.get_date()), - show_minor_authors=_("Show minor authors"), - hide_minor_authors=_("Hide minor authors"), - show_minor_rows=_("Show rows with minor work"), - hide_minor_rows=_("Hide rows with minor work"))) + jquery_js = ' src="https://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js">' + + print( + html_header.format( + title=_("Repository statistics for '{0}'").format(repos_string), + jquery=jquery_js, + jquery_tablesorter=tablesorter_js, + jquery_flot=flot_js, + jquery_flot_pie=pie_js, + jquery_flot_resize=resize_js, + logo=logo.decode("utf-8", "replace"), + logo_text=_("The output has been generated by {0} {1}. The statistical analysis tool" " for git repositories.").format( + 'gitinspector', version.__version__ + ), + repo_text=_(INFO_ONE_REPOSITORY if len(repos) <= 1 else INFO_MANY_REPOSITORIES).format( + repos_string, localization.get_date() + ), + show_minor_authors=_("Show minor authors"), + hide_minor_authors=_("Hide minor authors"), + show_minor_rows=_("Show rows with minor work"), + hide_minor_rows=_("Hide rows with minor work"), + ) + ) elif __selected_format__ == "json": - print("{\n\t\"gitinspector\": {") - print("\t\t\"version\": \"" + version.__version__ + "\",") + print('{\n\t"gitinspector": {') + print('\t\t"version": "' + version.__version__ + '",') if len(repos) <= 1: - print("\t\t\"repository\": \"" + repos_string + "\",") + print('\t\t"repository": "' + repos_string + '",') else: - repos_json = "\t\t\"repositories\": [ " + repos_json = '\t\t"repositories": [ ' for repo in repos: - repos_json += "\"" + repo.name + "\", " + repos_json += '"' + repo.name + '", ' print(repos_json[:-2] + " ],") - print("\t\t\"report_date\": \"" + time.strftime("%Y/%m/%d") + "\",") + print('\t\t"report_date": "' + time.strftime("%Y/%m/%d") + '",') elif __selected_format__ == "xml": print("") @@ -140,8 +152,13 @@ def output_header(repos): print("\t" + time.strftime("%Y/%m/%d") + "") else: - print(textwrap.fill(_(INFO_ONE_REPOSITORY if len(repos) <= 1 else INFO_MANY_REPOSITORIES).format( - repos_string, localization.get_date()), width=terminal.get_size()[0])) + print( + textwrap.fill( + _(INFO_ONE_REPOSITORY if len(repos) <= 1 else INFO_MANY_REPOSITORIES).format(repos_string, localization.get_date()), + width=terminal.get_size()[0], + ) + ) + def output_footer(): if __selected_format__ == "html" or __selected_format__ == "htmlembedded": diff --git a/gitinspector/git_utils.py b/gitinspector/git_utils.py new file mode 100644 index 00000000..bb6e8126 --- /dev/null +++ b/gitinspector/git_utils.py @@ -0,0 +1,186 @@ +"""Git utility functions with improved command detection and path handling.""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path +from typing import List, Optional, Union + + +class GitCommandError(Exception): + """Raised when git command execution fails.""" + pass + + +class GitNotFoundError(Exception): + """Raised when git command cannot be found in PATH.""" + pass + + +def find_git_command() -> str: + """ + Find the git command in the system PATH. + + Returns: + str: Path to the git executable + + Raises: + GitNotFoundError: If git command cannot be found + """ + git_cmd = shutil.which("git") + if git_cmd is None: + # Try common locations as fallback + common_paths = [ + "/usr/bin/git", + "/usr/local/bin/git", + "/opt/homebrew/bin/git", # macOS Homebrew on Apple Silicon + "/opt/local/bin/git" # macOS MacPorts + ] + + for path in common_paths: + if Path(path).exists(): + git_cmd = path + break + + if git_cmd is None: + raise GitNotFoundError( + "Git command not found in PATH. Please install Git or ensure it's in your PATH." + ) + + return git_cmd + + +def run_git_command( + args: List[str], + cwd: Optional[Union[str, Path]] = None, + capture_output: bool = True, + check: bool = True, + input_data: Optional[str] = None, +) -> subprocess.CompletedProcess[bytes]: + """ + Run a git command with improved error handling. + + Args: + args: Git command arguments (without 'git' prefix) + cwd: Working directory for the command + capture_output: Whether to capture stdout/stderr + check: Whether to raise exception on non-zero exit code + input_data: Optional input data to pass to the command + + Returns: + subprocess.CompletedProcess: Result of the command execution + + Raises: + GitNotFoundError: If git command cannot be found + GitCommandError: If git command fails and check=True + """ + git_cmd = find_git_command() + full_cmd = [git_cmd] + args + + try: + result = subprocess.run( + full_cmd, + cwd=cwd, + capture_output=capture_output, + check=False, # We'll handle checking ourselves + input=input_data.encode() if input_data else None, + ) + + if check and result.returncode != 0: + error_msg = f"Git command failed: {' '.join(full_cmd)}" + if result.stderr: + error_msg += f"\nError: {result.stderr.decode('utf-8', errors='replace')}" + raise GitCommandError(error_msg) + + return result + + except FileNotFoundError as e: + raise GitNotFoundError(f"Failed to execute git command: {e}") + + +def get_git_repository_root(path: Optional[Union[str, Path]] = None) -> Path: + """ + Get the root directory of a git repository. + + Args: + path: Path to check (defaults to current directory) + + Returns: + Path: Root directory of the git repository + + Raises: + GitCommandError: If not in a git repository or command fails + """ + try: + result = run_git_command( + ["rev-parse", "--show-toplevel"], + cwd=path, + ) + return Path(result.stdout.decode('utf-8').strip()) + except GitCommandError: + raise GitCommandError("Not in a git repository or git repository root not found") + + +def is_git_repository(path: Optional[Union[str, Path]] = None) -> bool: + """ + Check if a directory is a git repository. + + Args: + path: Path to check (defaults to current directory) + + Returns: + bool: True if the path is a git repository + """ + try: + run_git_command(["rev-parse", "--git-dir"], cwd=path) + return True + except (GitCommandError, GitNotFoundError): + return False + + +def is_bare_repository(path: Optional[Union[str, Path]] = None) -> bool: + """ + Check if a git repository is bare. + + Args: + path: Path to check (defaults to current directory) + + Returns: + bool: True if the repository is bare + + Raises: + GitCommandError: If not in a git repository + """ + try: + result = run_git_command( + ["rev-parse", "--is-bare-repository"], + cwd=path, + ) + return result.stdout.decode('utf-8').strip().lower() == "true" + except GitCommandError: + raise GitCommandError("Not in a git repository") + + +def get_git_dir(path: Optional[Union[str, Path]] = None) -> Path: + """ + Get the .git directory path. + + Args: + path: Path to check (defaults to current directory) + + Returns: + Path: Path to the .git directory + + Raises: + GitCommandError: If not in a git repository + """ + try: + result = run_git_command( + ["rev-parse", "--git-dir"], + cwd=path, + ) + git_dir = result.stdout.decode('utf-8').strip() + return Path(git_dir) if Path(git_dir).is_absolute() else Path(path or ".") / git_dir + except GitCommandError: + raise GitCommandError("Not in a git repository") diff --git a/gitinspector/gitinspector.py b/gitinspector/gitinspector.py index 71492943..249ab086 100644 --- a/gitinspector/gitinspector.py +++ b/gitinspector/gitinspector.py @@ -17,18 +17,19 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import print_function -from __future__ import unicode_literals + +from __future__ import annotations + import atexit import getopt import os import sys +from typing import Optional, List, Any from .blame import Blame from .changes import Changes from .config import GitConfig from .metrics import MetricsLogic -from . import (basedir, clone, extensions, filtering, format, help, interval, - localization, optval, terminal, version) +from . import basedir, clone, extensions, filtering, format, help, interval, localization, optval, terminal, version from .output import outputable from .output.blameoutput import BlameOutput from .output.changesoutput import ChangesOutput @@ -40,8 +41,9 @@ localization.init() -class Runner(object): - def __init__(self): + +class Runner: + def __init__(self) -> None: self.hard = False self.include_metrics = False self.list_file_types = False @@ -51,7 +53,7 @@ def __init__(self): self.timeline = False self.useweeks = False - def process(self, repos): + def process(self, repos: List[Any]) -> None: localization.check_compatibility(version.__version__) if not self.localize_output: @@ -102,22 +104,24 @@ def process(self, repos): format.output_footer() os.chdir(previous_directory) -def __check_python_version__(): - if sys.version_info < (2, 6): - python_version = str(sys.version_info[0]) + "." + str(sys.version_info[1]) - sys.exit(_("gitinspector requires at least Python 2.6 to run (version {0} was found).").format(python_version)) -def __get_validated_git_repos__(repos_relative): +def __check_python_version__() -> None: + if sys.version_info < (3, 10): + python_version = f"{sys.version_info[0]}.{sys.version_info[1]}" + sys.exit(_(f"gitinspector requires at least Python 3.10 to run (version {python_version} was found).")) + + +def __get_validated_git_repos__(repos_relative: Any) -> List[Any]: if not repos_relative: repos_relative = "." repos = [] - #Try to clone the repos or return the same directory and bail out. + # Try to clone the repos or return the same directory and bail out. for repo in repos_relative: cloned_repo = clone.create(repo) - if cloned_repo.name == None: + if cloned_repo.name is None: cloned_repo.location = basedir.get_basedir_git(cloned_repo.location) cloned_repo.name = os.path.basename(cloned_repo.location) @@ -125,31 +129,49 @@ def __get_validated_git_repos__(repos_relative): return repos -def main(): + +def main(argv: Optional[List[str]] = None) -> None: terminal.check_terminal_encoding() terminal.set_stdin_encoding() - argv = terminal.convert_command_line_to_utf8() + argv = terminal.convert_command_line_to_utf8() if argv is None else argv run = Runner() repos = [] try: - opts, args = optval.gnu_getopt(argv[1:], "f:F:hHlLmrTwx:", ["exclude=", "file-types=", "format=", - "hard:true", "help", "list-file-types:true", "localize-output:true", - "metrics:true", "responsibilities:true", "since=", "grading:true", - "timeline:true", "until=", "version", "weeks:true"]) + opts, args = optval.gnu_getopt( + argv[1:], + "f:F:hHlLmrTwx:", + [ + "exclude=", + "file-types=", + "format=", + "hard:true", + "help", + "list-file-types:true", + "localize-output:true", + "metrics:true", + "responsibilities:true", + "since=", + "grading:true", + "timeline:true", + "until=", + "version", + "weeks:true", + ], + ) repos = __get_validated_git_repos__(set(args)) - #We need the repos above to be set before we read the git config. + # We need the repos above to be set before we read the git config. GitConfig(run, repos[-1].location).read() clear_x_on_next_pass = True for o, a in opts: - if o in("-h", "--help"): + if o in ("-h", "--help"): help.output() sys.exit(0) - elif o in("-f", "--file-types"): + elif o in ("-f", "--file-types"): extensions.define(a) - elif o in("-F", "--format"): + elif o in ("-F", "--format"): if not format.select(a): raise format.InvalidFormatError(_("specified output format not supported.")) elif o == "-H": @@ -196,7 +218,7 @@ def main(): run.useweeks = True elif o == "--weeks": run.useweeks = optval.get_boolean_argument(a) - elif o in("-x", "--exclude"): + elif o in ("-x", "--exclude"): if clear_x_on_next_pass: clear_x_on_next_pass = False filtering.clear() @@ -210,9 +232,11 @@ def main(): print(_("Try `{0} --help' for more information.").format(sys.argv[0]), file=sys.stderr) sys.exit(2) + @atexit.register -def cleanup(): +def cleanup() -> None: clone.delete() + if __name__ == "__main__": main() diff --git a/gitinspector/gravatar.py b/gitinspector/gravatar.py index 1ca5070d..20f78cf1 100644 --- a/gitinspector/gravatar.py +++ b/gitinspector/gravatar.py @@ -17,16 +17,17 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import unicode_literals + import hashlib try: from urllib.parse import urlencode except: - from urllib import urlencode + from urllib.parse import urlencode from . import format + def get_url(email, size=20): md5hash = hashlib.md5(email.encode("utf-8").lower().strip()).hexdigest() base_url = "https://www.gravatar.com/avatar/" + md5hash diff --git a/gitinspector/help.py b/gitinspector/help.py index 447dcb50..99088306 100644 --- a/gitinspector/help.py +++ b/gitinspector/help.py @@ -17,66 +17,68 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import print_function -from __future__ import unicode_literals + import sys from .extensions import DEFAULT_EXTENSIONS from .format import __available_formats__ -__doc__ = _("""Usage: {0} [OPTION]... [REPOSITORY]... +__doc__ = _( + """Usage: {0} [OPTION]... [REPOSITORY]... List information about the repository in REPOSITORY. If no repository is specified, the current directory is used. If multiple repositories are given, information will be merged into a unified statistical report. Mandatory arguments to long options are mandatory for short options too. Boolean arguments can only be given to long options. - -f, --file-types=EXTENSIONS a comma separated list of file extensions to - include when computing statistics. The - default extensions used are: - {1} - Specifying * includes files with no - extension, while ** includes all files - -F, --format=FORMAT define in which format output should be - generated; the default format is 'text' and - the available formats are: - {2} - --grading[=BOOL] show statistics and information in a way that - is formatted for grading of student - projects; this is the same as supplying the - options -HlmrTw - -H, --hard[=BOOL] track rows and look for duplicates harder; - this can be quite slow with big repositories + -f, --file-types=EXTENSIONS a comma separated list of file extensions to + include when computing statistics. The + default extensions used are: + {1} + Specifying * includes files with no + extension, while ** includes all files + -F, --format=FORMAT define in which format output should be + generated; the default format is 'text' and + the available formats are: + {2} + --grading[=BOOL] show statistics and information in a way that + is formatted for grading of student + projects; this is the same as supplying the + options -HlmrTw + -H, --hard[=BOOL] track rows and look for duplicates harder; + this can be quite slow with big repositories -l, --list-file-types[=BOOL] list all the file extensions available in the - current branch of the repository + current branch of the repository -L, --localize-output[=BOOL] localize the generated output to the selected - system language if a translation is - available - -m --metrics[=BOOL] include checks for certain metrics during the - analysis of commits + system language if a translation is + available + -m --metrics[=BOOL] include checks for certain metrics during the + analysis of commits -r --responsibilities[=BOOL] show which files the different authors seem - most responsible for - --since=DATE only show statistics for commits more recent - than a specific date - -T, --timeline[=BOOL] show commit timeline, including author names - --until=DATE only show statistics for commits older than a - specific date - -w, --weeks[=BOOL] show all statistical information in weeks - instead of in months - -x, --exclude=PATTERN an exclusion pattern describing the file - paths, revisions, revisions with certain - commit messages, author names or author - emails that should be excluded from the - statistics; can be specified multiple times - -h, --help display this help and exit - --version output version information and exit + most responsible for + --since=DATE only show statistics for commits more recent + than a specific date + -T, --timeline[=BOOL] show commit timeline, including author names + --until=DATE only show statistics for commits older than a + specific date + -w, --weeks[=BOOL] show all statistical information in weeks + instead of in months + -x, --exclude=PATTERN an exclusion pattern describing the file + paths, revisions, revisions with certain + commit messages, author names or author + emails that should be excluded from the + statistics; can be specified multiple times + -h, --help display this help and exit + --version output version information and exit gitinspector will filter statistics to only include commits that modify, add or remove one of the specified extensions, see -f or --file-types for more information. gitinspector requires that the git executable is available in your PATH. -Report gitinspector bugs to gitinspector@ejwa.se.""") +Report gitinspector bugs to gitinspector@ejwa.se.""" +) + def output(): print(__doc__.format(sys.argv[0], ",".join(DEFAULT_EXTENSIONS), ",".join(__available_formats__))) diff --git a/gitinspector/interval.py b/gitinspector/interval.py index 23008072..43e3366b 100644 --- a/gitinspector/interval.py +++ b/gitinspector/interval.py @@ -17,7 +17,6 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import unicode_literals try: from shlex import quote @@ -30,26 +29,33 @@ __ref__ = "HEAD" + def has_interval(): return __since__ + __until__ != "" + def get_since(): return __since__ + def set_since(since): global __since__ __since__ = "--since=" + quote(since) + def get_until(): return __until__ + def set_until(until): global __until__ __until__ = "--until=" + quote(until) + def get_ref(): return __ref__ + def set_ref(ref): global __ref__ __ref__ = ref diff --git a/gitinspector/localization.py b/gitinspector/localization.py index ceac0a79..39d57fec 100644 --- a/gitinspector/localization.py +++ b/gitinspector/localization.py @@ -17,25 +17,28 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import print_function -from __future__ import unicode_literals +from __future__ import annotations + import gettext import locale -import os import re import sys import time +from pathlib import Path +from typing import Optional from . import basedir -__enabled__ = False -__installed__ = False -__translation__ = None +__enabled__: bool = False +__installed__: bool = False +__translation__: Optional[gettext.GNUTranslations] = None + -#Dummy function used to handle string constants -def N_(message): +# Dummy function used to handle string constants +def N_(message: str) -> str: return message -def init(): + +def init() -> None: global __enabled__ global __installed__ global __translation__ @@ -48,19 +51,22 @@ def init(): else: lang = locale.getlocale() - #Fix for non-POSIX-compliant systems (Windows et al.). - if os.getenv('LANG') is None: + # Fix for non-POSIX-compliant systems (Windows et al.). + import os + if os.getenv("LANG") is None: lang = locale.getdefaultlocale() if lang[0]: - os.environ['LANG'] = lang[0] + os.environ["LANG"] = lang[0] if lang[0] is not None: - filename = basedir.get_basedir() + "/translations/messages_%s.mo" % lang[0][0:2] + base_dir = Path(basedir.get_basedir()) + translation_file = base_dir / "translations" / f"messages_{lang[0][0:2]}.mo" try: - __translation__ = gettext.GNUTranslations(open(filename, "rb")) - except IOError: + with translation_file.open("rb") as f: + __translation__ = gettext.GNUTranslations(f) + except (IOError, OSError): __translation__ = gettext.NullTranslations() else: print("WARNING: Localization disabled because the system language could not be determined.", file=sys.stderr) @@ -68,39 +74,44 @@ def init(): __enabled__ = True __installed__ = True - __translation__.install(True) + __translation__.install() -def check_compatibility(version): + +def check_compatibility(version: str) -> None: if isinstance(__translation__, gettext.GNUTranslations): header_pattern = re.compile("^([^:\n]+): *(.*?) *$", re.MULTILINE) header_entries = dict(header_pattern.findall(_(""))) - if header_entries["Project-Id-Version"] != "gitinspector {0}".format(version): - print("WARNING: The translation for your system locale is not up to date with the current gitinspector " - "version. The current maintainer of this locale is {0}.".format(header_entries["Last-Translator"]), - file=sys.stderr) + if header_entries["Project-Id-Version"] != f"gitinspector {version}": + print( + "WARNING: The translation for your system locale is not up to date with the current gitinspector " + f"version. The current maintainer of this locale is {header_entries['Last-Translator']}.", + file=sys.stderr, + ) + -def get_date(): +def get_date() -> str: if __enabled__ and isinstance(__translation__, gettext.GNUTranslations): date = time.strftime("%x") - if hasattr(date, 'decode'): + if hasattr(date, "decode"): date = date.decode("utf-8", "replace") return date - else: - return time.strftime("%Y/%m/%d") + return time.strftime("%Y/%m/%d") -def enable(): + +def enable() -> None: if isinstance(__translation__, gettext.GNUTranslations): __translation__.install(True) global __enabled__ __enabled__ = True -def disable(): + +def disable() -> None: global __enabled__ __enabled__ = False if __installed__: - gettext.NullTranslations().install(True) + gettext.NullTranslations().install() diff --git a/gitinspector/metrics.py b/gitinspector/metrics.py index 6f90ef85..875882e9 100644 --- a/gitinspector/metrics.py +++ b/gitinspector/metrics.py @@ -17,35 +17,68 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import unicode_literals + import re import subprocess from .changes import FileDiff from . import comment, filtering, interval -__metric_eloc__ = {"java": 500, "c": 500, "cpp": 500, "cs": 500, "h": 300, "hpp": 300, "php": 500, "py": 500, "glsl": 1000, - "rb": 500, "js": 500, "sql": 1000, "xml": 1000} - -__metric_cc_tokens__ = [[["java", "js", "c", "cc", "cpp"], ["else", r"for\s+\(.*\)", r"if\s+\(.*\)", r"case\s+\w+:", - "default:", r"while\s+\(.*\)"], - ["assert", "break", "continue", "return"]], - [["cs"], ["else", r"for\s+\(.*\)", r"foreach\s+\(.*\)", r"goto\s+\w+:", r"if\s+\(.*\)", r"case\s+\w+:", - "default:", r"while\s+\(.*\)"], - ["assert", "break", "continue", "return"]], - [["py"], [r"^\s+elif .*:$", r"^\s+else:$", r"^\s+for .*:", r"^\s+if .*:$", r"^\s+while .*:$"], - [r"^\s+assert", "break", "continue", "return"]]] +__metric_eloc__ = { + "java": 500, + "c": 500, + "cpp": 500, + "cs": 500, + "h": 300, + "hpp": 300, + "php": 500, + "py": 500, + "glsl": 1000, + "rb": 500, + "js": 500, + "sql": 1000, + "xml": 1000, +} + +__metric_cc_tokens__ = [ + [ + ["java", "js", "c", "cc", "cpp"], + ["else", r"for\s+\(.*\)", r"if\s+\(.*\)", r"case\s+\w+:", "default:", r"while\s+\(.*\)"], + ["assert", "break", "continue", "return"], + ], + [ + ["cs"], + [ + "else", + r"for\s+\(.*\)", + r"foreach\s+\(.*\)", + r"goto\s+\w+:", + r"if\s+\(.*\)", + r"case\s+\w+:", + "default:", + r"while\s+\(.*\)", + ], + ["assert", "break", "continue", "return"], + ], + [ + ["py"], + [r"^\s+elif .*:$", r"^\s+else:$", r"^\s+for .*:", r"^\s+if .*:$", r"^\s+while .*:$"], + [r"^\s+assert", "break", "continue", "return"], + ], +] METRIC_CYCLOMATIC_COMPLEXITY_THRESHOLD = 50 METRIC_CYCLOMATIC_COMPLEXITY_DENSITY_THRESHOLD = 0.75 -class MetricsLogic(object): + +class MetricsLogic(): def __init__(self): self.eloc = {} self.cyclomatic_complexity = {} self.cyclomatic_complexity_density = {} - ls_tree_p = subprocess.Popen(["git", "ls-tree", "--name-only", "-r", interval.get_ref()], bufsize=1, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + ls_tree_p = subprocess.Popen( + ["git", "ls-tree", "--name-only", "-r", interval.get_ref()], stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) lines = ls_tree_p.communicate()[0].splitlines() ls_tree_p.stdout.close() @@ -53,17 +86,18 @@ def __init__(self): for i in lines: i = i.strip().decode("unicode_escape", "ignore") i = i.encode("latin-1", "replace") - i = i.decode("utf-8", "replace").strip("\"").strip("'").strip() + i = i.decode("utf-8", "replace").strip('"').strip("'").strip() if FileDiff.is_valid_extension(i) and not filtering.set_filtered(FileDiff.get_filename(i)): - file_r = subprocess.Popen(["git", "show", interval.get_ref() + ":{0}".format(i.strip())], - bufsize=1, stdout=subprocess.PIPE).stdout.readlines() + file_r = subprocess.Popen( + ["git", "show", interval.get_ref() + ":{0}".format(i.strip())], stdout=subprocess.PIPE + ).stdout.readlines() extension = FileDiff.get_extension(i) lines = MetricsLogic.get_eloc(file_r, extension) cycc = MetricsLogic.get_cyclomatic_complexity(file_r, extension) - if __metric_eloc__.get(extension, None) != None and __metric_eloc__[extension] < lines: + if __metric_eloc__.get(extension, None) is not None and __metric_eloc__[extension] < lines: self.eloc[i.strip()] = lines if METRIC_CYCLOMATIC_COMPLEXITY_THRESHOLD < cycc: @@ -79,7 +113,7 @@ def __iadd__(self, other): self.cyclomatic_complexity_density.update(other.cyclomatic_complexity_density) return self except AttributeError: - return other; + return other @staticmethod def get_cyclomatic_complexity(file_r, extension): diff --git a/gitinspector/optval.py b/gitinspector/optval.py index 38f0e7c1..7b934ecd 100644 --- a/gitinspector/optval.py +++ b/gitinspector/optval.py @@ -17,14 +17,16 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import unicode_literals + import getopt + class InvalidOptionArgument(Exception): def __init__(self, msg): super(InvalidOptionArgument, self).__init__(msg) self.msg = msg + def __find_arg_in_options__(arg, options): for opt in options: if opt[0].find(arg) == 0: @@ -32,6 +34,7 @@ def __find_arg_in_options__(arg, options): return None + def __find_options_to_extend__(long_options): options_to_extend = [] @@ -43,8 +46,10 @@ def __find_options_to_extend__(long_options): return options_to_extend + # This is a duplicate of gnu_getopt, but with support for optional arguments in long options, in the form; "arg:default_value". + def gnu_getopt(args, options, long_options): options_to_extend = __find_options_to_extend__(long_options) @@ -55,12 +60,13 @@ def gnu_getopt(args, options, long_options): return getopt.gnu_getopt(args, options, long_options) + def get_boolean_argument(arg): if isinstance(arg, bool): return arg - elif arg == None or arg.lower() == "false" or arg.lower() == "f" or arg == "0": + if arg is None or arg.lower() == "false" or arg.lower() == "f" or arg == "0": return False - elif arg.lower() == "true" or arg.lower() == "t" or arg == "1": + if arg.lower() == "true" or arg.lower() == "t" or arg == "1": return True raise InvalidOptionArgument(_("The given option argument is not a valid boolean.")) diff --git a/gitinspector/output/blameoutput.py b/gitinspector/output/blameoutput.py index f49ee63b..fb035571 100644 --- a/gitinspector/output/blameoutput.py +++ b/gitinspector/output/blameoutput.py @@ -17,8 +17,7 @@ # You should have received a copy of the GNU General Public License # along with gitinspector. If not, see . -from __future__ import print_function -from __future__ import unicode_literals + import json import sys import textwrap @@ -27,8 +26,10 @@ from ..blame import Blame from .outputable import Outputable -BLAME_INFO_TEXT = N_("Below are the number of rows from each author that have survived and are still " - "intact in the current revision") +BLAME_INFO_TEXT = N_( + "Below are the number of rows from each author that have survived and are still " "intact in the current revision" +) + class BlameOutput(Outputable): def __init__(self, changes, blame): @@ -40,10 +41,11 @@ def __init__(self, changes, blame): Outputable.__init__(self) def output_html(self): - blame_xml = "
" - blame_xml += "

" + _(BLAME_INFO_TEXT) + ".

" + blame_xml = '
' + blame_xml += "

" + _(BLAME_INFO_TEXT) + '.

' blame_xml += "".format( - _("Author"), _("Rows"), _("Stability"), _("Age"), _("% in comments")) + _("Author"), _("Rows"), _("Stability"), _("Age"), _("% in comments") + ) blame_xml += "" chart_data = "" blames = sorted(self.blame.get_summed_blames().items()) @@ -54,11 +56,11 @@ def output_html(self): for i, entry in enumerate(blames): work_percentage = str("{0:.2f}".format(100.0 * entry[1].rows / total_blames)) - blame_xml += "" if i % 2 == 1 else ">") + blame_xml += "' if i % 2 == 1 else ">") if format.get_selected() == "html": author_email = self.changes.get_latest_email_by_author(entry[0]) - blame_xml += "".format(gravatar.get_url(author_email), entry[0]) + blame_xml += ''.format(gravatar.get_url(author_email), entry[0]) else: blame_xml += "" @@ -66,24 +68,24 @@ def output_html(self): blame_xml += "") blame_xml += "" blame_xml += "" - blame_xml += "" + blame_xml += '" blame_xml += "" chart_data += "{{label: {0}, data: {1}}}".format(json.dumps(entry[0]), work_percentage) if blames[-1] != entry: chart_data += ", " - blame_xml += "
{0} {1} {2} {3} {4}
{1}{1}" + entry[0] + "" + ("{0:.1f}".format(Blame.get_stability(entry[0], entry[1].rows, self.changes)) + "" + "{0:.1f}".format(float(entry[1].skew) / entry[1].rows) + "" + "{0:.2f}".format(100.0 * entry[1].comments / entry[1].rows) + "" + work_percentage + "' + work_percentage + "
 
" - blame_xml += "
" - blame_xml += "