├── mkp ├── cli │ ├── __init__.py │ ├── extract.py │ └── init.py ├── __init__.py └── _version.py ├── .gitattributes ├── .python-version ├── MANIFEST.in ├── test ├── test_original.mkp ├── test_original_with_info_json.mkp ├── conftest.py ├── test_original_mkp_files.py ├── test_cli_init.py ├── test_cli_extract.py └── test_mkp.py ├── scripts ├── test ├── bootstrap └── scan-exchange-for-known-directories ├── tox.ini ├── setup.cfg ├── Pipfile ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── setup.py ├── README.md ├── LICENSE ├── Pipfile.lock └── versioneer.py /mkp/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | mkp/_version.py export-subst 2 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.8 2 | 3.9 3 | 3.10 4 | 3.11 5 | 3.12 6 | 3.13 7 | 3.14 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include versioneer.py 3 | include mkp/_version.py 4 | -------------------------------------------------------------------------------- /test/test_original.mkp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tom-mi/python-mkp/HEAD/test/test_original.mkp -------------------------------------------------------------------------------- /test/test_original_with_info_json.mkp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tom-mi/python-mkp/HEAD/test/test_original_with_info_json.mkp -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | cd $(dirname "$BASH_SOURCE")/.. 6 | 7 | source .venv/bin/activate 8 | 9 | tox -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38, py39, py310, py311, py312, py313, py314 3 | 4 | [testenv] 5 | deps = 6 | pytest 7 | commands= 8 | pytest 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [versioneer] 5 | VCS = git 6 | style = pep440 7 | versionfile_source = mkp/_version.py 8 | versionfile_build = mkp/_version.py 9 | tag_prefix = 10 | parentdir_prefix = python- 11 | -------------------------------------------------------------------------------- /scripts/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | cd $(dirname "$BASH_SOURCE")/.. 6 | 7 | # for some reason combining both options does not seem to work 8 | PIPENV_VENV_IN_PROJECT=1 pipenv sync --dev 9 | PIPENV_VENV_IN_PROJECT=1 pipenv sync --categories=dev-local -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | pytest = "*" 8 | wheel = "*" 9 | setuptools = "*" 10 | 11 | [dev-local] 12 | tox = "*" 13 | versioneer = "*" 14 | requests = "*" 15 | 16 | [packages] 17 | mkp = {editable = true, path = "."} 18 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | TEST_DIR = os.path.dirname(__file__) 6 | 7 | 8 | @pytest.fixture() 9 | def original_mkp_file(): 10 | return _test_file('test_original.mkp') 11 | 12 | 13 | @pytest.fixture() 14 | def original_mkp_file_with_info_json(): 15 | return _test_file('test_original_with_info_json.mkp') 16 | 17 | 18 | def _test_file(filename): 19 | path = os.path.join(TEST_DIR, filename) 20 | with open(path, 'rb') as f: 21 | return f.read() 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python: 12 | - '3.8' 13 | - '3.9' 14 | - '3.10' 15 | - '3.11' 16 | - '3.12' 17 | - '3.13' 18 | - '3.14' 19 | 20 | steps: 21 | - uses: actions/checkout@v5 22 | - name: Setup Python 23 | uses: actions/setup-python@v6 24 | with: 25 | python-version: ${{ matrix.python }} 26 | - name: Install Tox and any other packages 27 | run: pip install tox 28 | - name: Run Tox 29 | # Run tox using the version of Python in `PATH` 30 | run: tox -e py 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+" 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | environment: release 12 | permissions: 13 | # IMPORTANT: this permission is mandatory for Trusted Publishing 14 | id-token: write 15 | steps: 16 | - uses: actions/checkout@v5 17 | with: 18 | fetch-depth: 0 19 | - name: Set up Python 20 | uses: actions/setup-python@v6 21 | with: 22 | python-version: 3.13 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install pipenv 27 | pipenv install --dev --deploy 28 | - name: Build 29 | run: | 30 | pipenv run pytest 31 | pipenv run python setup.py bdist_wheel 32 | - name: Publish package distributions to PyPI 33 | uses: pypa/gh-action-pypi-publish@release/v1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | 59 | # Vim 60 | *.swp 61 | 62 | .idea 63 | 64 | .venv 65 | -------------------------------------------------------------------------------- /test/test_original_mkp_files.py: -------------------------------------------------------------------------------- 1 | import mkp 2 | 3 | 4 | def test_load_bytes(original_mkp_file): 5 | package = mkp.load_bytes(original_mkp_file) 6 | 7 | assert type(package) == mkp.Package 8 | assert package.info['title'] == 'Title of test' 9 | 10 | 11 | def test_load_file(original_mkp_file, tmpdir): 12 | tmpdir.join('test.mkp').write_binary(original_mkp_file) 13 | 14 | package = mkp.load_file(str(tmpdir.join('test.mkp'))) 15 | 16 | assert type(package) == mkp.Package 17 | assert package.info['title'] == 'Title of test' 18 | assert package.json_info is None 19 | 20 | 21 | def test_extract_files(original_mkp_file, tmpdir): 22 | package = mkp.load_bytes(original_mkp_file) 23 | 24 | package.extract_files(str(tmpdir)) 25 | 26 | assert tmpdir.join('agents', 'special', 'agent_test').exists() 27 | assert tmpdir.join('checkman', 'test').exists() 28 | assert tmpdir.join('checkman', 'test').open().read() == 'title: Hello World!\n' 29 | 30 | 31 | def test_load_bytes_with_info_json(original_mkp_file_with_info_json): 32 | package = mkp.load_bytes(original_mkp_file_with_info_json) 33 | 34 | assert type(package) == mkp.Package 35 | assert package.info['title'] == 'Title of test' 36 | assert package.json_info['title'] == 'Title of test' 37 | -------------------------------------------------------------------------------- /test/test_cli_init.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | import pytest 5 | from mkp import DIRECTORIES 6 | 7 | 8 | @pytest.mark.smoke 9 | def test_mkp_init_creates_skeleton_and_mkp(tmp_path): 10 | # Run mkp-init in the temp directory 11 | result = subprocess.run([ 12 | sys.executable, '-m', 'mkp.cli.init', '--name', 'testpkg', '--author', 'Tester', '--version', '1.2.3', 13 | '--description', 'desc', '--title', 'TestPkg', '--download_url', 'http://example.com/', '--min_required', 14 | '1.0.0', '--ignore-non-empty' 15 | ], cwd=tmp_path, capture_output=True, text=True) 16 | assert result.returncode == 0, f"mkp-init failed: {result.stderr}" 17 | # Check all directories are created 18 | for d in DIRECTORIES: 19 | assert (tmp_path / d).is_dir(), f"Directory {d} not created" 20 | # Check dist.py is created 21 | dist_py = tmp_path / 'dist.py' 22 | assert dist_py.is_file(), "dist.py not created" 23 | # Run dist.py to create mkp package 24 | result = subprocess.run([ 25 | str(dist_py) 26 | ], cwd=tmp_path, capture_output=True, text=True) 27 | assert result.returncode == 0, f"dist.py failed: {result.stderr}" 28 | # Check dist/ directory and .mkp file 29 | dist_dir = tmp_path / 'dist' 30 | assert dist_dir.is_dir(), "dist directory not created" 31 | mkp_files = list(dist_dir.glob('*.mkp')) 32 | assert mkp_files, "No mkp package created by dist.py" 33 | -------------------------------------------------------------------------------- /test/test_cli_extract.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import os 3 | 4 | TEST_MKP = os.path.join(os.path.dirname(__file__), "test_original.mkp") 5 | EXTRACT_SCRIPT = os.path.join(os.path.dirname(__file__), "../mkp/cli/extract.py") 6 | 7 | 8 | def test_cli_extract_smoke(tmpdir): 9 | output_dir = str(tmpdir) 10 | result = subprocess.run([ 11 | "python3", EXTRACT_SCRIPT, TEST_MKP, "-o", output_dir 12 | ], capture_output=True, text=True) 13 | assert result.returncode == 0, f"CLI failed: {result.stderr}" 14 | # Check output dir for extracted files 15 | subdirs = os.listdir(output_dir) 16 | assert subdirs, "No output directory created" 17 | extract_path = os.path.join(output_dir, subdirs[0]) 18 | assert os.path.isdir(extract_path), "Extracted directory missing" 19 | # Check info files 20 | assert os.path.isfile(os.path.join(extract_path, "info")), "info file missing" 21 | assert os.path.isfile(os.path.join(extract_path, "info.json")), "info.json file missing" 22 | # Check agents/special/agent_test exists and is a file 23 | agent_test_path = os.path.join(extract_path, "agents", "special", "agent_test") 24 | assert os.path.isfile(agent_test_path), "agents/special/agent_test missing" 25 | # Check checkman/test exists and contains expected content 26 | checkman_test_path = os.path.join(extract_path, "checkman", "test") 27 | assert os.path.isfile(checkman_test_path), "checkman/test missing" 28 | with open(checkman_test_path) as f: 29 | content = f.read() 30 | assert "Hello World!" in content, "checkman/test content mismatch" 31 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | import versioneer 3 | 4 | 5 | setup( 6 | name='mkp', 7 | version=versioneer.get_version(), 8 | url='https://github.com/tom-mi/python-mkp/', 9 | license='GPLv2', 10 | author='Thomas Reifenberger', 11 | install_requires=[], 12 | author_email='tom-mi@users.noreply.github.com', 13 | description='Pack and unpack Check_MK mkp files', 14 | long_description=open('README.md', 'r').read(), 15 | long_description_content_type='text/markdown', 16 | packages=find_packages(), 17 | platforms='any', 18 | classifiers=[ 19 | 'License :: OSI Approved :: GNU General Public License v2 (GPLv2)', 20 | 'Programming Language :: Python', 21 | 'Programming Language :: Python :: 3', 22 | 'Programming Language :: Python :: 3.6', 23 | 'Programming Language :: Python :: 3.7', 24 | 'Programming Language :: Python :: 3.8', 25 | 'Programming Language :: Python :: 3.9', 26 | 'Programming Language :: Python :: 3.10', 27 | 'Programming Language :: Python :: 3.11', 28 | 'Programming Language :: Python :: 3.12', 29 | 'Programming Language :: Python :: 3.13', 30 | 'Programming Language :: Python :: 3.14', 31 | 'Development Status :: 4 - Beta', 32 | 'Intended Audience :: Developers', 33 | 'Topic :: System :: Monitoring', 34 | ], 35 | cmdclass=versioneer.get_cmdclass(), 36 | entry_points={ 37 | 'console_scripts': [ 38 | 'mkp-extract=mkp.cli.extract:main', 39 | 'mkp-init=mkp.cli.init:main', 40 | ], 41 | }, 42 | ) 43 | -------------------------------------------------------------------------------- /mkp/cli/extract.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | from mkp import load_file 4 | 5 | 6 | def main(): 7 | args = _parse_args() 8 | package = load_file(args.mkp_file) 9 | extract_path = _get_extract_path(args.output_dir, args.no_prefix, package) 10 | os.makedirs(extract_path, exist_ok=True) 11 | package.extract_files(extract_path) 12 | _write_info_files(package, extract_path) 13 | 14 | 15 | def _parse_args(): 16 | parser = argparse.ArgumentParser(description='Extract a Check_MK mkp package. ' 17 | 'By default, a directory with the package name and version is created in the output ' 18 | 'directory.') 19 | parser.add_argument('mkp_file', help='Path to the mkp package file.') 20 | parser.add_argument('-o', '--output-dir', default='.', 21 | help='Output directory (default: current directory)') 22 | parser.add_argument('--no-prefix', action='store_true', 23 | help='Do not create a package prefix directory') 24 | return parser.parse_args() 25 | 26 | 27 | def _get_extract_path(output_dir, no_prefix, package): 28 | if no_prefix: 29 | return output_dir 30 | name = package.info.get('name', 'package') 31 | version = package.info.get('version', 'unknown') 32 | dir_name = f"{name}-{version}" 33 | return os.path.join(output_dir, dir_name) 34 | 35 | 36 | def _write_info_files(package, extract_path): 37 | info_path = os.path.join(extract_path, "info") 38 | info_json_path = os.path.join(extract_path, "info.json") 39 | with open(info_path, "w") as f: 40 | import pprint 41 | f.write(pprint.pformat(package.info)) 42 | if package.json_info is not None: 43 | import json 44 | with open(info_json_path, "w") as f: 45 | json.dump(package.json_info, f, indent=2) 46 | else: 47 | with open(info_json_path, "w") as f: 48 | f.write("{}\n") 49 | 50 | 51 | if __name__ == "__main__": 52 | main() 53 | -------------------------------------------------------------------------------- /mkp/cli/init.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import textwrap 4 | from mkp import DIRECTORIES 5 | 6 | 7 | def main(): 8 | args = _parse_args() 9 | cwd = os.getcwd() 10 | if not args.ignore_non_empty: 11 | _ensure_empty_directory(cwd) 12 | 13 | for directory in DIRECTORIES: 14 | os.makedirs(os.path.join(cwd, directory), exist_ok=True) 15 | dist_py_path = os.path.join(cwd, 'dist.py') 16 | with open(dist_py_path, 'w') as f: 17 | f.write(_dist_py_template(args)) 18 | os.chmod(dist_py_path, 0o755) 19 | 20 | 21 | def _parse_args(): 22 | parser = argparse.ArgumentParser(description='Create a Check_MK mkp project skeleton in the current directory.') 23 | parser.add_argument('--name', default='example', help='Package name') 24 | parser.add_argument('--author', default='John Doe', help='Author name') 25 | parser.add_argument('--version', default='0.1.0', help='Package version') 26 | parser.add_argument('--description', default='Example package', help='Package description') 27 | parser.add_argument('--title', default='Example', help='Package title') 28 | parser.add_argument('--download_url', default='http://example.com/', help='Download URL') 29 | parser.add_argument('--min_required', default='1.2.3', help='Minimum required version') 30 | parser.add_argument('--ignore-non-empty', action='store_true', help='Allow running in non-empty directories') 31 | return parser.parse_args() 32 | 33 | 34 | def _dist_py_template(args): 35 | return textwrap.dedent(f""" 36 | #!/usr/bin/env python 37 | 38 | from mkp import dist 39 | 40 | dist({{ 41 | "author": "{args.author}", 42 | "description": "{args.description}", 43 | "download_url": "{args.download_url}", 44 | "name": "{args.name}", 45 | "title": "{args.title}", 46 | "version": "{args.version}", 47 | "version.min_required": "{args.min_required}", 48 | }}) 49 | """).lstrip() 50 | 51 | 52 | def _ensure_empty_directory(path): 53 | entries = [e for e in os.listdir(path) if not e.startswith('.')] 54 | if entries: 55 | print("Error: The current directory is not empty. Use --ignore-non-empty to override.") 56 | exit(1) 57 | 58 | 59 | if __name__ == "__main__": 60 | main() 61 | -------------------------------------------------------------------------------- /scripts/scan-exchange-for-known-directories: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import requests 4 | import tempfile 5 | import os 6 | from collections import Counter 7 | from mkp import Package 8 | 9 | API_BASE = "https://exchange.checkmk.com" 10 | 11 | 12 | # Fetch package metadata from the correct API structure 13 | def fetch_packages_metadata(n): 14 | url = f"{API_BASE}/api/package/search" 15 | per_page = 10 # as seen in API response 16 | pages = (n + per_page - 1) // per_page 17 | packages = [] 18 | for page in range(1, pages + 1): 19 | params = { 20 | "sort": "date", 21 | "direction": "desc", 22 | "page": page, 23 | "search": "" 24 | } 25 | resp = requests.get(url, params=params) 26 | resp.raise_for_status() 27 | data = resp.json() 28 | page_packages = data.get("data", {}).get("packages", {}).get("data", []) 29 | packages.extend(page_packages) 30 | if len(packages) >= n: 31 | break 32 | return packages[:n] 33 | 34 | 35 | # Download MKP file from full URL 36 | def download_package(url): 37 | if url.startswith("/"): 38 | url = API_BASE + url 39 | resp = requests.get(url, stream=True) 40 | resp.raise_for_status() 41 | tmp = tempfile.NamedTemporaryFile(delete=False) 42 | for chunk in resp.iter_content(chunk_size=8192): 43 | tmp.write(chunk) 44 | tmp.close() 45 | return tmp.name 46 | 47 | 48 | # Main CLI logic 49 | def main(): 50 | parser = argparse.ArgumentParser( 51 | description="Crawl Checkmk Exchange and count top-level directories in MKP packages.") 52 | parser.add_argument("-n", type=int, default=20, help="Number of packages to process (default: 20)") 53 | args = parser.parse_args() 54 | 55 | print(f"Fetching metadata for {args.n} packages...") 56 | packages = fetch_packages_metadata(args.n) 57 | counter = Counter() 58 | total = 0 59 | for pkg in packages: 60 | # Get download URL from latest version 61 | versions = pkg.get("versions", {}) 62 | latest = versions.get("latest", {}) 63 | download_url = latest.get("download_url") 64 | if not download_url: 65 | print(f'No download URL found for package {pkg.get("name")}, skipping.') 66 | continue 67 | path = None # Ensure path is always defined 68 | try: 69 | path = download_package(download_url) 70 | with open(path, "rb") as f: 71 | package = Package(f) 72 | dirs = package.info.get("files", {}).keys() 73 | counter.update(dirs) 74 | total += 1 75 | except Exception as e: 76 | print(f"Error processing package: {e}") 77 | finally: 78 | if path and os.path.exists(path): 79 | os.remove(path) 80 | print(f"\nProcessed {total} packages.") 81 | print("Top-level directory counts:") 82 | for key, count in counter.most_common(): 83 | print(f" {key}: {count}") 84 | 85 | 86 | if __name__ == "__main__": 87 | main() 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-mkp 2 | 3 | ![ci](https://github.com/tom-mi/python-mkp/workflows/ci/badge.svg) 4 | ![release](https://github.com/tom-mi/python-mkp/workflows/release/badge.svg) 5 | [![PyPI version](https://badge.fury.io/py/mkp.svg)](https://badge.fury.io/py/mkp) 6 | 7 | Pack or unpack [Check_MK](https://mathias-kettner.de/check_mk.html) mkp files. 8 | 9 | The purpose of this library is to generate mkp files from source without having to set up a complete Check\_MK instance. 10 | It is not intended for installing mkp files to a Check\_MK site. 11 | 12 | ## Installation 13 | 14 | ```sh 15 | pip install mkp 16 | ``` 17 | 18 | ## Usage 19 | 20 | ### Automatically pack mkp package 21 | 22 | Run `mkp-init` in an empty directory. 23 | 24 | This will create an executable script `dist.py`: 25 | 26 | ```python 27 | #!/usr/bin/env python 28 | 29 | from mkp import dist 30 | 31 | dist({ 32 | 'author': 'John Doe', 33 | 'description': 'Test the automatic creation of packages', 34 | 'download_url': 'http://example.com/', 35 | 'name': 'test', 36 | 'title': 'Test', 37 | 'version': '1.0', 38 | 'version.min_required': '1.2.3', 39 | }) 40 | ``` 41 | 42 | and a directory structure similar to this: 43 | 44 | ```text 45 | ├── agents/ 46 | ├── agent_based/ 47 | ├── checkman/ 48 | ├── checks/ 49 | ├── doc/ 50 | ├── inventory/ 51 | ├── lib/ 52 | ├── notifications/ 53 | ├── pnp-templates/ 54 | ├── web/ 55 | └── dist.py 56 | ``` 57 | 58 | Now add your files to the respective directories and edit the metadata in 59 | `dist.py` as needed. Empty directories can be deleted. 60 | 61 | Running `./dist.py` will pack all files in the directories listed above to a mkp package with the canonical name and the 62 | specified metadata. The mkp file will be written to the `dist` directory. 63 | 64 | ### Extract mkp package using mkp-extract cli tool 65 | 66 | ```sh 67 | mkp-extract --help 68 | mkp-extract foo-1.0.mkp 69 | mkp-extract foo-1.0.mkp --output-dir bar --no-prefix 70 | ``` 71 | 72 | ### Advanced usage 73 | 74 | #### Extract mkp package programmatically 75 | 76 | ```python 77 | import mkp 78 | 79 | package = mkp.load_file('foo-1.0.mkp') 80 | print(package.info) 81 | package.extract_files('path/to/somewhere') 82 | ``` 83 | 84 | #### Pack files to mkp package 85 | 86 | In contrast to `dist`, this provides the possibility to manually select the 87 | files by replacing `find_files`. It is also possible to choose a different 88 | output filename. 89 | 90 | ```python 91 | import mkp 92 | 93 | info = { 94 | 'author': 'tom-mi', 95 | 'description': 'Test the system', 96 | 'download_url': 'http://example.com/', 97 | 'files': mkp.find_files('path/to/files'), 98 | 'name': 'test', 99 | 'title': 'Test', 100 | 'version': '1.0', 101 | 'version.min_required': '1.2.3', 102 | } 103 | mkp.pack_to_file(info, 'path/to/files', 'test-1.0.mkp') 104 | ``` 105 | 106 | #### Exclude files when packing using [regular expressions](https://docs.python.org/3/library/re.html): 107 | 108 | ```python 109 | from mkp import dist 110 | 111 | dist({ 112 | # ... 113 | }, exclude_patterns=[r'.*\.pyc$', '__pycache__']) 114 | ``` 115 | 116 | or 117 | 118 | ```python 119 | import mkp 120 | 121 | files = mkp.find_files('path/to/files', exclude_patterns=[r'.*\.pyc$', '__pycache__']) 122 | ``` 123 | 124 | #### Include all subdirectories instead of just the "known" ones: 125 | 126 | ```python 127 | from mkp import dist, INCLUDE_ALL 128 | 129 | dist({ 130 | # ... 131 | }, directories=INCLUDE_ALL) 132 | ``` 133 | 134 | or 135 | 136 | ```python 137 | import mkp 138 | 139 | files = mkp.find_files('path/to/files', directories=mkp.INCLUDE_ALL) 140 | ``` 141 | 142 | #### Include only specific subdirectories: 143 | 144 | ```python 145 | from mkp import dist 146 | 147 | dist({ 148 | # ... 149 | }, directories=['checks', 'agents']) 150 | ``` 151 | 152 | or 153 | 154 | ```python 155 | import mkp 156 | 157 | files = mkp.find_files('path/to/files', directories=['checks', 'agents']) 158 | ``` 159 | 160 | ## Development Setup 161 | 162 | Install development dependencies into local environment (`${repo_root}/.venv`): 163 | 164 | ```sh 165 | scripts/bootstrap 166 | ``` 167 | 168 | Run all tests with tox: 169 | 170 | ```sh 171 | scripts/test 172 | # or 173 | source .venv/bin/activate 174 | tox 175 | ``` 176 | 177 | Run tests of current python version with pytest: 178 | 179 | ```sh 180 | source .venv/bin/activate 181 | pytest 182 | ``` 183 | 184 | Release new version: 185 | 186 | ```sh 187 | git tag 188 | git push --tags 189 | ``` 190 | 191 | ## License 192 | 193 | This software is licensed under GPLv2. 194 | -------------------------------------------------------------------------------- /mkp/__init__.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import io 3 | import json 4 | import os 5 | import os.path 6 | import pprint 7 | import tarfile 8 | import re 9 | from typing import List, Tuple, Dict, Any, Union 10 | 11 | from ._version import get_versions 12 | 13 | __version__ = get_versions()['version'] 14 | del get_versions 15 | 16 | DIRECTORIES = ( 17 | 'agent_based', 18 | 'agents', 19 | 'alert_handlers', 20 | 'bin', 21 | 'checkman', 22 | 'checks', 23 | 'cmk_addons_plugins', 24 | 'cmk_plugins', 25 | 'doc', 26 | 'gui', 27 | 'inventory', 28 | 'lib', 29 | 'locales', 30 | 'mibs', 31 | 'notifications', 32 | 'pnp-rraconf', 33 | 'pnp-templates', 34 | 'web', 35 | ) 36 | 37 | 38 | class IncludeAll: 39 | """Marker to pass to find_files or dist to include all subdirectories.""" 40 | 41 | 42 | INCLUDE_ALL = IncludeAll() 43 | 44 | _VERSION_PACKAGED = 'python-mkp' 45 | _DIST_DIR = 'dist' 46 | 47 | 48 | def dist(info: Dict[str, Any], 49 | path: str = None, 50 | directories: Union[List[str], IncludeAll] = DIRECTORIES, 51 | exclude_patterns: List[str] = None): 52 | if exclude_patterns is None: 53 | exclude_patterns = [] 54 | 55 | if not path: 56 | import __main__ as main 57 | path = os.path.dirname(os.path.realpath(main.__file__)) 58 | 59 | info['files'] = find_files(path, directories=directories, exclude_patterns=exclude_patterns) 60 | info['num_files'] = sum(len(file_list) for file_list in info['files'].values()) 61 | dist_dir = os.path.join(path, _DIST_DIR) 62 | filename = '{}-{}.mkp'.format(info['name'], info['version']) 63 | 64 | if not os.path.exists(dist_dir): 65 | os.makedirs(dist_dir) 66 | 67 | pack_to_file(info, path, os.path.join(dist_dir, filename)) 68 | 69 | 70 | def find_files(path: str, directories: List[str] = DIRECTORIES, exclude_patterns: List[str] = None): 71 | if exclude_patterns is None: 72 | exclude_patterns = [] 73 | result = {} 74 | 75 | if isinstance(directories, IncludeAll): 76 | directories = [d for d in os.listdir(path) if os.path.isdir(os.path.join(path, d)) and not d.startswith('.') 77 | and not d == _DIST_DIR] 78 | elif _DIST_DIR in directories: 79 | raise ValueError('Directory list cannot include "dist"') 80 | 81 | for directory in directories: 82 | assert directory != _DIST_DIR, "The dist directory cannot be included in the package files." 83 | files = _find_files_in_directory(os.path.join(path, directory), exclude_patterns=exclude_patterns) 84 | if files: 85 | result[directory] = files 86 | 87 | return result 88 | 89 | 90 | def _find_files_in_directory(path: str, exclude_patterns: List[str]): 91 | result = [] 92 | for root, dirs, files in os.walk(path): 93 | for dirname in dirs: 94 | if dirname.startswith('.'): 95 | dirs.remove(dirname) 96 | for filename in files: 97 | if filename.startswith('.'): 98 | continue 99 | elif filename.endswith('~'): 100 | continue 101 | abspath = os.path.join(root, filename) 102 | relpath = os.path.relpath(abspath, start=path) 103 | if any(re.search(pattern, abspath) for pattern in exclude_patterns): 104 | continue 105 | result.append(relpath) 106 | return result 107 | 108 | 109 | def pack_to_file(info: Dict[str, Any], path: str, outfile: str) -> None: 110 | with open(outfile, 'wb') as f: 111 | f.write(pack_to_bytes(info, path)) 112 | 113 | 114 | def pack_to_bytes(info: Dict[str, Any], path: str) -> bytes: 115 | _patch_info(info) 116 | bytes_io = io.BytesIO() 117 | with tarfile.open(fileobj=bytes_io, mode='w:gz') as archive: 118 | _add_to_archive(archive, 'info', encode_info(info)) 119 | _add_to_archive(archive, 'info.json', encode_info_json(info)) 120 | 121 | for directory in info['files'].keys(): 122 | files = info['files'].get(directory, []) 123 | if not files: 124 | continue 125 | 126 | directory_archive = _create_directory_archive(os.path.join(path, directory), files) 127 | _add_to_archive(archive, directory + '.tar', directory_archive) 128 | 129 | return bytes_io.getvalue() 130 | 131 | 132 | def _patch_info(info: Dict[str, Any]) -> None: 133 | info['version.packaged'] = _VERSION_PACKAGED 134 | 135 | 136 | def _create_directory_archive(path: str, files: List[str]) -> bytes: 137 | bytes_io = io.BytesIO() 138 | with tarfile.open(fileobj=bytes_io, mode='w') as archive: 139 | for filename in files: 140 | archive.add(os.path.join(path, filename), arcname=filename) 141 | 142 | return bytes_io.getvalue() 143 | 144 | 145 | def _add_to_archive(archive: tarfile.TarFile, filename: str, data: bytes) -> None: 146 | tarinfo, file_object = _create_tarinfo_and_buffer(data, filename) 147 | archive.addfile(tarinfo, fileobj=file_object) 148 | 149 | 150 | def _create_tarinfo_and_buffer(data: bytes, filename: str) -> Tuple[tarfile.TarInfo, io.BytesIO]: 151 | tarinfo = tarfile.TarInfo(filename) 152 | tarinfo.size = len(data) 153 | bytes_io = io.BytesIO(data) 154 | return tarinfo, bytes_io 155 | 156 | 157 | def encode_info(info: Dict[str, Any]) -> bytes: 158 | return pprint.pformat(info).encode() 159 | 160 | 161 | def encode_info_json(info) -> bytes: 162 | return json.dumps(info).encode() 163 | 164 | 165 | def decode_info(info_bytes: bytes) -> Dict[str, Any]: 166 | return ast.literal_eval(info_bytes.decode()) 167 | 168 | 169 | class Package(object): 170 | 171 | def __init__(self, fileobj): 172 | self.archive = tarfile.open(fileobj=fileobj) 173 | self._info = self._get_info() 174 | self._json_info = self._get_json_info() 175 | 176 | def _get_info(self): 177 | info_file = self.archive.extractfile('info') 178 | return decode_info(info_file.read()) 179 | 180 | def _get_json_info(self): 181 | try: 182 | info_file = self.archive.extractfile('info.json') 183 | return json.loads(info_file.read()) 184 | except KeyError: 185 | return None 186 | 187 | @property 188 | def info(self): 189 | return self._info 190 | 191 | @property 192 | def json_info(self) -> Dict[str, Any]: 193 | return self._json_info 194 | 195 | def extract_files(self, path: str): 196 | for directory in self.info['files'].keys(): 197 | self._extract_files_in_directory(path, directory) 198 | 199 | def _extract_files_in_directory(self, path: str, directory: str): 200 | files = self.info['files'].get(directory, []) 201 | 202 | if not files: 203 | return 204 | 205 | target_path = os.path.join(path, directory) 206 | os.makedirs(target_path) 207 | dir_archive_file = self.archive.extractfile(directory + '.tar') 208 | 209 | with tarfile.open(fileobj=dir_archive_file) as archive: 210 | members = [member for member in archive.getmembers() if member.name in files] 211 | archive.extractall(path=target_path, members=members) 212 | 213 | 214 | def load_file(path: str) -> Package: 215 | file_io = open(path, 'rb') 216 | return Package(file_io) 217 | 218 | 219 | def load_bytes(data: bytes) -> Package: 220 | bytes_io = io.BytesIO(data) 221 | return Package(bytes_io) 222 | -------------------------------------------------------------------------------- /test/test_mkp.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import io 3 | import re 4 | import tarfile 5 | 6 | import pytest 7 | 8 | import mkp 9 | 10 | DIRECTORIES = [ 11 | 'agent_based', 12 | 'agents', 13 | 'alert_handlers', 14 | 'bin', 15 | 'checkman', 16 | 'checks', 17 | 'cmk_addons_plugins', 18 | 'cmk_plugins', 19 | 'doc', 20 | 'gui', 21 | 'inventory', 22 | 'lib', 23 | 'locales', 24 | 'mibs', 25 | 'notifications', 26 | 'pnp-rraconf', 27 | 'pnp-templates', 28 | 'web', 29 | ] 30 | 31 | 32 | @pytest.fixture 33 | def sample_files(tmpdir): 34 | tmpdir.join('agents', 'special', 'agent_test').write_binary(b'hello', ensure=True) 35 | tmpdir.join('checks', 'foo').write_binary(b'Check Me!', ensure=True) 36 | 37 | 38 | @pytest.fixture 39 | def sample_info(): 40 | return { 41 | 'author': 'John Doe', 42 | 'name': 'foo', 43 | 'version': '42', 44 | 'version.min_required': '1.2.6p5', 45 | 'version.usable_until': None, 46 | } 47 | 48 | 49 | def test_pack_to_bytes(tmpdir): 50 | info = { 51 | 'files': {'agents': ['special/agent_test']}, 52 | 'title': 'Test package', 53 | } 54 | tmpdir.join('agents', 'special', 'agent_test').write_binary(b'hello', ensure=True) 55 | 56 | data = mkp.pack_to_bytes(info, str(tmpdir)) 57 | 58 | bytes_io = io.BytesIO(data) 59 | archive = tarfile.open(fileobj=bytes_io) 60 | 61 | info_file = archive.extractfile('info').read() 62 | extracted_info = ast.literal_eval(info_file.decode()) 63 | assert extracted_info['files'] == info['files'] 64 | assert extracted_info['title'] == info['title'] 65 | assert extracted_info['version.packaged'] == 'python-mkp' 66 | 67 | agents_archive_file = archive.extractfile('agents.tar') 68 | agents_archive = tarfile.open(fileobj=agents_archive_file, mode='r:') 69 | agent_file = agents_archive.extractfile('special/agent_test') 70 | assert agent_file.read() == b'hello' 71 | 72 | 73 | def test_pack_to_file(tmpdir): 74 | info = { 75 | 'files': {'agents': ['special/agent_test']}, 76 | 'title': 'Test package', 77 | } 78 | tmpdir.join('agents', 'special', 'agent_test').write_binary(b'hello', ensure=True) 79 | 80 | outfile = tmpdir.join('test.mkp') 81 | 82 | mkp.pack_to_file(info, str(tmpdir), str(outfile)) 83 | 84 | archive = tarfile.open(str(outfile)) 85 | 86 | info_file = archive.extractfile('info').read() 87 | extracted_info = ast.literal_eval(info_file.decode()) 88 | assert extracted_info['files'] == info['files'] 89 | assert extracted_info['title'] == info['title'] 90 | assert extracted_info['version.packaged'] == 'python-mkp' 91 | 92 | agents_archive_file = archive.extractfile('agents.tar') 93 | agents_archive = tarfile.open(fileobj=agents_archive_file, mode='r:') 94 | agent_file = agents_archive.extractfile('special/agent_test') 95 | assert agent_file.read() == b'hello' 96 | 97 | 98 | def test_find_files_searches_all_directories(tmpdir): 99 | for directory in DIRECTORIES: 100 | tmpdir.join(directory, 'test').write_binary(b'Foo', ensure=True) 101 | 102 | result = mkp.find_files(str(tmpdir)) 103 | for directory in DIRECTORIES: 104 | assert result[directory] == ['test'] 105 | 106 | 107 | def test_find_files_ignores_files_outside_known_directories(tmpdir): 108 | # given 109 | tmpdir.join('unknown_dir', 'test').write_binary(b'Foo', ensure=True) 110 | 111 | # when 112 | result = mkp.find_files(str(tmpdir)) 113 | 114 | # then 115 | assert result == {} 116 | 117 | 118 | def test_find_files_with_custom_directory_list(tmpdir): 119 | # given 120 | tmpdir.join('agent', 'test').write_binary(b'Foo', ensure=True) 121 | tmpdir.join('custom_dir', 'test').write_binary(b'Foo', ensure=True) 122 | tmpdir.join('other_dir', 'test').write_binary(b'Foo', ensure=True) 123 | 124 | # when 125 | result = mkp.find_files(str(tmpdir), directories=['custom_dir']) 126 | 127 | # then 128 | assert result['custom_dir'] == ['test'] 129 | 130 | 131 | def test_find_files_searches_subdirectories(tmpdir): 132 | tmpdir.join('agents', 'special', 'agent_test').write_binary(b'hello', ensure=True) 133 | 134 | result = mkp.find_files(str(tmpdir)) 135 | 136 | assert result['agents'] == ['special/agent_test'] 137 | 138 | 139 | def test_find_files_ignores_hidden_files_and_dirs(tmpdir): 140 | tmpdir.join('agents', '.hidden').write_binary(b'hello', ensure=True) 141 | tmpdir.join('agents', 'test~').write_binary(b'hello', ensure=True) 142 | tmpdir.join('agents', '.hidden_dir', 'visible_file').write_binary(b'hello', ensure=True) 143 | 144 | result = mkp.find_files(str(tmpdir)) 145 | 146 | assert result == {} 147 | 148 | 149 | def test_find_files_skips_the_dist_directory(tmpdir): 150 | # given 151 | tmpdir.join('agents', 'test').write_binary(b'Foo', ensure=True) 152 | tmpdir.join('dist', 'should_not_be_found').write_binary(b'Bar', ensure=True) 153 | 154 | # when 155 | result = mkp.find_files(str(tmpdir)) 156 | 157 | # then 158 | assert result['agents'] == ['test'] 159 | assert 'dist' not in result 160 | 161 | 162 | def test_find_files_with_include_all_mode_skips_the_dist_directory(tmpdir): 163 | # given 164 | tmpdir.join('agents', 'test').write_binary(b'Foo', ensure=True) 165 | tmpdir.join('custom_dir', 'test').write_binary(b'Foo', ensure=True) 166 | tmpdir.join('dist', 'should_not_be_found').write_binary(b'Bar', ensure=True) 167 | 168 | # when 169 | result = mkp.find_files(str(tmpdir), directories=mkp.INCLUDE_ALL) 170 | 171 | # then 172 | assert result['agents'] == ['test'] 173 | assert result['custom_dir'] == ['test'] 174 | assert 'dist' not in result 175 | 176 | 177 | def test_find_files_with_custom_directory_list_fails_if_dist_directory_is_included(tmpdir): 178 | # given 179 | tmpdir.join('custom_dir', 'test').write_binary(b'Foo', ensure=True) 180 | tmpdir.join('dist', 'should_not_be_found').write_binary(b'Bar', ensure=True) 181 | 182 | # when & then 183 | with pytest.raises(ValueError, match=r'Directory list cannot include "dist"'): 184 | mkp.find_files(str(tmpdir), directories=['custom_dir', 'dist']) 185 | 186 | 187 | def test_find_files_omits_files_matching_an_exclude_pattern(tmpdir): 188 | # given 189 | a = re.compile(r'.*\.pyc') 190 | exclude_patterns = [r'.*file_to_exclude$', r'dir-to-omit/.*'] 191 | tmpdir.join('agents', 'file_to_include').write_binary(b'hello', ensure=True) 192 | tmpdir.join('agents', 'file_to_exclude').write_binary(b'hello', ensure=True) 193 | tmpdir.join('agents', 'file_to_exclude.not').write_binary(b'hello', ensure=True) 194 | tmpdir.join('agents', 'dir-to-omit', 'file_inside').write_binary(b'hello', ensure=True) 195 | 196 | # when 197 | result = mkp.find_files(str(tmpdir), exclude_patterns=exclude_patterns) 198 | 199 | # then 200 | assert result['agents'] == ['file_to_exclude.not', 'file_to_include'] 201 | 202 | 203 | def test_find_files_includes_all_regular_directories_for_mode_include_all(tmpdir): 204 | # given 205 | tmpdir.join('agents', 'test').write_binary(b'Foo', ensure=True) 206 | tmpdir.join('custom_dir', 'test').write_binary(b'Foo', ensure=True) 207 | tmpdir.join('other_dir', 'test').write_binary(b'Foo', ensure=True) 208 | tmpdir.join('.hidden_dir', 'test').write_binary(b'Foo', ensure=True) 209 | 210 | # when 211 | result = mkp.find_files(str(tmpdir), directories=mkp.INCLUDE_ALL) 212 | 213 | # then 214 | assert result['agents'] == ['test'] 215 | assert result['custom_dir'] == ['test'] 216 | assert result['other_dir'] == ['test'] 217 | assert '.hidden_dir' not in result 218 | 219 | 220 | def test_pack_and_unpack_covers_all_known_directories(tmpdir): 221 | # given 222 | info = { 223 | 'files': {key: ['test'] for key in DIRECTORIES}, 224 | } 225 | source = tmpdir.join('source').mkdir() 226 | dest = tmpdir.join('dest').mkdir() 227 | 228 | for directory in DIRECTORIES: 229 | source.join(directory, 'test').write_binary(b'Foo', ensure=True) 230 | 231 | # when 232 | package_bytes = mkp.pack_to_bytes(info, str(source)) 233 | package = mkp.load_bytes(package_bytes) 234 | package.extract_files(str(dest)) 235 | 236 | # then 237 | for directory in DIRECTORIES: 238 | assert dest.join(directory, 'test').exists() 239 | 240 | 241 | def test_pack_and_unpack_with_covers_custom_directories(tmpdir): 242 | # given 243 | info = { 244 | 'files': {'agents': ['test'], 'custom_dir': ['test']}, 245 | } 246 | source = tmpdir.join('source').mkdir() 247 | dest = tmpdir.join('dest').mkdir() 248 | 249 | source.join('agents', 'test').write_binary(b'Foo', ensure=True) 250 | source.join('custom_dir', 'test').write_binary(b'Bar', ensure=True) 251 | 252 | # when 253 | package_bytes = mkp.pack_to_bytes(info, str(source)) 254 | package = mkp.load_bytes(package_bytes) 255 | package.extract_files(str(dest)) 256 | 257 | # then 258 | assert dest.join('agents', 'test').exists() 259 | assert dest.join('custom_dir', 'test').exists() 260 | 261 | 262 | def test_dist(tmpdir, sample_files, sample_info): 263 | mkp.dist(sample_info, str(tmpdir)) 264 | 265 | assert tmpdir.join('dist', 'foo-42.mkp').exists() 266 | package = mkp.load_file(str(tmpdir.join('dist', 'foo-42.mkp'))) 267 | assert package.info['author'] == 'John Doe' 268 | assert package.info['name'] == 'foo' 269 | assert package.info['files']['agents'] == ['special/agent_test'] 270 | assert package.info['files']['checks'] == ['foo'] 271 | assert package.info['num_files'] == 2 272 | assert package.info['version'] == '42' 273 | assert package.info['version.packaged'] == 'python-mkp' 274 | assert package.info['version.min_required'] == '1.2.6p5' 275 | assert package.info['version.usable_until'] is None 276 | 277 | 278 | def test_dist_with_exclude_patterns(tmpdir, sample_files, sample_info): 279 | # given 280 | exclude_patterns = [r'special/.*'] 281 | 282 | # when 283 | mkp.dist(sample_info, str(tmpdir), exclude_patterns=exclude_patterns) 284 | 285 | # then 286 | assert tmpdir.join('dist', 'foo-42.mkp').exists() 287 | package = mkp.load_file(str(tmpdir.join('dist', 'foo-42.mkp'))) 288 | assert package.info['author'] == 'John Doe' 289 | assert package.info['files'].keys() == {'checks'} 290 | assert package.info['files']['checks'] == ['foo'] 291 | assert package.info['num_files'] == 1 292 | 293 | 294 | def test_dist_with_include_all(tmpdir, sample_files, sample_info): 295 | # given 296 | tmpdir.join('custom_dir', 'custom_file').write_binary(b'Custom', ensure=True) 297 | 298 | # when 299 | mkp.dist(sample_info, str(tmpdir), directories=mkp.INCLUDE_ALL) 300 | 301 | # then 302 | assert tmpdir.join('dist', 'foo-42.mkp').exists() 303 | package = mkp.load_file(str(tmpdir.join('dist', 'foo-42.mkp'))) 304 | assert package.info['author'] == 'John Doe' 305 | assert package.info['files']['agents'] == ['special/agent_test'] 306 | assert package.info['files']['checks'] == ['foo'] 307 | assert package.info['files']['custom_dir'] == ['custom_file'] 308 | assert package.info['num_files'] == 3 309 | 310 | 311 | def test_dist_with_custom_directories(tmpdir, sample_files, sample_info): 312 | # when 313 | mkp.dist(sample_info, str(tmpdir), directories=['agents', 'custom_dir']) 314 | 315 | # then 316 | assert tmpdir.join('dist', 'foo-42.mkp').exists() 317 | package = mkp.load_file(str(tmpdir.join('dist', 'foo-42.mkp'))) 318 | assert package.info['author'] == 'John Doe' 319 | assert package.info['files']['agents'] == ['special/agent_test'] 320 | assert 'checks' not in package.info['files'] 321 | assert package.info['num_files'] == 1 322 | 323 | 324 | def test_dist_omits_empty_directories(tmpdir, sample_files, sample_info): 325 | # given 326 | tmpdir.join('doc').mkdir() 327 | 328 | # when 329 | mkp.dist(sample_info, str(tmpdir), directories=['agents', 'checks', 'doc']) 330 | 331 | # then 332 | assert tmpdir.join('dist', 'foo-42.mkp').exists() 333 | package = mkp.load_file(str(tmpdir.join('dist', 'foo-42.mkp'))) 334 | assert package.info['author'] == 'John Doe' 335 | assert package.info['files'].keys() == {'agents', 'checks'} 336 | with pytest.raises(KeyError): 337 | package.archive.getmember('doc.tar') 338 | 339 | 340 | def test_dist_json(tmpdir, sample_files, sample_info): 341 | mkp.dist(sample_info, str(tmpdir)) 342 | 343 | assert tmpdir.join('dist', 'foo-42.mkp').exists() 344 | package = mkp.load_file(str(tmpdir.join('dist', 'foo-42.mkp'))) 345 | assert package.json_info['author'] == 'John Doe' 346 | assert package.json_info['name'] == 'foo' 347 | assert package.json_info['files']['agents'] == ['special/agent_test'] 348 | assert package.json_info['files']['checks'] == ['foo'] 349 | assert package.json_info['num_files'] == 2 350 | assert package.json_info['version'] == '42' 351 | assert package.json_info['version.packaged'] == 'python-mkp' 352 | assert package.json_info['version.min_required'] == '1.2.6p5' 353 | assert package.json_info['version.usable_until'] is None 354 | 355 | 356 | def test_dist_uses_script_path_when_no_path_is_given(tmpdir): 357 | script = tmpdir.join('dist.py') 358 | script.write_text(u'''#!/usr/bin/env python 359 | 360 | from mkp import dist 361 | 362 | 363 | dist({ 364 | 'author': 'John Doe', 365 | 'name': 'foo', 366 | 'version': '42', 367 | }) 368 | ''', 'utf-8') 369 | script.chmod(0o700) 370 | tmpdir.join('agents', 'special', 'agent_test').write_binary(b'hello', ensure=True) 371 | tmpdir.join('checks', 'foo').write_binary(b'Check Me!', ensure=True) 372 | 373 | script.sysexec() 374 | 375 | assert tmpdir.join('dist', 'foo-42.mkp').exists() 376 | package = mkp.load_file(str(tmpdir.join('dist', 'foo-42.mkp'))) 377 | assert package.info['author'] == 'John Doe' 378 | assert package.info['name'] == 'foo' 379 | assert package.info['files']['agents'] == ['special/agent_test'] 380 | assert package.info['files']['checks'] == ['foo'] 381 | assert package.info['version'] == '42' 382 | assert package.info['version.packaged'] == 'python-mkp' 383 | assert package.info['num_files'] == 2 384 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | 341 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "3c395114349a57f1b49903fa230979f35acbdbaac6f27b2c457dd31edcae2958" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": {}, 8 | "sources": [ 9 | { 10 | "name": "pypi", 11 | "url": "https://pypi.org/simple", 12 | "verify_ssl": true 13 | } 14 | ] 15 | }, 16 | "default": { 17 | "mkp": { 18 | "editable": true, 19 | "path": "." 20 | } 21 | }, 22 | "dev-local": { 23 | "cachetools": { 24 | "hashes": [ 25 | "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", 26 | "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6" 27 | ], 28 | "markers": "python_version >= '3.9'", 29 | "version": "==6.2.2" 30 | }, 31 | "certifi": { 32 | "hashes": [ 33 | "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", 34 | "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316" 35 | ], 36 | "markers": "python_version >= '3.7'", 37 | "version": "==2025.11.12" 38 | }, 39 | "chardet": { 40 | "hashes": [ 41 | "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", 42 | "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970" 43 | ], 44 | "markers": "python_version >= '3.7'", 45 | "version": "==5.2.0" 46 | }, 47 | "charset-normalizer": { 48 | "hashes": [ 49 | "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", 50 | "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", 51 | "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", 52 | "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", 53 | "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", 54 | "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", 55 | "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", 56 | "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", 57 | "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", 58 | "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", 59 | "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", 60 | "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", 61 | "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", 62 | "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", 63 | "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", 64 | "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", 65 | "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", 66 | "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", 67 | "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", 68 | "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", 69 | "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", 70 | "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", 71 | "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", 72 | "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", 73 | "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", 74 | "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", 75 | "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", 76 | "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", 77 | "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", 78 | "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", 79 | "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", 80 | "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", 81 | "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", 82 | "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", 83 | "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", 84 | "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", 85 | "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", 86 | "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", 87 | "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", 88 | "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", 89 | "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", 90 | "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", 91 | "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", 92 | "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", 93 | "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", 94 | "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", 95 | "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", 96 | "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", 97 | "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", 98 | "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", 99 | "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", 100 | "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", 101 | "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", 102 | "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", 103 | "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", 104 | "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", 105 | "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", 106 | "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", 107 | "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", 108 | "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", 109 | "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", 110 | "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", 111 | "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", 112 | "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", 113 | "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", 114 | "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", 115 | "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", 116 | "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", 117 | "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", 118 | "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", 119 | "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", 120 | "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", 121 | "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", 122 | "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", 123 | "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", 124 | "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", 125 | "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", 126 | "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", 127 | "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", 128 | "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", 129 | "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", 130 | "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", 131 | "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", 132 | "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", 133 | "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", 134 | "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", 135 | "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", 136 | "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", 137 | "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", 138 | "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", 139 | "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", 140 | "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", 141 | "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", 142 | "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", 143 | "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", 144 | "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", 145 | "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", 146 | "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", 147 | "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", 148 | "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", 149 | "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", 150 | "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", 151 | "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", 152 | "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", 153 | "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", 154 | "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", 155 | "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", 156 | "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", 157 | "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", 158 | "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", 159 | "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", 160 | "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", 161 | "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608" 162 | ], 163 | "markers": "python_version >= '3.7'", 164 | "version": "==3.4.4" 165 | }, 166 | "colorama": { 167 | "hashes": [ 168 | "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", 169 | "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" 170 | ], 171 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", 172 | "version": "==0.4.6" 173 | }, 174 | "distlib": { 175 | "hashes": [ 176 | "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", 177 | "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d" 178 | ], 179 | "version": "==0.4.0" 180 | }, 181 | "filelock": { 182 | "hashes": [ 183 | "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", 184 | "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4" 185 | ], 186 | "markers": "python_version >= '3.10'", 187 | "version": "==3.20.0" 188 | }, 189 | "idna": { 190 | "hashes": [ 191 | "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", 192 | "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" 193 | ], 194 | "markers": "python_version >= '3.8'", 195 | "version": "==3.11" 196 | }, 197 | "packaging": { 198 | "hashes": [ 199 | "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", 200 | "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" 201 | ], 202 | "markers": "python_version >= '3.8'", 203 | "version": "==25.0" 204 | }, 205 | "platformdirs": { 206 | "hashes": [ 207 | "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", 208 | "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3" 209 | ], 210 | "markers": "python_version >= '3.10'", 211 | "version": "==4.5.0" 212 | }, 213 | "pluggy": { 214 | "hashes": [ 215 | "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", 216 | "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" 217 | ], 218 | "markers": "python_version >= '3.9'", 219 | "version": "==1.6.0" 220 | }, 221 | "pyproject-api": { 222 | "hashes": [ 223 | "sha256:40c6f2d82eebdc4afee61c773ed208c04c19db4c4a60d97f8d7be3ebc0bbb330", 224 | "sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09" 225 | ], 226 | "markers": "python_version >= '3.10'", 227 | "version": "==1.10.0" 228 | }, 229 | "requests": { 230 | "hashes": [ 231 | "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", 232 | "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" 233 | ], 234 | "index": "pypi", 235 | "markers": "python_version >= '3.9'", 236 | "version": "==2.32.5" 237 | }, 238 | "tox": { 239 | "hashes": [ 240 | "sha256:1ad476b5f4d3679455b89a992849ffc3367560bbc7e9495ee8a3963542e7c8ff", 241 | "sha256:451e81dc02ba8d1ed20efd52ee409641ae4b5d5830e008af10fe8823ef1bd551" 242 | ], 243 | "index": "pypi", 244 | "markers": "python_version >= '3.10'", 245 | "version": "==4.32.0" 246 | }, 247 | "urllib3": { 248 | "hashes": [ 249 | "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", 250 | "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" 251 | ], 252 | "markers": "python_version >= '3.9'", 253 | "version": "==2.5.0" 254 | }, 255 | "versioneer": { 256 | "hashes": [ 257 | "sha256:0f1a137bb5d6811e96a79bb0486798aeae9b9c6efc24b389659cebb0ee396cb9", 258 | "sha256:5ab283b9857211d61b53318b7c792cf68e798e765ee17c27ade9f6c924235731" 259 | ], 260 | "index": "pypi", 261 | "markers": "python_version >= '3.7'", 262 | "version": "==0.29" 263 | }, 264 | "virtualenv": { 265 | "hashes": [ 266 | "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", 267 | "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b" 268 | ], 269 | "markers": "python_version >= '3.8'", 270 | "version": "==20.35.4" 271 | } 272 | }, 273 | "develop": { 274 | "iniconfig": { 275 | "hashes": [ 276 | "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", 277 | "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" 278 | ], 279 | "markers": "python_version >= '3.10'", 280 | "version": "==2.3.0" 281 | }, 282 | "packaging": { 283 | "hashes": [ 284 | "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", 285 | "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f" 286 | ], 287 | "markers": "python_version >= '3.8'", 288 | "version": "==25.0" 289 | }, 290 | "pluggy": { 291 | "hashes": [ 292 | "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", 293 | "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" 294 | ], 295 | "markers": "python_version >= '3.9'", 296 | "version": "==1.6.0" 297 | }, 298 | "pygments": { 299 | "hashes": [ 300 | "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", 301 | "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" 302 | ], 303 | "markers": "python_version >= '3.8'", 304 | "version": "==2.19.2" 305 | }, 306 | "pytest": { 307 | "hashes": [ 308 | "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", 309 | "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad" 310 | ], 311 | "index": "pypi", 312 | "markers": "python_version >= '3.10'", 313 | "version": "==9.0.1" 314 | }, 315 | "setuptools": { 316 | "hashes": [ 317 | "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", 318 | "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c" 319 | ], 320 | "index": "pypi", 321 | "markers": "python_version >= '3.9'", 322 | "version": "==80.9.0" 323 | }, 324 | "wheel": { 325 | "hashes": [ 326 | "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", 327 | "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248" 328 | ], 329 | "index": "pypi", 330 | "markers": "python_version >= '3.8'", 331 | "version": "==0.45.1" 332 | } 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /mkp/_version.py: -------------------------------------------------------------------------------- 1 | 2 | # This file helps to compute a version number in source trees obtained from 3 | # git-archive tarball (such as those provided by githubs download-from-tag 4 | # feature). Distribution tarballs (built by setup.py sdist) and build 5 | # directories (produced by setup.py build) will contain a much shorter file 6 | # that just contains the computed version number. 7 | 8 | # This file is released into the public domain. 9 | # Generated by versioneer-0.29 10 | # https://github.com/python-versioneer/python-versioneer 11 | 12 | """Git implementation of _version.py.""" 13 | 14 | import errno 15 | import os 16 | import re 17 | import subprocess 18 | import sys 19 | from typing import Any, Callable, Dict, List, Optional, Tuple 20 | import functools 21 | 22 | 23 | def get_keywords() -> Dict[str, str]: 24 | """Get the keywords needed to look up the version information.""" 25 | # these strings will be replaced by git during git-archive. 26 | # setup.py/versioneer.py will grep for the variable names, so they must 27 | # each be defined on a line of their own. _version.py will just call 28 | # get_keywords(). 29 | git_refnames = " (HEAD -> master, tag: 0.7)" 30 | git_full = "4c0d64ce1eeee9874695c4e8cbf3d7f0973959f6" 31 | git_date = "2025-12-04 23:21:11 +0100" 32 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} 33 | return keywords 34 | 35 | 36 | class VersioneerConfig: 37 | """Container for Versioneer configuration parameters.""" 38 | 39 | VCS: str 40 | style: str 41 | tag_prefix: str 42 | parentdir_prefix: str 43 | versionfile_source: str 44 | verbose: bool 45 | 46 | 47 | def get_config() -> VersioneerConfig: 48 | """Create, populate and return the VersioneerConfig() object.""" 49 | # these strings are filled in when 'setup.py versioneer' creates 50 | # _version.py 51 | cfg = VersioneerConfig() 52 | cfg.VCS = "git" 53 | cfg.style = "pep440" 54 | cfg.tag_prefix = "" 55 | cfg.parentdir_prefix = "python-" 56 | cfg.versionfile_source = "mkp/_version.py" 57 | cfg.verbose = False 58 | return cfg 59 | 60 | 61 | class NotThisMethod(Exception): 62 | """Exception raised if a method is not valid for the current scenario.""" 63 | 64 | 65 | LONG_VERSION_PY: Dict[str, str] = {} 66 | HANDLERS: Dict[str, Dict[str, Callable]] = {} 67 | 68 | 69 | def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator 70 | """Create decorator to mark a method as the handler of a VCS.""" 71 | def decorate(f: Callable) -> Callable: 72 | """Store f in HANDLERS[vcs][method].""" 73 | if vcs not in HANDLERS: 74 | HANDLERS[vcs] = {} 75 | HANDLERS[vcs][method] = f 76 | return f 77 | return decorate 78 | 79 | 80 | def run_command( 81 | commands: List[str], 82 | args: List[str], 83 | cwd: Optional[str] = None, 84 | verbose: bool = False, 85 | hide_stderr: bool = False, 86 | env: Optional[Dict[str, str]] = None, 87 | ) -> Tuple[Optional[str], Optional[int]]: 88 | """Call the given command(s).""" 89 | assert isinstance(commands, list) 90 | process = None 91 | 92 | popen_kwargs: Dict[str, Any] = {} 93 | if sys.platform == "win32": 94 | # This hides the console window if pythonw.exe is used 95 | startupinfo = subprocess.STARTUPINFO() 96 | startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW 97 | popen_kwargs["startupinfo"] = startupinfo 98 | 99 | for command in commands: 100 | try: 101 | dispcmd = str([command] + args) 102 | # remember shell=False, so use git.cmd on windows, not just git 103 | process = subprocess.Popen([command] + args, cwd=cwd, env=env, 104 | stdout=subprocess.PIPE, 105 | stderr=(subprocess.PIPE if hide_stderr 106 | else None), **popen_kwargs) 107 | break 108 | except OSError as e: 109 | if e.errno == errno.ENOENT: 110 | continue 111 | if verbose: 112 | print("unable to run %s" % dispcmd) 113 | print(e) 114 | return None, None 115 | else: 116 | if verbose: 117 | print("unable to find command, tried %s" % (commands,)) 118 | return None, None 119 | stdout = process.communicate()[0].strip().decode() 120 | if process.returncode != 0: 121 | if verbose: 122 | print("unable to run %s (error)" % dispcmd) 123 | print("stdout was %s" % stdout) 124 | return None, process.returncode 125 | return stdout, process.returncode 126 | 127 | 128 | def versions_from_parentdir( 129 | parentdir_prefix: str, 130 | root: str, 131 | verbose: bool, 132 | ) -> Dict[str, Any]: 133 | """Try to determine the version from the parent directory name. 134 | 135 | Source tarballs conventionally unpack into a directory that includes both 136 | the project name and a version string. We will also support searching up 137 | two directory levels for an appropriately named parent directory 138 | """ 139 | rootdirs = [] 140 | 141 | for _ in range(3): 142 | dirname = os.path.basename(root) 143 | if dirname.startswith(parentdir_prefix): 144 | return {"version": dirname[len(parentdir_prefix):], 145 | "full-revisionid": None, 146 | "dirty": False, "error": None, "date": None} 147 | rootdirs.append(root) 148 | root = os.path.dirname(root) # up a level 149 | 150 | if verbose: 151 | print("Tried directories %s but none started with prefix %s" % 152 | (str(rootdirs), parentdir_prefix)) 153 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 154 | 155 | 156 | @register_vcs_handler("git", "get_keywords") 157 | def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: 158 | """Extract version information from the given file.""" 159 | # the code embedded in _version.py can just fetch the value of these 160 | # keywords. When used from setup.py, we don't want to import _version.py, 161 | # so we do it with a regexp instead. This function is not used from 162 | # _version.py. 163 | keywords: Dict[str, str] = {} 164 | try: 165 | with open(versionfile_abs, "r") as fobj: 166 | for line in fobj: 167 | if line.strip().startswith("git_refnames ="): 168 | mo = re.search(r'=\s*"(.*)"', line) 169 | if mo: 170 | keywords["refnames"] = mo.group(1) 171 | if line.strip().startswith("git_full ="): 172 | mo = re.search(r'=\s*"(.*)"', line) 173 | if mo: 174 | keywords["full"] = mo.group(1) 175 | if line.strip().startswith("git_date ="): 176 | mo = re.search(r'=\s*"(.*)"', line) 177 | if mo: 178 | keywords["date"] = mo.group(1) 179 | except OSError: 180 | pass 181 | return keywords 182 | 183 | 184 | @register_vcs_handler("git", "keywords") 185 | def git_versions_from_keywords( 186 | keywords: Dict[str, str], 187 | tag_prefix: str, 188 | verbose: bool, 189 | ) -> Dict[str, Any]: 190 | """Get version information from git keywords.""" 191 | if "refnames" not in keywords: 192 | raise NotThisMethod("Short version file found") 193 | date = keywords.get("date") 194 | if date is not None: 195 | # Use only the last line. Previous lines may contain GPG signature 196 | # information. 197 | date = date.splitlines()[-1] 198 | 199 | # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant 200 | # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 201 | # -like" string, which we must then edit to make compliant), because 202 | # it's been around since git-1.5.3, and it's too difficult to 203 | # discover which version we're using, or to work around using an 204 | # older one. 205 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 206 | refnames = keywords["refnames"].strip() 207 | if refnames.startswith("$Format"): 208 | if verbose: 209 | print("keywords are unexpanded, not using") 210 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 211 | refs = {r.strip() for r in refnames.strip("()").split(",")} 212 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 213 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 214 | TAG = "tag: " 215 | tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} 216 | if not tags: 217 | # Either we're using git < 1.8.3, or there really are no tags. We use 218 | # a heuristic: assume all version tags have a digit. The old git %d 219 | # expansion behaves like git log --decorate=short and strips out the 220 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 221 | # between branches and tags. By ignoring refnames without digits, we 222 | # filter out many common branch names like "release" and 223 | # "stabilization", as well as "HEAD" and "master". 224 | tags = {r for r in refs if re.search(r'\d', r)} 225 | if verbose: 226 | print("discarding '%s', no digits" % ",".join(refs - tags)) 227 | if verbose: 228 | print("likely tags: %s" % ",".join(sorted(tags))) 229 | for ref in sorted(tags): 230 | # sorting will prefer e.g. "2.0" over "2.0rc1" 231 | if ref.startswith(tag_prefix): 232 | r = ref[len(tag_prefix):] 233 | # Filter out refs that exactly match prefix or that don't start 234 | # with a number once the prefix is stripped (mostly a concern 235 | # when prefix is '') 236 | if not re.match(r'\d', r): 237 | continue 238 | if verbose: 239 | print("picking %s" % r) 240 | return {"version": r, 241 | "full-revisionid": keywords["full"].strip(), 242 | "dirty": False, "error": None, 243 | "date": date} 244 | # no suitable tags, so version is "0+unknown", but full hex is still there 245 | if verbose: 246 | print("no suitable tags, using unknown + full revision id") 247 | return {"version": "0+unknown", 248 | "full-revisionid": keywords["full"].strip(), 249 | "dirty": False, "error": "no suitable tags", "date": None} 250 | 251 | 252 | @register_vcs_handler("git", "pieces_from_vcs") 253 | def git_pieces_from_vcs( 254 | tag_prefix: str, 255 | root: str, 256 | verbose: bool, 257 | runner: Callable = run_command 258 | ) -> Dict[str, Any]: 259 | """Get version from 'git describe' in the root of the source tree. 260 | 261 | This only gets called if the git-archive 'subst' keywords were *not* 262 | expanded, and _version.py hasn't already been rewritten with a short 263 | version string, meaning we're inside a checked out source tree. 264 | """ 265 | GITS = ["git"] 266 | if sys.platform == "win32": 267 | GITS = ["git.cmd", "git.exe"] 268 | 269 | # GIT_DIR can interfere with correct operation of Versioneer. 270 | # It may be intended to be passed to the Versioneer-versioned project, 271 | # but that should not change where we get our version from. 272 | env = os.environ.copy() 273 | env.pop("GIT_DIR", None) 274 | runner = functools.partial(runner, env=env) 275 | 276 | _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, 277 | hide_stderr=not verbose) 278 | if rc != 0: 279 | if verbose: 280 | print("Directory %s not under git control" % root) 281 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 282 | 283 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 284 | # if there isn't one, this yields HEX[-dirty] (no NUM) 285 | describe_out, rc = runner(GITS, [ 286 | "describe", "--tags", "--dirty", "--always", "--long", 287 | "--match", f"{tag_prefix}[[:digit:]]*" 288 | ], cwd=root) 289 | # --long was added in git-1.5.5 290 | if describe_out is None: 291 | raise NotThisMethod("'git describe' failed") 292 | describe_out = describe_out.strip() 293 | full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) 294 | if full_out is None: 295 | raise NotThisMethod("'git rev-parse' failed") 296 | full_out = full_out.strip() 297 | 298 | pieces: Dict[str, Any] = {} 299 | pieces["long"] = full_out 300 | pieces["short"] = full_out[:7] # maybe improved later 301 | pieces["error"] = None 302 | 303 | branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], 304 | cwd=root) 305 | # --abbrev-ref was added in git-1.6.3 306 | if rc != 0 or branch_name is None: 307 | raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") 308 | branch_name = branch_name.strip() 309 | 310 | if branch_name == "HEAD": 311 | # If we aren't exactly on a branch, pick a branch which represents 312 | # the current commit. If all else fails, we are on a branchless 313 | # commit. 314 | branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) 315 | # --contains was added in git-1.5.4 316 | if rc != 0 or branches is None: 317 | raise NotThisMethod("'git branch --contains' returned error") 318 | branches = branches.split("\n") 319 | 320 | # Remove the first line if we're running detached 321 | if "(" in branches[0]: 322 | branches.pop(0) 323 | 324 | # Strip off the leading "* " from the list of branches. 325 | branches = [branch[2:] for branch in branches] 326 | if "master" in branches: 327 | branch_name = "master" 328 | elif not branches: 329 | branch_name = None 330 | else: 331 | # Pick the first branch that is returned. Good or bad. 332 | branch_name = branches[0] 333 | 334 | pieces["branch"] = branch_name 335 | 336 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 337 | # TAG might have hyphens. 338 | git_describe = describe_out 339 | 340 | # look for -dirty suffix 341 | dirty = git_describe.endswith("-dirty") 342 | pieces["dirty"] = dirty 343 | if dirty: 344 | git_describe = git_describe[:git_describe.rindex("-dirty")] 345 | 346 | # now we have TAG-NUM-gHEX or HEX 347 | 348 | if "-" in git_describe: 349 | # TAG-NUM-gHEX 350 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 351 | if not mo: 352 | # unparsable. Maybe git-describe is misbehaving? 353 | pieces["error"] = ("unable to parse git-describe output: '%s'" 354 | % describe_out) 355 | return pieces 356 | 357 | # tag 358 | full_tag = mo.group(1) 359 | if not full_tag.startswith(tag_prefix): 360 | if verbose: 361 | fmt = "tag '%s' doesn't start with prefix '%s'" 362 | print(fmt % (full_tag, tag_prefix)) 363 | pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" 364 | % (full_tag, tag_prefix)) 365 | return pieces 366 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 367 | 368 | # distance: number of commits since tag 369 | pieces["distance"] = int(mo.group(2)) 370 | 371 | # commit: short hex revision ID 372 | pieces["short"] = mo.group(3) 373 | 374 | else: 375 | # HEX: no tags 376 | pieces["closest-tag"] = None 377 | out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) 378 | pieces["distance"] = len(out.split()) # total number of commits 379 | 380 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 381 | date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() 382 | # Use only the last line. Previous lines may contain GPG signature 383 | # information. 384 | date = date.splitlines()[-1] 385 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 386 | 387 | return pieces 388 | 389 | 390 | def plus_or_dot(pieces: Dict[str, Any]) -> str: 391 | """Return a + if we don't already have one, else return a .""" 392 | if "+" in pieces.get("closest-tag", ""): 393 | return "." 394 | return "+" 395 | 396 | 397 | def render_pep440(pieces: Dict[str, Any]) -> str: 398 | """Build up version string, with post-release "local version identifier". 399 | 400 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 401 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 402 | 403 | Exceptions: 404 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 405 | """ 406 | if pieces["closest-tag"]: 407 | rendered = pieces["closest-tag"] 408 | if pieces["distance"] or pieces["dirty"]: 409 | rendered += plus_or_dot(pieces) 410 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 411 | if pieces["dirty"]: 412 | rendered += ".dirty" 413 | else: 414 | # exception #1 415 | rendered = "0+untagged.%d.g%s" % (pieces["distance"], 416 | pieces["short"]) 417 | if pieces["dirty"]: 418 | rendered += ".dirty" 419 | return rendered 420 | 421 | 422 | def render_pep440_branch(pieces: Dict[str, Any]) -> str: 423 | """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . 424 | 425 | The ".dev0" means not master branch. Note that .dev0 sorts backwards 426 | (a feature branch will appear "older" than the master branch). 427 | 428 | Exceptions: 429 | 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] 430 | """ 431 | if pieces["closest-tag"]: 432 | rendered = pieces["closest-tag"] 433 | if pieces["distance"] or pieces["dirty"]: 434 | if pieces["branch"] != "master": 435 | rendered += ".dev0" 436 | rendered += plus_or_dot(pieces) 437 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 438 | if pieces["dirty"]: 439 | rendered += ".dirty" 440 | else: 441 | # exception #1 442 | rendered = "0" 443 | if pieces["branch"] != "master": 444 | rendered += ".dev0" 445 | rendered += "+untagged.%d.g%s" % (pieces["distance"], 446 | pieces["short"]) 447 | if pieces["dirty"]: 448 | rendered += ".dirty" 449 | return rendered 450 | 451 | 452 | def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: 453 | """Split pep440 version string at the post-release segment. 454 | 455 | Returns the release segments before the post-release and the 456 | post-release version number (or -1 if no post-release segment is present). 457 | """ 458 | vc = str.split(ver, ".post") 459 | return vc[0], int(vc[1] or 0) if len(vc) == 2 else None 460 | 461 | 462 | def render_pep440_pre(pieces: Dict[str, Any]) -> str: 463 | """TAG[.postN.devDISTANCE] -- No -dirty. 464 | 465 | Exceptions: 466 | 1: no tags. 0.post0.devDISTANCE 467 | """ 468 | if pieces["closest-tag"]: 469 | if pieces["distance"]: 470 | # update the post release segment 471 | tag_version, post_version = pep440_split_post(pieces["closest-tag"]) 472 | rendered = tag_version 473 | if post_version is not None: 474 | rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"]) 475 | else: 476 | rendered += ".post0.dev%d" % (pieces["distance"]) 477 | else: 478 | # no commits, use the tag as the version 479 | rendered = pieces["closest-tag"] 480 | else: 481 | # exception #1 482 | rendered = "0.post0.dev%d" % pieces["distance"] 483 | return rendered 484 | 485 | 486 | def render_pep440_post(pieces: Dict[str, Any]) -> str: 487 | """TAG[.postDISTANCE[.dev0]+gHEX] . 488 | 489 | The ".dev0" means dirty. Note that .dev0 sorts backwards 490 | (a dirty tree will appear "older" than the corresponding clean one), 491 | but you shouldn't be releasing software with -dirty anyways. 492 | 493 | Exceptions: 494 | 1: no tags. 0.postDISTANCE[.dev0] 495 | """ 496 | if pieces["closest-tag"]: 497 | rendered = pieces["closest-tag"] 498 | if pieces["distance"] or pieces["dirty"]: 499 | rendered += ".post%d" % pieces["distance"] 500 | if pieces["dirty"]: 501 | rendered += ".dev0" 502 | rendered += plus_or_dot(pieces) 503 | rendered += "g%s" % pieces["short"] 504 | else: 505 | # exception #1 506 | rendered = "0.post%d" % pieces["distance"] 507 | if pieces["dirty"]: 508 | rendered += ".dev0" 509 | rendered += "+g%s" % pieces["short"] 510 | return rendered 511 | 512 | 513 | def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: 514 | """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . 515 | 516 | The ".dev0" means not master branch. 517 | 518 | Exceptions: 519 | 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] 520 | """ 521 | if pieces["closest-tag"]: 522 | rendered = pieces["closest-tag"] 523 | if pieces["distance"] or pieces["dirty"]: 524 | rendered += ".post%d" % pieces["distance"] 525 | if pieces["branch"] != "master": 526 | rendered += ".dev0" 527 | rendered += plus_or_dot(pieces) 528 | rendered += "g%s" % pieces["short"] 529 | if pieces["dirty"]: 530 | rendered += ".dirty" 531 | else: 532 | # exception #1 533 | rendered = "0.post%d" % pieces["distance"] 534 | if pieces["branch"] != "master": 535 | rendered += ".dev0" 536 | rendered += "+g%s" % pieces["short"] 537 | if pieces["dirty"]: 538 | rendered += ".dirty" 539 | return rendered 540 | 541 | 542 | def render_pep440_old(pieces: Dict[str, Any]) -> str: 543 | """TAG[.postDISTANCE[.dev0]] . 544 | 545 | The ".dev0" means dirty. 546 | 547 | Exceptions: 548 | 1: no tags. 0.postDISTANCE[.dev0] 549 | """ 550 | if pieces["closest-tag"]: 551 | rendered = pieces["closest-tag"] 552 | if pieces["distance"] or pieces["dirty"]: 553 | rendered += ".post%d" % pieces["distance"] 554 | if pieces["dirty"]: 555 | rendered += ".dev0" 556 | else: 557 | # exception #1 558 | rendered = "0.post%d" % pieces["distance"] 559 | if pieces["dirty"]: 560 | rendered += ".dev0" 561 | return rendered 562 | 563 | 564 | def render_git_describe(pieces: Dict[str, Any]) -> str: 565 | """TAG[-DISTANCE-gHEX][-dirty]. 566 | 567 | Like 'git describe --tags --dirty --always'. 568 | 569 | Exceptions: 570 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 571 | """ 572 | if pieces["closest-tag"]: 573 | rendered = pieces["closest-tag"] 574 | if pieces["distance"]: 575 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 576 | else: 577 | # exception #1 578 | rendered = pieces["short"] 579 | if pieces["dirty"]: 580 | rendered += "-dirty" 581 | return rendered 582 | 583 | 584 | def render_git_describe_long(pieces: Dict[str, Any]) -> str: 585 | """TAG-DISTANCE-gHEX[-dirty]. 586 | 587 | Like 'git describe --tags --dirty --always -long'. 588 | The distance/hash is unconditional. 589 | 590 | Exceptions: 591 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 592 | """ 593 | if pieces["closest-tag"]: 594 | rendered = pieces["closest-tag"] 595 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 596 | else: 597 | # exception #1 598 | rendered = pieces["short"] 599 | if pieces["dirty"]: 600 | rendered += "-dirty" 601 | return rendered 602 | 603 | 604 | def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: 605 | """Render the given version pieces into the requested style.""" 606 | if pieces["error"]: 607 | return {"version": "unknown", 608 | "full-revisionid": pieces.get("long"), 609 | "dirty": None, 610 | "error": pieces["error"], 611 | "date": None} 612 | 613 | if not style or style == "default": 614 | style = "pep440" # the default 615 | 616 | if style == "pep440": 617 | rendered = render_pep440(pieces) 618 | elif style == "pep440-branch": 619 | rendered = render_pep440_branch(pieces) 620 | elif style == "pep440-pre": 621 | rendered = render_pep440_pre(pieces) 622 | elif style == "pep440-post": 623 | rendered = render_pep440_post(pieces) 624 | elif style == "pep440-post-branch": 625 | rendered = render_pep440_post_branch(pieces) 626 | elif style == "pep440-old": 627 | rendered = render_pep440_old(pieces) 628 | elif style == "git-describe": 629 | rendered = render_git_describe(pieces) 630 | elif style == "git-describe-long": 631 | rendered = render_git_describe_long(pieces) 632 | else: 633 | raise ValueError("unknown style '%s'" % style) 634 | 635 | return {"version": rendered, "full-revisionid": pieces["long"], 636 | "dirty": pieces["dirty"], "error": None, 637 | "date": pieces.get("date")} 638 | 639 | 640 | def get_versions() -> Dict[str, Any]: 641 | """Get version information or return default if unable to do so.""" 642 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 643 | # __file__, we can work backwards from there to the root. Some 644 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 645 | # case we can only use expanded keywords. 646 | 647 | cfg = get_config() 648 | verbose = cfg.verbose 649 | 650 | try: 651 | return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, 652 | verbose) 653 | except NotThisMethod: 654 | pass 655 | 656 | try: 657 | root = os.path.realpath(__file__) 658 | # versionfile_source is the relative path from the top of the source 659 | # tree (where the .git directory might live) to this file. Invert 660 | # this to find the root from __file__. 661 | for _ in cfg.versionfile_source.split('/'): 662 | root = os.path.dirname(root) 663 | except NameError: 664 | return {"version": "0+unknown", "full-revisionid": None, 665 | "dirty": None, 666 | "error": "unable to find root of source tree", 667 | "date": None} 668 | 669 | try: 670 | pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) 671 | return render(pieces, cfg.style) 672 | except NotThisMethod: 673 | pass 674 | 675 | try: 676 | if cfg.parentdir_prefix: 677 | return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 678 | except NotThisMethod: 679 | pass 680 | 681 | return {"version": "0+unknown", "full-revisionid": None, 682 | "dirty": None, 683 | "error": "unable to compute version", "date": None} 684 | -------------------------------------------------------------------------------- /versioneer.py: -------------------------------------------------------------------------------- 1 | 2 | # Version: 0.29 3 | 4 | """The Versioneer - like a rocketeer, but for versions. 5 | 6 | The Versioneer 7 | ============== 8 | 9 | * like a rocketeer, but for versions! 10 | * https://github.com/python-versioneer/python-versioneer 11 | * Brian Warner 12 | * License: Public Domain (Unlicense) 13 | * Compatible with: Python 3.7, 3.8, 3.9, 3.10, 3.11 and pypy3 14 | * [![Latest Version][pypi-image]][pypi-url] 15 | * [![Build Status][travis-image]][travis-url] 16 | 17 | This is a tool for managing a recorded version number in setuptools-based 18 | python projects. The goal is to remove the tedious and error-prone "update 19 | the embedded version string" step from your release process. Making a new 20 | release should be as easy as recording a new tag in your version-control 21 | system, and maybe making new tarballs. 22 | 23 | 24 | ## Quick Install 25 | 26 | Versioneer provides two installation modes. The "classic" vendored mode installs 27 | a copy of versioneer into your repository. The experimental build-time dependency mode 28 | is intended to allow you to skip this step and simplify the process of upgrading. 29 | 30 | ### Vendored mode 31 | 32 | * `pip install versioneer` to somewhere in your $PATH 33 | * A [conda-forge recipe](https://github.com/conda-forge/versioneer-feedstock) is 34 | available, so you can also use `conda install -c conda-forge versioneer` 35 | * add a `[tool.versioneer]` section to your `pyproject.toml` or a 36 | `[versioneer]` section to your `setup.cfg` (see [Install](INSTALL.md)) 37 | * Note that you will need to add `tomli; python_version < "3.11"` to your 38 | build-time dependencies if you use `pyproject.toml` 39 | * run `versioneer install --vendor` in your source tree, commit the results 40 | * verify version information with `python setup.py version` 41 | 42 | ### Build-time dependency mode 43 | 44 | * `pip install versioneer` to somewhere in your $PATH 45 | * A [conda-forge recipe](https://github.com/conda-forge/versioneer-feedstock) is 46 | available, so you can also use `conda install -c conda-forge versioneer` 47 | * add a `[tool.versioneer]` section to your `pyproject.toml` or a 48 | `[versioneer]` section to your `setup.cfg` (see [Install](INSTALL.md)) 49 | * add `versioneer` (with `[toml]` extra, if configuring in `pyproject.toml`) 50 | to the `requires` key of the `build-system` table in `pyproject.toml`: 51 | ```toml 52 | [build-system] 53 | requires = ["setuptools", "versioneer[toml]"] 54 | build-backend = "setuptools.build_meta" 55 | ``` 56 | * run `versioneer install --no-vendor` in your source tree, commit the results 57 | * verify version information with `python setup.py version` 58 | 59 | ## Version Identifiers 60 | 61 | Source trees come from a variety of places: 62 | 63 | * a version-control system checkout (mostly used by developers) 64 | * a nightly tarball, produced by build automation 65 | * a snapshot tarball, produced by a web-based VCS browser, like github's 66 | "tarball from tag" feature 67 | * a release tarball, produced by "setup.py sdist", distributed through PyPI 68 | 69 | Within each source tree, the version identifier (either a string or a number, 70 | this tool is format-agnostic) can come from a variety of places: 71 | 72 | * ask the VCS tool itself, e.g. "git describe" (for checkouts), which knows 73 | about recent "tags" and an absolute revision-id 74 | * the name of the directory into which the tarball was unpacked 75 | * an expanded VCS keyword ($Id$, etc) 76 | * a `_version.py` created by some earlier build step 77 | 78 | For released software, the version identifier is closely related to a VCS 79 | tag. Some projects use tag names that include more than just the version 80 | string (e.g. "myproject-1.2" instead of just "1.2"), in which case the tool 81 | needs to strip the tag prefix to extract the version identifier. For 82 | unreleased software (between tags), the version identifier should provide 83 | enough information to help developers recreate the same tree, while also 84 | giving them an idea of roughly how old the tree is (after version 1.2, before 85 | version 1.3). Many VCS systems can report a description that captures this, 86 | for example `git describe --tags --dirty --always` reports things like 87 | "0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the 88 | 0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has 89 | uncommitted changes). 90 | 91 | The version identifier is used for multiple purposes: 92 | 93 | * to allow the module to self-identify its version: `myproject.__version__` 94 | * to choose a name and prefix for a 'setup.py sdist' tarball 95 | 96 | ## Theory of Operation 97 | 98 | Versioneer works by adding a special `_version.py` file into your source 99 | tree, where your `__init__.py` can import it. This `_version.py` knows how to 100 | dynamically ask the VCS tool for version information at import time. 101 | 102 | `_version.py` also contains `$Revision$` markers, and the installation 103 | process marks `_version.py` to have this marker rewritten with a tag name 104 | during the `git archive` command. As a result, generated tarballs will 105 | contain enough information to get the proper version. 106 | 107 | To allow `setup.py` to compute a version too, a `versioneer.py` is added to 108 | the top level of your source tree, next to `setup.py` and the `setup.cfg` 109 | that configures it. This overrides several distutils/setuptools commands to 110 | compute the version when invoked, and changes `setup.py build` and `setup.py 111 | sdist` to replace `_version.py` with a small static file that contains just 112 | the generated version data. 113 | 114 | ## Installation 115 | 116 | See [INSTALL.md](./INSTALL.md) for detailed installation instructions. 117 | 118 | ## Version-String Flavors 119 | 120 | Code which uses Versioneer can learn about its version string at runtime by 121 | importing `_version` from your main `__init__.py` file and running the 122 | `get_versions()` function. From the "outside" (e.g. in `setup.py`), you can 123 | import the top-level `versioneer.py` and run `get_versions()`. 124 | 125 | Both functions return a dictionary with different flavors of version 126 | information: 127 | 128 | * `['version']`: A condensed version string, rendered using the selected 129 | style. This is the most commonly used value for the project's version 130 | string. The default "pep440" style yields strings like `0.11`, 131 | `0.11+2.g1076c97`, or `0.11+2.g1076c97.dirty`. See the "Styles" section 132 | below for alternative styles. 133 | 134 | * `['full-revisionid']`: detailed revision identifier. For Git, this is the 135 | full SHA1 commit id, e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac". 136 | 137 | * `['date']`: Date and time of the latest `HEAD` commit. For Git, it is the 138 | commit date in ISO 8601 format. This will be None if the date is not 139 | available. 140 | 141 | * `['dirty']`: a boolean, True if the tree has uncommitted changes. Note that 142 | this is only accurate if run in a VCS checkout, otherwise it is likely to 143 | be False or None 144 | 145 | * `['error']`: if the version string could not be computed, this will be set 146 | to a string describing the problem, otherwise it will be None. It may be 147 | useful to throw an exception in setup.py if this is set, to avoid e.g. 148 | creating tarballs with a version string of "unknown". 149 | 150 | Some variants are more useful than others. Including `full-revisionid` in a 151 | bug report should allow developers to reconstruct the exact code being tested 152 | (or indicate the presence of local changes that should be shared with the 153 | developers). `version` is suitable for display in an "about" box or a CLI 154 | `--version` output: it can be easily compared against release notes and lists 155 | of bugs fixed in various releases. 156 | 157 | The installer adds the following text to your `__init__.py` to place a basic 158 | version in `YOURPROJECT.__version__`: 159 | 160 | from ._version import get_versions 161 | __version__ = get_versions()['version'] 162 | del get_versions 163 | 164 | ## Styles 165 | 166 | The setup.cfg `style=` configuration controls how the VCS information is 167 | rendered into a version string. 168 | 169 | The default style, "pep440", produces a PEP440-compliant string, equal to the 170 | un-prefixed tag name for actual releases, and containing an additional "local 171 | version" section with more detail for in-between builds. For Git, this is 172 | TAG[+DISTANCE.gHEX[.dirty]] , using information from `git describe --tags 173 | --dirty --always`. For example "0.11+2.g1076c97.dirty" indicates that the 174 | tree is like the "1076c97" commit but has uncommitted changes (".dirty"), and 175 | that this commit is two revisions ("+2") beyond the "0.11" tag. For released 176 | software (exactly equal to a known tag), the identifier will only contain the 177 | stripped tag, e.g. "0.11". 178 | 179 | Other styles are available. See [details.md](details.md) in the Versioneer 180 | source tree for descriptions. 181 | 182 | ## Debugging 183 | 184 | Versioneer tries to avoid fatal errors: if something goes wrong, it will tend 185 | to return a version of "0+unknown". To investigate the problem, run `setup.py 186 | version`, which will run the version-lookup code in a verbose mode, and will 187 | display the full contents of `get_versions()` (including the `error` string, 188 | which may help identify what went wrong). 189 | 190 | ## Known Limitations 191 | 192 | Some situations are known to cause problems for Versioneer. This details the 193 | most significant ones. More can be found on Github 194 | [issues page](https://github.com/python-versioneer/python-versioneer/issues). 195 | 196 | ### Subprojects 197 | 198 | Versioneer has limited support for source trees in which `setup.py` is not in 199 | the root directory (e.g. `setup.py` and `.git/` are *not* siblings). The are 200 | two common reasons why `setup.py` might not be in the root: 201 | 202 | * Source trees which contain multiple subprojects, such as 203 | [Buildbot](https://github.com/buildbot/buildbot), which contains both 204 | "master" and "slave" subprojects, each with their own `setup.py`, 205 | `setup.cfg`, and `tox.ini`. Projects like these produce multiple PyPI 206 | distributions (and upload multiple independently-installable tarballs). 207 | * Source trees whose main purpose is to contain a C library, but which also 208 | provide bindings to Python (and perhaps other languages) in subdirectories. 209 | 210 | Versioneer will look for `.git` in parent directories, and most operations 211 | should get the right version string. However `pip` and `setuptools` have bugs 212 | and implementation details which frequently cause `pip install .` from a 213 | subproject directory to fail to find a correct version string (so it usually 214 | defaults to `0+unknown`). 215 | 216 | `pip install --editable .` should work correctly. `setup.py install` might 217 | work too. 218 | 219 | Pip-8.1.1 is known to have this problem, but hopefully it will get fixed in 220 | some later version. 221 | 222 | [Bug #38](https://github.com/python-versioneer/python-versioneer/issues/38) is tracking 223 | this issue. The discussion in 224 | [PR #61](https://github.com/python-versioneer/python-versioneer/pull/61) describes the 225 | issue from the Versioneer side in more detail. 226 | [pip PR#3176](https://github.com/pypa/pip/pull/3176) and 227 | [pip PR#3615](https://github.com/pypa/pip/pull/3615) contain work to improve 228 | pip to let Versioneer work correctly. 229 | 230 | Versioneer-0.16 and earlier only looked for a `.git` directory next to the 231 | `setup.cfg`, so subprojects were completely unsupported with those releases. 232 | 233 | ### Editable installs with setuptools <= 18.5 234 | 235 | `setup.py develop` and `pip install --editable .` allow you to install a 236 | project into a virtualenv once, then continue editing the source code (and 237 | test) without re-installing after every change. 238 | 239 | "Entry-point scripts" (`setup(entry_points={"console_scripts": ..})`) are a 240 | convenient way to specify executable scripts that should be installed along 241 | with the python package. 242 | 243 | These both work as expected when using modern setuptools. When using 244 | setuptools-18.5 or earlier, however, certain operations will cause 245 | `pkg_resources.DistributionNotFound` errors when running the entrypoint 246 | script, which must be resolved by re-installing the package. This happens 247 | when the install happens with one version, then the egg_info data is 248 | regenerated while a different version is checked out. Many setup.py commands 249 | cause egg_info to be rebuilt (including `sdist`, `wheel`, and installing into 250 | a different virtualenv), so this can be surprising. 251 | 252 | [Bug #83](https://github.com/python-versioneer/python-versioneer/issues/83) describes 253 | this one, but upgrading to a newer version of setuptools should probably 254 | resolve it. 255 | 256 | 257 | ## Updating Versioneer 258 | 259 | To upgrade your project to a new release of Versioneer, do the following: 260 | 261 | * install the new Versioneer (`pip install -U versioneer` or equivalent) 262 | * edit `setup.cfg` and `pyproject.toml`, if necessary, 263 | to include any new configuration settings indicated by the release notes. 264 | See [UPGRADING](./UPGRADING.md) for details. 265 | * re-run `versioneer install --[no-]vendor` in your source tree, to replace 266 | `SRC/_version.py` 267 | * commit any changed files 268 | 269 | ## Future Directions 270 | 271 | This tool is designed to make it easily extended to other version-control 272 | systems: all VCS-specific components are in separate directories like 273 | src/git/ . The top-level `versioneer.py` script is assembled from these 274 | components by running make-versioneer.py . In the future, make-versioneer.py 275 | will take a VCS name as an argument, and will construct a version of 276 | `versioneer.py` that is specific to the given VCS. It might also take the 277 | configuration arguments that are currently provided manually during 278 | installation by editing setup.py . Alternatively, it might go the other 279 | direction and include code from all supported VCS systems, reducing the 280 | number of intermediate scripts. 281 | 282 | ## Similar projects 283 | 284 | * [setuptools_scm](https://github.com/pypa/setuptools_scm/) - a non-vendored build-time 285 | dependency 286 | * [minver](https://github.com/jbweston/miniver) - a lightweight reimplementation of 287 | versioneer 288 | * [versioningit](https://github.com/jwodder/versioningit) - a PEP 518-based setuptools 289 | plugin 290 | 291 | ## License 292 | 293 | To make Versioneer easier to embed, all its code is dedicated to the public 294 | domain. The `_version.py` that it creates is also in the public domain. 295 | Specifically, both are released under the "Unlicense", as described in 296 | https://unlicense.org/. 297 | 298 | [pypi-image]: https://img.shields.io/pypi/v/versioneer.svg 299 | [pypi-url]: https://pypi.python.org/pypi/versioneer/ 300 | [travis-image]: 301 | https://img.shields.io/travis/com/python-versioneer/python-versioneer.svg 302 | [travis-url]: https://travis-ci.com/github/python-versioneer/python-versioneer 303 | 304 | """ 305 | # pylint:disable=invalid-name,import-outside-toplevel,missing-function-docstring 306 | # pylint:disable=missing-class-docstring,too-many-branches,too-many-statements 307 | # pylint:disable=raise-missing-from,too-many-lines,too-many-locals,import-error 308 | # pylint:disable=too-few-public-methods,redefined-outer-name,consider-using-with 309 | # pylint:disable=attribute-defined-outside-init,too-many-arguments 310 | 311 | import configparser 312 | import errno 313 | import json 314 | import os 315 | import re 316 | import subprocess 317 | import sys 318 | from pathlib import Path 319 | from typing import Any, Callable, cast, Dict, List, Optional, Tuple, Union 320 | from typing import NoReturn 321 | import functools 322 | 323 | have_tomllib = True 324 | if sys.version_info >= (3, 11): 325 | import tomllib 326 | else: 327 | try: 328 | import tomli as tomllib 329 | except ImportError: 330 | have_tomllib = False 331 | 332 | 333 | class VersioneerConfig: 334 | """Container for Versioneer configuration parameters.""" 335 | 336 | VCS: str 337 | style: str 338 | tag_prefix: str 339 | versionfile_source: str 340 | versionfile_build: Optional[str] 341 | parentdir_prefix: Optional[str] 342 | verbose: Optional[bool] 343 | 344 | 345 | def get_root() -> str: 346 | """Get the project root directory. 347 | 348 | We require that all commands are run from the project root, i.e. the 349 | directory that contains setup.py, setup.cfg, and versioneer.py . 350 | """ 351 | root = os.path.realpath(os.path.abspath(os.getcwd())) 352 | setup_py = os.path.join(root, "setup.py") 353 | pyproject_toml = os.path.join(root, "pyproject.toml") 354 | versioneer_py = os.path.join(root, "versioneer.py") 355 | if not ( 356 | os.path.exists(setup_py) 357 | or os.path.exists(pyproject_toml) 358 | or os.path.exists(versioneer_py) 359 | ): 360 | # allow 'python path/to/setup.py COMMAND' 361 | root = os.path.dirname(os.path.realpath(os.path.abspath(sys.argv[0]))) 362 | setup_py = os.path.join(root, "setup.py") 363 | pyproject_toml = os.path.join(root, "pyproject.toml") 364 | versioneer_py = os.path.join(root, "versioneer.py") 365 | if not ( 366 | os.path.exists(setup_py) 367 | or os.path.exists(pyproject_toml) 368 | or os.path.exists(versioneer_py) 369 | ): 370 | err = ("Versioneer was unable to run the project root directory. " 371 | "Versioneer requires setup.py to be executed from " 372 | "its immediate directory (like 'python setup.py COMMAND'), " 373 | "or in a way that lets it use sys.argv[0] to find the root " 374 | "(like 'python path/to/setup.py COMMAND').") 375 | raise VersioneerBadRootError(err) 376 | try: 377 | # Certain runtime workflows (setup.py install/develop in a setuptools 378 | # tree) execute all dependencies in a single python process, so 379 | # "versioneer" may be imported multiple times, and python's shared 380 | # module-import table will cache the first one. So we can't use 381 | # os.path.dirname(__file__), as that will find whichever 382 | # versioneer.py was first imported, even in later projects. 383 | my_path = os.path.realpath(os.path.abspath(__file__)) 384 | me_dir = os.path.normcase(os.path.splitext(my_path)[0]) 385 | vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) 386 | if me_dir != vsr_dir and "VERSIONEER_PEP518" not in globals(): 387 | print("Warning: build in %s is using versioneer.py from %s" 388 | % (os.path.dirname(my_path), versioneer_py)) 389 | except NameError: 390 | pass 391 | return root 392 | 393 | 394 | def get_config_from_root(root: str) -> VersioneerConfig: 395 | """Read the project setup.cfg file to determine Versioneer config.""" 396 | # This might raise OSError (if setup.cfg is missing), or 397 | # configparser.NoSectionError (if it lacks a [versioneer] section), or 398 | # configparser.NoOptionError (if it lacks "VCS="). See the docstring at 399 | # the top of versioneer.py for instructions on writing your setup.cfg . 400 | root_pth = Path(root) 401 | pyproject_toml = root_pth / "pyproject.toml" 402 | setup_cfg = root_pth / "setup.cfg" 403 | section: Union[Dict[str, Any], configparser.SectionProxy, None] = None 404 | if pyproject_toml.exists() and have_tomllib: 405 | try: 406 | with open(pyproject_toml, 'rb') as fobj: 407 | pp = tomllib.load(fobj) 408 | section = pp['tool']['versioneer'] 409 | except (tomllib.TOMLDecodeError, KeyError) as e: 410 | print(f"Failed to load config from {pyproject_toml}: {e}") 411 | print("Try to load it from setup.cfg") 412 | if not section: 413 | parser = configparser.ConfigParser() 414 | with open(setup_cfg) as cfg_file: 415 | parser.read_file(cfg_file) 416 | parser.get("versioneer", "VCS") # raise error if missing 417 | 418 | section = parser["versioneer"] 419 | 420 | # `cast`` really shouldn't be used, but its simplest for the 421 | # common VersioneerConfig users at the moment. We verify against 422 | # `None` values elsewhere where it matters 423 | 424 | cfg = VersioneerConfig() 425 | cfg.VCS = section['VCS'] 426 | cfg.style = section.get("style", "") 427 | cfg.versionfile_source = cast(str, section.get("versionfile_source")) 428 | cfg.versionfile_build = section.get("versionfile_build") 429 | cfg.tag_prefix = cast(str, section.get("tag_prefix")) 430 | if cfg.tag_prefix in ("''", '""', None): 431 | cfg.tag_prefix = "" 432 | cfg.parentdir_prefix = section.get("parentdir_prefix") 433 | if isinstance(section, configparser.SectionProxy): 434 | # Make sure configparser translates to bool 435 | cfg.verbose = section.getboolean("verbose") 436 | else: 437 | cfg.verbose = section.get("verbose") 438 | 439 | return cfg 440 | 441 | 442 | class NotThisMethod(Exception): 443 | """Exception raised if a method is not valid for the current scenario.""" 444 | 445 | 446 | # these dictionaries contain VCS-specific tools 447 | LONG_VERSION_PY: Dict[str, str] = {} 448 | HANDLERS: Dict[str, Dict[str, Callable]] = {} 449 | 450 | 451 | def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator 452 | """Create decorator to mark a method as the handler of a VCS.""" 453 | def decorate(f: Callable) -> Callable: 454 | """Store f in HANDLERS[vcs][method].""" 455 | HANDLERS.setdefault(vcs, {})[method] = f 456 | return f 457 | return decorate 458 | 459 | 460 | def run_command( 461 | commands: List[str], 462 | args: List[str], 463 | cwd: Optional[str] = None, 464 | verbose: bool = False, 465 | hide_stderr: bool = False, 466 | env: Optional[Dict[str, str]] = None, 467 | ) -> Tuple[Optional[str], Optional[int]]: 468 | """Call the given command(s).""" 469 | assert isinstance(commands, list) 470 | process = None 471 | 472 | popen_kwargs: Dict[str, Any] = {} 473 | if sys.platform == "win32": 474 | # This hides the console window if pythonw.exe is used 475 | startupinfo = subprocess.STARTUPINFO() 476 | startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW 477 | popen_kwargs["startupinfo"] = startupinfo 478 | 479 | for command in commands: 480 | try: 481 | dispcmd = str([command] + args) 482 | # remember shell=False, so use git.cmd on windows, not just git 483 | process = subprocess.Popen([command] + args, cwd=cwd, env=env, 484 | stdout=subprocess.PIPE, 485 | stderr=(subprocess.PIPE if hide_stderr 486 | else None), **popen_kwargs) 487 | break 488 | except OSError as e: 489 | if e.errno == errno.ENOENT: 490 | continue 491 | if verbose: 492 | print("unable to run %s" % dispcmd) 493 | print(e) 494 | return None, None 495 | else: 496 | if verbose: 497 | print("unable to find command, tried %s" % (commands,)) 498 | return None, None 499 | stdout = process.communicate()[0].strip().decode() 500 | if process.returncode != 0: 501 | if verbose: 502 | print("unable to run %s (error)" % dispcmd) 503 | print("stdout was %s" % stdout) 504 | return None, process.returncode 505 | return stdout, process.returncode 506 | 507 | 508 | LONG_VERSION_PY['git'] = r''' 509 | # This file helps to compute a version number in source trees obtained from 510 | # git-archive tarball (such as those provided by githubs download-from-tag 511 | # feature). Distribution tarballs (built by setup.py sdist) and build 512 | # directories (produced by setup.py build) will contain a much shorter file 513 | # that just contains the computed version number. 514 | 515 | # This file is released into the public domain. 516 | # Generated by versioneer-0.29 517 | # https://github.com/python-versioneer/python-versioneer 518 | 519 | """Git implementation of _version.py.""" 520 | 521 | import errno 522 | import os 523 | import re 524 | import subprocess 525 | import sys 526 | from typing import Any, Callable, Dict, List, Optional, Tuple 527 | import functools 528 | 529 | 530 | def get_keywords() -> Dict[str, str]: 531 | """Get the keywords needed to look up the version information.""" 532 | # these strings will be replaced by git during git-archive. 533 | # setup.py/versioneer.py will grep for the variable names, so they must 534 | # each be defined on a line of their own. _version.py will just call 535 | # get_keywords(). 536 | git_refnames = "%(DOLLAR)sFormat:%%d%(DOLLAR)s" 537 | git_full = "%(DOLLAR)sFormat:%%H%(DOLLAR)s" 538 | git_date = "%(DOLLAR)sFormat:%%ci%(DOLLAR)s" 539 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} 540 | return keywords 541 | 542 | 543 | class VersioneerConfig: 544 | """Container for Versioneer configuration parameters.""" 545 | 546 | VCS: str 547 | style: str 548 | tag_prefix: str 549 | parentdir_prefix: str 550 | versionfile_source: str 551 | verbose: bool 552 | 553 | 554 | def get_config() -> VersioneerConfig: 555 | """Create, populate and return the VersioneerConfig() object.""" 556 | # these strings are filled in when 'setup.py versioneer' creates 557 | # _version.py 558 | cfg = VersioneerConfig() 559 | cfg.VCS = "git" 560 | cfg.style = "%(STYLE)s" 561 | cfg.tag_prefix = "%(TAG_PREFIX)s" 562 | cfg.parentdir_prefix = "%(PARENTDIR_PREFIX)s" 563 | cfg.versionfile_source = "%(VERSIONFILE_SOURCE)s" 564 | cfg.verbose = False 565 | return cfg 566 | 567 | 568 | class NotThisMethod(Exception): 569 | """Exception raised if a method is not valid for the current scenario.""" 570 | 571 | 572 | LONG_VERSION_PY: Dict[str, str] = {} 573 | HANDLERS: Dict[str, Dict[str, Callable]] = {} 574 | 575 | 576 | def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator 577 | """Create decorator to mark a method as the handler of a VCS.""" 578 | def decorate(f: Callable) -> Callable: 579 | """Store f in HANDLERS[vcs][method].""" 580 | if vcs not in HANDLERS: 581 | HANDLERS[vcs] = {} 582 | HANDLERS[vcs][method] = f 583 | return f 584 | return decorate 585 | 586 | 587 | def run_command( 588 | commands: List[str], 589 | args: List[str], 590 | cwd: Optional[str] = None, 591 | verbose: bool = False, 592 | hide_stderr: bool = False, 593 | env: Optional[Dict[str, str]] = None, 594 | ) -> Tuple[Optional[str], Optional[int]]: 595 | """Call the given command(s).""" 596 | assert isinstance(commands, list) 597 | process = None 598 | 599 | popen_kwargs: Dict[str, Any] = {} 600 | if sys.platform == "win32": 601 | # This hides the console window if pythonw.exe is used 602 | startupinfo = subprocess.STARTUPINFO() 603 | startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW 604 | popen_kwargs["startupinfo"] = startupinfo 605 | 606 | for command in commands: 607 | try: 608 | dispcmd = str([command] + args) 609 | # remember shell=False, so use git.cmd on windows, not just git 610 | process = subprocess.Popen([command] + args, cwd=cwd, env=env, 611 | stdout=subprocess.PIPE, 612 | stderr=(subprocess.PIPE if hide_stderr 613 | else None), **popen_kwargs) 614 | break 615 | except OSError as e: 616 | if e.errno == errno.ENOENT: 617 | continue 618 | if verbose: 619 | print("unable to run %%s" %% dispcmd) 620 | print(e) 621 | return None, None 622 | else: 623 | if verbose: 624 | print("unable to find command, tried %%s" %% (commands,)) 625 | return None, None 626 | stdout = process.communicate()[0].strip().decode() 627 | if process.returncode != 0: 628 | if verbose: 629 | print("unable to run %%s (error)" %% dispcmd) 630 | print("stdout was %%s" %% stdout) 631 | return None, process.returncode 632 | return stdout, process.returncode 633 | 634 | 635 | def versions_from_parentdir( 636 | parentdir_prefix: str, 637 | root: str, 638 | verbose: bool, 639 | ) -> Dict[str, Any]: 640 | """Try to determine the version from the parent directory name. 641 | 642 | Source tarballs conventionally unpack into a directory that includes both 643 | the project name and a version string. We will also support searching up 644 | two directory levels for an appropriately named parent directory 645 | """ 646 | rootdirs = [] 647 | 648 | for _ in range(3): 649 | dirname = os.path.basename(root) 650 | if dirname.startswith(parentdir_prefix): 651 | return {"version": dirname[len(parentdir_prefix):], 652 | "full-revisionid": None, 653 | "dirty": False, "error": None, "date": None} 654 | rootdirs.append(root) 655 | root = os.path.dirname(root) # up a level 656 | 657 | if verbose: 658 | print("Tried directories %%s but none started with prefix %%s" %% 659 | (str(rootdirs), parentdir_prefix)) 660 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 661 | 662 | 663 | @register_vcs_handler("git", "get_keywords") 664 | def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: 665 | """Extract version information from the given file.""" 666 | # the code embedded in _version.py can just fetch the value of these 667 | # keywords. When used from setup.py, we don't want to import _version.py, 668 | # so we do it with a regexp instead. This function is not used from 669 | # _version.py. 670 | keywords: Dict[str, str] = {} 671 | try: 672 | with open(versionfile_abs, "r") as fobj: 673 | for line in fobj: 674 | if line.strip().startswith("git_refnames ="): 675 | mo = re.search(r'=\s*"(.*)"', line) 676 | if mo: 677 | keywords["refnames"] = mo.group(1) 678 | if line.strip().startswith("git_full ="): 679 | mo = re.search(r'=\s*"(.*)"', line) 680 | if mo: 681 | keywords["full"] = mo.group(1) 682 | if line.strip().startswith("git_date ="): 683 | mo = re.search(r'=\s*"(.*)"', line) 684 | if mo: 685 | keywords["date"] = mo.group(1) 686 | except OSError: 687 | pass 688 | return keywords 689 | 690 | 691 | @register_vcs_handler("git", "keywords") 692 | def git_versions_from_keywords( 693 | keywords: Dict[str, str], 694 | tag_prefix: str, 695 | verbose: bool, 696 | ) -> Dict[str, Any]: 697 | """Get version information from git keywords.""" 698 | if "refnames" not in keywords: 699 | raise NotThisMethod("Short version file found") 700 | date = keywords.get("date") 701 | if date is not None: 702 | # Use only the last line. Previous lines may contain GPG signature 703 | # information. 704 | date = date.splitlines()[-1] 705 | 706 | # git-2.2.0 added "%%cI", which expands to an ISO-8601 -compliant 707 | # datestamp. However we prefer "%%ci" (which expands to an "ISO-8601 708 | # -like" string, which we must then edit to make compliant), because 709 | # it's been around since git-1.5.3, and it's too difficult to 710 | # discover which version we're using, or to work around using an 711 | # older one. 712 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 713 | refnames = keywords["refnames"].strip() 714 | if refnames.startswith("$Format"): 715 | if verbose: 716 | print("keywords are unexpanded, not using") 717 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 718 | refs = {r.strip() for r in refnames.strip("()").split(",")} 719 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 720 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 721 | TAG = "tag: " 722 | tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} 723 | if not tags: 724 | # Either we're using git < 1.8.3, or there really are no tags. We use 725 | # a heuristic: assume all version tags have a digit. The old git %%d 726 | # expansion behaves like git log --decorate=short and strips out the 727 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 728 | # between branches and tags. By ignoring refnames without digits, we 729 | # filter out many common branch names like "release" and 730 | # "stabilization", as well as "HEAD" and "master". 731 | tags = {r for r in refs if re.search(r'\d', r)} 732 | if verbose: 733 | print("discarding '%%s', no digits" %% ",".join(refs - tags)) 734 | if verbose: 735 | print("likely tags: %%s" %% ",".join(sorted(tags))) 736 | for ref in sorted(tags): 737 | # sorting will prefer e.g. "2.0" over "2.0rc1" 738 | if ref.startswith(tag_prefix): 739 | r = ref[len(tag_prefix):] 740 | # Filter out refs that exactly match prefix or that don't start 741 | # with a number once the prefix is stripped (mostly a concern 742 | # when prefix is '') 743 | if not re.match(r'\d', r): 744 | continue 745 | if verbose: 746 | print("picking %%s" %% r) 747 | return {"version": r, 748 | "full-revisionid": keywords["full"].strip(), 749 | "dirty": False, "error": None, 750 | "date": date} 751 | # no suitable tags, so version is "0+unknown", but full hex is still there 752 | if verbose: 753 | print("no suitable tags, using unknown + full revision id") 754 | return {"version": "0+unknown", 755 | "full-revisionid": keywords["full"].strip(), 756 | "dirty": False, "error": "no suitable tags", "date": None} 757 | 758 | 759 | @register_vcs_handler("git", "pieces_from_vcs") 760 | def git_pieces_from_vcs( 761 | tag_prefix: str, 762 | root: str, 763 | verbose: bool, 764 | runner: Callable = run_command 765 | ) -> Dict[str, Any]: 766 | """Get version from 'git describe' in the root of the source tree. 767 | 768 | This only gets called if the git-archive 'subst' keywords were *not* 769 | expanded, and _version.py hasn't already been rewritten with a short 770 | version string, meaning we're inside a checked out source tree. 771 | """ 772 | GITS = ["git"] 773 | if sys.platform == "win32": 774 | GITS = ["git.cmd", "git.exe"] 775 | 776 | # GIT_DIR can interfere with correct operation of Versioneer. 777 | # It may be intended to be passed to the Versioneer-versioned project, 778 | # but that should not change where we get our version from. 779 | env = os.environ.copy() 780 | env.pop("GIT_DIR", None) 781 | runner = functools.partial(runner, env=env) 782 | 783 | _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, 784 | hide_stderr=not verbose) 785 | if rc != 0: 786 | if verbose: 787 | print("Directory %%s not under git control" %% root) 788 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 789 | 790 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 791 | # if there isn't one, this yields HEX[-dirty] (no NUM) 792 | describe_out, rc = runner(GITS, [ 793 | "describe", "--tags", "--dirty", "--always", "--long", 794 | "--match", f"{tag_prefix}[[:digit:]]*" 795 | ], cwd=root) 796 | # --long was added in git-1.5.5 797 | if describe_out is None: 798 | raise NotThisMethod("'git describe' failed") 799 | describe_out = describe_out.strip() 800 | full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) 801 | if full_out is None: 802 | raise NotThisMethod("'git rev-parse' failed") 803 | full_out = full_out.strip() 804 | 805 | pieces: Dict[str, Any] = {} 806 | pieces["long"] = full_out 807 | pieces["short"] = full_out[:7] # maybe improved later 808 | pieces["error"] = None 809 | 810 | branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], 811 | cwd=root) 812 | # --abbrev-ref was added in git-1.6.3 813 | if rc != 0 or branch_name is None: 814 | raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") 815 | branch_name = branch_name.strip() 816 | 817 | if branch_name == "HEAD": 818 | # If we aren't exactly on a branch, pick a branch which represents 819 | # the current commit. If all else fails, we are on a branchless 820 | # commit. 821 | branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) 822 | # --contains was added in git-1.5.4 823 | if rc != 0 or branches is None: 824 | raise NotThisMethod("'git branch --contains' returned error") 825 | branches = branches.split("\n") 826 | 827 | # Remove the first line if we're running detached 828 | if "(" in branches[0]: 829 | branches.pop(0) 830 | 831 | # Strip off the leading "* " from the list of branches. 832 | branches = [branch[2:] for branch in branches] 833 | if "master" in branches: 834 | branch_name = "master" 835 | elif not branches: 836 | branch_name = None 837 | else: 838 | # Pick the first branch that is returned. Good or bad. 839 | branch_name = branches[0] 840 | 841 | pieces["branch"] = branch_name 842 | 843 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 844 | # TAG might have hyphens. 845 | git_describe = describe_out 846 | 847 | # look for -dirty suffix 848 | dirty = git_describe.endswith("-dirty") 849 | pieces["dirty"] = dirty 850 | if dirty: 851 | git_describe = git_describe[:git_describe.rindex("-dirty")] 852 | 853 | # now we have TAG-NUM-gHEX or HEX 854 | 855 | if "-" in git_describe: 856 | # TAG-NUM-gHEX 857 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 858 | if not mo: 859 | # unparsable. Maybe git-describe is misbehaving? 860 | pieces["error"] = ("unable to parse git-describe output: '%%s'" 861 | %% describe_out) 862 | return pieces 863 | 864 | # tag 865 | full_tag = mo.group(1) 866 | if not full_tag.startswith(tag_prefix): 867 | if verbose: 868 | fmt = "tag '%%s' doesn't start with prefix '%%s'" 869 | print(fmt %% (full_tag, tag_prefix)) 870 | pieces["error"] = ("tag '%%s' doesn't start with prefix '%%s'" 871 | %% (full_tag, tag_prefix)) 872 | return pieces 873 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 874 | 875 | # distance: number of commits since tag 876 | pieces["distance"] = int(mo.group(2)) 877 | 878 | # commit: short hex revision ID 879 | pieces["short"] = mo.group(3) 880 | 881 | else: 882 | # HEX: no tags 883 | pieces["closest-tag"] = None 884 | out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) 885 | pieces["distance"] = len(out.split()) # total number of commits 886 | 887 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 888 | date = runner(GITS, ["show", "-s", "--format=%%ci", "HEAD"], cwd=root)[0].strip() 889 | # Use only the last line. Previous lines may contain GPG signature 890 | # information. 891 | date = date.splitlines()[-1] 892 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 893 | 894 | return pieces 895 | 896 | 897 | def plus_or_dot(pieces: Dict[str, Any]) -> str: 898 | """Return a + if we don't already have one, else return a .""" 899 | if "+" in pieces.get("closest-tag", ""): 900 | return "." 901 | return "+" 902 | 903 | 904 | def render_pep440(pieces: Dict[str, Any]) -> str: 905 | """Build up version string, with post-release "local version identifier". 906 | 907 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 908 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 909 | 910 | Exceptions: 911 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 912 | """ 913 | if pieces["closest-tag"]: 914 | rendered = pieces["closest-tag"] 915 | if pieces["distance"] or pieces["dirty"]: 916 | rendered += plus_or_dot(pieces) 917 | rendered += "%%d.g%%s" %% (pieces["distance"], pieces["short"]) 918 | if pieces["dirty"]: 919 | rendered += ".dirty" 920 | else: 921 | # exception #1 922 | rendered = "0+untagged.%%d.g%%s" %% (pieces["distance"], 923 | pieces["short"]) 924 | if pieces["dirty"]: 925 | rendered += ".dirty" 926 | return rendered 927 | 928 | 929 | def render_pep440_branch(pieces: Dict[str, Any]) -> str: 930 | """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . 931 | 932 | The ".dev0" means not master branch. Note that .dev0 sorts backwards 933 | (a feature branch will appear "older" than the master branch). 934 | 935 | Exceptions: 936 | 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] 937 | """ 938 | if pieces["closest-tag"]: 939 | rendered = pieces["closest-tag"] 940 | if pieces["distance"] or pieces["dirty"]: 941 | if pieces["branch"] != "master": 942 | rendered += ".dev0" 943 | rendered += plus_or_dot(pieces) 944 | rendered += "%%d.g%%s" %% (pieces["distance"], pieces["short"]) 945 | if pieces["dirty"]: 946 | rendered += ".dirty" 947 | else: 948 | # exception #1 949 | rendered = "0" 950 | if pieces["branch"] != "master": 951 | rendered += ".dev0" 952 | rendered += "+untagged.%%d.g%%s" %% (pieces["distance"], 953 | pieces["short"]) 954 | if pieces["dirty"]: 955 | rendered += ".dirty" 956 | return rendered 957 | 958 | 959 | def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: 960 | """Split pep440 version string at the post-release segment. 961 | 962 | Returns the release segments before the post-release and the 963 | post-release version number (or -1 if no post-release segment is present). 964 | """ 965 | vc = str.split(ver, ".post") 966 | return vc[0], int(vc[1] or 0) if len(vc) == 2 else None 967 | 968 | 969 | def render_pep440_pre(pieces: Dict[str, Any]) -> str: 970 | """TAG[.postN.devDISTANCE] -- No -dirty. 971 | 972 | Exceptions: 973 | 1: no tags. 0.post0.devDISTANCE 974 | """ 975 | if pieces["closest-tag"]: 976 | if pieces["distance"]: 977 | # update the post release segment 978 | tag_version, post_version = pep440_split_post(pieces["closest-tag"]) 979 | rendered = tag_version 980 | if post_version is not None: 981 | rendered += ".post%%d.dev%%d" %% (post_version + 1, pieces["distance"]) 982 | else: 983 | rendered += ".post0.dev%%d" %% (pieces["distance"]) 984 | else: 985 | # no commits, use the tag as the version 986 | rendered = pieces["closest-tag"] 987 | else: 988 | # exception #1 989 | rendered = "0.post0.dev%%d" %% pieces["distance"] 990 | return rendered 991 | 992 | 993 | def render_pep440_post(pieces: Dict[str, Any]) -> str: 994 | """TAG[.postDISTANCE[.dev0]+gHEX] . 995 | 996 | The ".dev0" means dirty. Note that .dev0 sorts backwards 997 | (a dirty tree will appear "older" than the corresponding clean one), 998 | but you shouldn't be releasing software with -dirty anyways. 999 | 1000 | Exceptions: 1001 | 1: no tags. 0.postDISTANCE[.dev0] 1002 | """ 1003 | if pieces["closest-tag"]: 1004 | rendered = pieces["closest-tag"] 1005 | if pieces["distance"] or pieces["dirty"]: 1006 | rendered += ".post%%d" %% pieces["distance"] 1007 | if pieces["dirty"]: 1008 | rendered += ".dev0" 1009 | rendered += plus_or_dot(pieces) 1010 | rendered += "g%%s" %% pieces["short"] 1011 | else: 1012 | # exception #1 1013 | rendered = "0.post%%d" %% pieces["distance"] 1014 | if pieces["dirty"]: 1015 | rendered += ".dev0" 1016 | rendered += "+g%%s" %% pieces["short"] 1017 | return rendered 1018 | 1019 | 1020 | def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: 1021 | """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . 1022 | 1023 | The ".dev0" means not master branch. 1024 | 1025 | Exceptions: 1026 | 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] 1027 | """ 1028 | if pieces["closest-tag"]: 1029 | rendered = pieces["closest-tag"] 1030 | if pieces["distance"] or pieces["dirty"]: 1031 | rendered += ".post%%d" %% pieces["distance"] 1032 | if pieces["branch"] != "master": 1033 | rendered += ".dev0" 1034 | rendered += plus_or_dot(pieces) 1035 | rendered += "g%%s" %% pieces["short"] 1036 | if pieces["dirty"]: 1037 | rendered += ".dirty" 1038 | else: 1039 | # exception #1 1040 | rendered = "0.post%%d" %% pieces["distance"] 1041 | if pieces["branch"] != "master": 1042 | rendered += ".dev0" 1043 | rendered += "+g%%s" %% pieces["short"] 1044 | if pieces["dirty"]: 1045 | rendered += ".dirty" 1046 | return rendered 1047 | 1048 | 1049 | def render_pep440_old(pieces: Dict[str, Any]) -> str: 1050 | """TAG[.postDISTANCE[.dev0]] . 1051 | 1052 | The ".dev0" means dirty. 1053 | 1054 | Exceptions: 1055 | 1: no tags. 0.postDISTANCE[.dev0] 1056 | """ 1057 | if pieces["closest-tag"]: 1058 | rendered = pieces["closest-tag"] 1059 | if pieces["distance"] or pieces["dirty"]: 1060 | rendered += ".post%%d" %% pieces["distance"] 1061 | if pieces["dirty"]: 1062 | rendered += ".dev0" 1063 | else: 1064 | # exception #1 1065 | rendered = "0.post%%d" %% pieces["distance"] 1066 | if pieces["dirty"]: 1067 | rendered += ".dev0" 1068 | return rendered 1069 | 1070 | 1071 | def render_git_describe(pieces: Dict[str, Any]) -> str: 1072 | """TAG[-DISTANCE-gHEX][-dirty]. 1073 | 1074 | Like 'git describe --tags --dirty --always'. 1075 | 1076 | Exceptions: 1077 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 1078 | """ 1079 | if pieces["closest-tag"]: 1080 | rendered = pieces["closest-tag"] 1081 | if pieces["distance"]: 1082 | rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) 1083 | else: 1084 | # exception #1 1085 | rendered = pieces["short"] 1086 | if pieces["dirty"]: 1087 | rendered += "-dirty" 1088 | return rendered 1089 | 1090 | 1091 | def render_git_describe_long(pieces: Dict[str, Any]) -> str: 1092 | """TAG-DISTANCE-gHEX[-dirty]. 1093 | 1094 | Like 'git describe --tags --dirty --always -long'. 1095 | The distance/hash is unconditional. 1096 | 1097 | Exceptions: 1098 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 1099 | """ 1100 | if pieces["closest-tag"]: 1101 | rendered = pieces["closest-tag"] 1102 | rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) 1103 | else: 1104 | # exception #1 1105 | rendered = pieces["short"] 1106 | if pieces["dirty"]: 1107 | rendered += "-dirty" 1108 | return rendered 1109 | 1110 | 1111 | def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: 1112 | """Render the given version pieces into the requested style.""" 1113 | if pieces["error"]: 1114 | return {"version": "unknown", 1115 | "full-revisionid": pieces.get("long"), 1116 | "dirty": None, 1117 | "error": pieces["error"], 1118 | "date": None} 1119 | 1120 | if not style or style == "default": 1121 | style = "pep440" # the default 1122 | 1123 | if style == "pep440": 1124 | rendered = render_pep440(pieces) 1125 | elif style == "pep440-branch": 1126 | rendered = render_pep440_branch(pieces) 1127 | elif style == "pep440-pre": 1128 | rendered = render_pep440_pre(pieces) 1129 | elif style == "pep440-post": 1130 | rendered = render_pep440_post(pieces) 1131 | elif style == "pep440-post-branch": 1132 | rendered = render_pep440_post_branch(pieces) 1133 | elif style == "pep440-old": 1134 | rendered = render_pep440_old(pieces) 1135 | elif style == "git-describe": 1136 | rendered = render_git_describe(pieces) 1137 | elif style == "git-describe-long": 1138 | rendered = render_git_describe_long(pieces) 1139 | else: 1140 | raise ValueError("unknown style '%%s'" %% style) 1141 | 1142 | return {"version": rendered, "full-revisionid": pieces["long"], 1143 | "dirty": pieces["dirty"], "error": None, 1144 | "date": pieces.get("date")} 1145 | 1146 | 1147 | def get_versions() -> Dict[str, Any]: 1148 | """Get version information or return default if unable to do so.""" 1149 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 1150 | # __file__, we can work backwards from there to the root. Some 1151 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 1152 | # case we can only use expanded keywords. 1153 | 1154 | cfg = get_config() 1155 | verbose = cfg.verbose 1156 | 1157 | try: 1158 | return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, 1159 | verbose) 1160 | except NotThisMethod: 1161 | pass 1162 | 1163 | try: 1164 | root = os.path.realpath(__file__) 1165 | # versionfile_source is the relative path from the top of the source 1166 | # tree (where the .git directory might live) to this file. Invert 1167 | # this to find the root from __file__. 1168 | for _ in cfg.versionfile_source.split('/'): 1169 | root = os.path.dirname(root) 1170 | except NameError: 1171 | return {"version": "0+unknown", "full-revisionid": None, 1172 | "dirty": None, 1173 | "error": "unable to find root of source tree", 1174 | "date": None} 1175 | 1176 | try: 1177 | pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) 1178 | return render(pieces, cfg.style) 1179 | except NotThisMethod: 1180 | pass 1181 | 1182 | try: 1183 | if cfg.parentdir_prefix: 1184 | return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 1185 | except NotThisMethod: 1186 | pass 1187 | 1188 | return {"version": "0+unknown", "full-revisionid": None, 1189 | "dirty": None, 1190 | "error": "unable to compute version", "date": None} 1191 | ''' 1192 | 1193 | 1194 | @register_vcs_handler("git", "get_keywords") 1195 | def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: 1196 | """Extract version information from the given file.""" 1197 | # the code embedded in _version.py can just fetch the value of these 1198 | # keywords. When used from setup.py, we don't want to import _version.py, 1199 | # so we do it with a regexp instead. This function is not used from 1200 | # _version.py. 1201 | keywords: Dict[str, str] = {} 1202 | try: 1203 | with open(versionfile_abs, "r") as fobj: 1204 | for line in fobj: 1205 | if line.strip().startswith("git_refnames ="): 1206 | mo = re.search(r'=\s*"(.*)"', line) 1207 | if mo: 1208 | keywords["refnames"] = mo.group(1) 1209 | if line.strip().startswith("git_full ="): 1210 | mo = re.search(r'=\s*"(.*)"', line) 1211 | if mo: 1212 | keywords["full"] = mo.group(1) 1213 | if line.strip().startswith("git_date ="): 1214 | mo = re.search(r'=\s*"(.*)"', line) 1215 | if mo: 1216 | keywords["date"] = mo.group(1) 1217 | except OSError: 1218 | pass 1219 | return keywords 1220 | 1221 | 1222 | @register_vcs_handler("git", "keywords") 1223 | def git_versions_from_keywords( 1224 | keywords: Dict[str, str], 1225 | tag_prefix: str, 1226 | verbose: bool, 1227 | ) -> Dict[str, Any]: 1228 | """Get version information from git keywords.""" 1229 | if "refnames" not in keywords: 1230 | raise NotThisMethod("Short version file found") 1231 | date = keywords.get("date") 1232 | if date is not None: 1233 | # Use only the last line. Previous lines may contain GPG signature 1234 | # information. 1235 | date = date.splitlines()[-1] 1236 | 1237 | # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant 1238 | # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 1239 | # -like" string, which we must then edit to make compliant), because 1240 | # it's been around since git-1.5.3, and it's too difficult to 1241 | # discover which version we're using, or to work around using an 1242 | # older one. 1243 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 1244 | refnames = keywords["refnames"].strip() 1245 | if refnames.startswith("$Format"): 1246 | if verbose: 1247 | print("keywords are unexpanded, not using") 1248 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 1249 | refs = {r.strip() for r in refnames.strip("()").split(",")} 1250 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 1251 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 1252 | TAG = "tag: " 1253 | tags = {r[len(TAG):] for r in refs if r.startswith(TAG)} 1254 | if not tags: 1255 | # Either we're using git < 1.8.3, or there really are no tags. We use 1256 | # a heuristic: assume all version tags have a digit. The old git %d 1257 | # expansion behaves like git log --decorate=short and strips out the 1258 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 1259 | # between branches and tags. By ignoring refnames without digits, we 1260 | # filter out many common branch names like "release" and 1261 | # "stabilization", as well as "HEAD" and "master". 1262 | tags = {r for r in refs if re.search(r'\d', r)} 1263 | if verbose: 1264 | print("discarding '%s', no digits" % ",".join(refs - tags)) 1265 | if verbose: 1266 | print("likely tags: %s" % ",".join(sorted(tags))) 1267 | for ref in sorted(tags): 1268 | # sorting will prefer e.g. "2.0" over "2.0rc1" 1269 | if ref.startswith(tag_prefix): 1270 | r = ref[len(tag_prefix):] 1271 | # Filter out refs that exactly match prefix or that don't start 1272 | # with a number once the prefix is stripped (mostly a concern 1273 | # when prefix is '') 1274 | if not re.match(r'\d', r): 1275 | continue 1276 | if verbose: 1277 | print("picking %s" % r) 1278 | return {"version": r, 1279 | "full-revisionid": keywords["full"].strip(), 1280 | "dirty": False, "error": None, 1281 | "date": date} 1282 | # no suitable tags, so version is "0+unknown", but full hex is still there 1283 | if verbose: 1284 | print("no suitable tags, using unknown + full revision id") 1285 | return {"version": "0+unknown", 1286 | "full-revisionid": keywords["full"].strip(), 1287 | "dirty": False, "error": "no suitable tags", "date": None} 1288 | 1289 | 1290 | @register_vcs_handler("git", "pieces_from_vcs") 1291 | def git_pieces_from_vcs( 1292 | tag_prefix: str, 1293 | root: str, 1294 | verbose: bool, 1295 | runner: Callable = run_command 1296 | ) -> Dict[str, Any]: 1297 | """Get version from 'git describe' in the root of the source tree. 1298 | 1299 | This only gets called if the git-archive 'subst' keywords were *not* 1300 | expanded, and _version.py hasn't already been rewritten with a short 1301 | version string, meaning we're inside a checked out source tree. 1302 | """ 1303 | GITS = ["git"] 1304 | if sys.platform == "win32": 1305 | GITS = ["git.cmd", "git.exe"] 1306 | 1307 | # GIT_DIR can interfere with correct operation of Versioneer. 1308 | # It may be intended to be passed to the Versioneer-versioned project, 1309 | # but that should not change where we get our version from. 1310 | env = os.environ.copy() 1311 | env.pop("GIT_DIR", None) 1312 | runner = functools.partial(runner, env=env) 1313 | 1314 | _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, 1315 | hide_stderr=not verbose) 1316 | if rc != 0: 1317 | if verbose: 1318 | print("Directory %s not under git control" % root) 1319 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 1320 | 1321 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 1322 | # if there isn't one, this yields HEX[-dirty] (no NUM) 1323 | describe_out, rc = runner(GITS, [ 1324 | "describe", "--tags", "--dirty", "--always", "--long", 1325 | "--match", f"{tag_prefix}[[:digit:]]*" 1326 | ], cwd=root) 1327 | # --long was added in git-1.5.5 1328 | if describe_out is None: 1329 | raise NotThisMethod("'git describe' failed") 1330 | describe_out = describe_out.strip() 1331 | full_out, rc = runner(GITS, ["rev-parse", "HEAD"], cwd=root) 1332 | if full_out is None: 1333 | raise NotThisMethod("'git rev-parse' failed") 1334 | full_out = full_out.strip() 1335 | 1336 | pieces: Dict[str, Any] = {} 1337 | pieces["long"] = full_out 1338 | pieces["short"] = full_out[:7] # maybe improved later 1339 | pieces["error"] = None 1340 | 1341 | branch_name, rc = runner(GITS, ["rev-parse", "--abbrev-ref", "HEAD"], 1342 | cwd=root) 1343 | # --abbrev-ref was added in git-1.6.3 1344 | if rc != 0 or branch_name is None: 1345 | raise NotThisMethod("'git rev-parse --abbrev-ref' returned error") 1346 | branch_name = branch_name.strip() 1347 | 1348 | if branch_name == "HEAD": 1349 | # If we aren't exactly on a branch, pick a branch which represents 1350 | # the current commit. If all else fails, we are on a branchless 1351 | # commit. 1352 | branches, rc = runner(GITS, ["branch", "--contains"], cwd=root) 1353 | # --contains was added in git-1.5.4 1354 | if rc != 0 or branches is None: 1355 | raise NotThisMethod("'git branch --contains' returned error") 1356 | branches = branches.split("\n") 1357 | 1358 | # Remove the first line if we're running detached 1359 | if "(" in branches[0]: 1360 | branches.pop(0) 1361 | 1362 | # Strip off the leading "* " from the list of branches. 1363 | branches = [branch[2:] for branch in branches] 1364 | if "master" in branches: 1365 | branch_name = "master" 1366 | elif not branches: 1367 | branch_name = None 1368 | else: 1369 | # Pick the first branch that is returned. Good or bad. 1370 | branch_name = branches[0] 1371 | 1372 | pieces["branch"] = branch_name 1373 | 1374 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 1375 | # TAG might have hyphens. 1376 | git_describe = describe_out 1377 | 1378 | # look for -dirty suffix 1379 | dirty = git_describe.endswith("-dirty") 1380 | pieces["dirty"] = dirty 1381 | if dirty: 1382 | git_describe = git_describe[:git_describe.rindex("-dirty")] 1383 | 1384 | # now we have TAG-NUM-gHEX or HEX 1385 | 1386 | if "-" in git_describe: 1387 | # TAG-NUM-gHEX 1388 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 1389 | if not mo: 1390 | # unparsable. Maybe git-describe is misbehaving? 1391 | pieces["error"] = ("unable to parse git-describe output: '%s'" 1392 | % describe_out) 1393 | return pieces 1394 | 1395 | # tag 1396 | full_tag = mo.group(1) 1397 | if not full_tag.startswith(tag_prefix): 1398 | if verbose: 1399 | fmt = "tag '%s' doesn't start with prefix '%s'" 1400 | print(fmt % (full_tag, tag_prefix)) 1401 | pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" 1402 | % (full_tag, tag_prefix)) 1403 | return pieces 1404 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 1405 | 1406 | # distance: number of commits since tag 1407 | pieces["distance"] = int(mo.group(2)) 1408 | 1409 | # commit: short hex revision ID 1410 | pieces["short"] = mo.group(3) 1411 | 1412 | else: 1413 | # HEX: no tags 1414 | pieces["closest-tag"] = None 1415 | out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) 1416 | pieces["distance"] = len(out.split()) # total number of commits 1417 | 1418 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 1419 | date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() 1420 | # Use only the last line. Previous lines may contain GPG signature 1421 | # information. 1422 | date = date.splitlines()[-1] 1423 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 1424 | 1425 | return pieces 1426 | 1427 | 1428 | def do_vcs_install(versionfile_source: str, ipy: Optional[str]) -> None: 1429 | """Git-specific installation logic for Versioneer. 1430 | 1431 | For Git, this means creating/changing .gitattributes to mark _version.py 1432 | for export-subst keyword substitution. 1433 | """ 1434 | GITS = ["git"] 1435 | if sys.platform == "win32": 1436 | GITS = ["git.cmd", "git.exe"] 1437 | files = [versionfile_source] 1438 | if ipy: 1439 | files.append(ipy) 1440 | if "VERSIONEER_PEP518" not in globals(): 1441 | try: 1442 | my_path = __file__ 1443 | if my_path.endswith((".pyc", ".pyo")): 1444 | my_path = os.path.splitext(my_path)[0] + ".py" 1445 | versioneer_file = os.path.relpath(my_path) 1446 | except NameError: 1447 | versioneer_file = "versioneer.py" 1448 | files.append(versioneer_file) 1449 | present = False 1450 | try: 1451 | with open(".gitattributes", "r") as fobj: 1452 | for line in fobj: 1453 | if line.strip().startswith(versionfile_source): 1454 | if "export-subst" in line.strip().split()[1:]: 1455 | present = True 1456 | break 1457 | except OSError: 1458 | pass 1459 | if not present: 1460 | with open(".gitattributes", "a+") as fobj: 1461 | fobj.write(f"{versionfile_source} export-subst\n") 1462 | files.append(".gitattributes") 1463 | run_command(GITS, ["add", "--"] + files) 1464 | 1465 | 1466 | def versions_from_parentdir( 1467 | parentdir_prefix: str, 1468 | root: str, 1469 | verbose: bool, 1470 | ) -> Dict[str, Any]: 1471 | """Try to determine the version from the parent directory name. 1472 | 1473 | Source tarballs conventionally unpack into a directory that includes both 1474 | the project name and a version string. We will also support searching up 1475 | two directory levels for an appropriately named parent directory 1476 | """ 1477 | rootdirs = [] 1478 | 1479 | for _ in range(3): 1480 | dirname = os.path.basename(root) 1481 | if dirname.startswith(parentdir_prefix): 1482 | return {"version": dirname[len(parentdir_prefix):], 1483 | "full-revisionid": None, 1484 | "dirty": False, "error": None, "date": None} 1485 | rootdirs.append(root) 1486 | root = os.path.dirname(root) # up a level 1487 | 1488 | if verbose: 1489 | print("Tried directories %s but none started with prefix %s" % 1490 | (str(rootdirs), parentdir_prefix)) 1491 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 1492 | 1493 | 1494 | SHORT_VERSION_PY = """ 1495 | # This file was generated by 'versioneer.py' (0.29) from 1496 | # revision-control system data, or from the parent directory name of an 1497 | # unpacked source archive. Distribution tarballs contain a pre-generated copy 1498 | # of this file. 1499 | 1500 | import json 1501 | 1502 | version_json = ''' 1503 | %s 1504 | ''' # END VERSION_JSON 1505 | 1506 | 1507 | def get_versions(): 1508 | return json.loads(version_json) 1509 | """ 1510 | 1511 | 1512 | def versions_from_file(filename: str) -> Dict[str, Any]: 1513 | """Try to determine the version from _version.py if present.""" 1514 | try: 1515 | with open(filename) as f: 1516 | contents = f.read() 1517 | except OSError: 1518 | raise NotThisMethod("unable to read _version.py") 1519 | mo = re.search(r"version_json = '''\n(.*)''' # END VERSION_JSON", 1520 | contents, re.M | re.S) 1521 | if not mo: 1522 | mo = re.search(r"version_json = '''\r\n(.*)''' # END VERSION_JSON", 1523 | contents, re.M | re.S) 1524 | if not mo: 1525 | raise NotThisMethod("no version_json in _version.py") 1526 | return json.loads(mo.group(1)) 1527 | 1528 | 1529 | def write_to_version_file(filename: str, versions: Dict[str, Any]) -> None: 1530 | """Write the given version number to the given _version.py file.""" 1531 | contents = json.dumps(versions, sort_keys=True, 1532 | indent=1, separators=(",", ": ")) 1533 | with open(filename, "w") as f: 1534 | f.write(SHORT_VERSION_PY % contents) 1535 | 1536 | print("set %s to '%s'" % (filename, versions["version"])) 1537 | 1538 | 1539 | def plus_or_dot(pieces: Dict[str, Any]) -> str: 1540 | """Return a + if we don't already have one, else return a .""" 1541 | if "+" in pieces.get("closest-tag", ""): 1542 | return "." 1543 | return "+" 1544 | 1545 | 1546 | def render_pep440(pieces: Dict[str, Any]) -> str: 1547 | """Build up version string, with post-release "local version identifier". 1548 | 1549 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 1550 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 1551 | 1552 | Exceptions: 1553 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 1554 | """ 1555 | if pieces["closest-tag"]: 1556 | rendered = pieces["closest-tag"] 1557 | if pieces["distance"] or pieces["dirty"]: 1558 | rendered += plus_or_dot(pieces) 1559 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 1560 | if pieces["dirty"]: 1561 | rendered += ".dirty" 1562 | else: 1563 | # exception #1 1564 | rendered = "0+untagged.%d.g%s" % (pieces["distance"], 1565 | pieces["short"]) 1566 | if pieces["dirty"]: 1567 | rendered += ".dirty" 1568 | return rendered 1569 | 1570 | 1571 | def render_pep440_branch(pieces: Dict[str, Any]) -> str: 1572 | """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . 1573 | 1574 | The ".dev0" means not master branch. Note that .dev0 sorts backwards 1575 | (a feature branch will appear "older" than the master branch). 1576 | 1577 | Exceptions: 1578 | 1: no tags. 0[.dev0]+untagged.DISTANCE.gHEX[.dirty] 1579 | """ 1580 | if pieces["closest-tag"]: 1581 | rendered = pieces["closest-tag"] 1582 | if pieces["distance"] or pieces["dirty"]: 1583 | if pieces["branch"] != "master": 1584 | rendered += ".dev0" 1585 | rendered += plus_or_dot(pieces) 1586 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 1587 | if pieces["dirty"]: 1588 | rendered += ".dirty" 1589 | else: 1590 | # exception #1 1591 | rendered = "0" 1592 | if pieces["branch"] != "master": 1593 | rendered += ".dev0" 1594 | rendered += "+untagged.%d.g%s" % (pieces["distance"], 1595 | pieces["short"]) 1596 | if pieces["dirty"]: 1597 | rendered += ".dirty" 1598 | return rendered 1599 | 1600 | 1601 | def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: 1602 | """Split pep440 version string at the post-release segment. 1603 | 1604 | Returns the release segments before the post-release and the 1605 | post-release version number (or -1 if no post-release segment is present). 1606 | """ 1607 | vc = str.split(ver, ".post") 1608 | return vc[0], int(vc[1] or 0) if len(vc) == 2 else None 1609 | 1610 | 1611 | def render_pep440_pre(pieces: Dict[str, Any]) -> str: 1612 | """TAG[.postN.devDISTANCE] -- No -dirty. 1613 | 1614 | Exceptions: 1615 | 1: no tags. 0.post0.devDISTANCE 1616 | """ 1617 | if pieces["closest-tag"]: 1618 | if pieces["distance"]: 1619 | # update the post release segment 1620 | tag_version, post_version = pep440_split_post(pieces["closest-tag"]) 1621 | rendered = tag_version 1622 | if post_version is not None: 1623 | rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"]) 1624 | else: 1625 | rendered += ".post0.dev%d" % (pieces["distance"]) 1626 | else: 1627 | # no commits, use the tag as the version 1628 | rendered = pieces["closest-tag"] 1629 | else: 1630 | # exception #1 1631 | rendered = "0.post0.dev%d" % pieces["distance"] 1632 | return rendered 1633 | 1634 | 1635 | def render_pep440_post(pieces: Dict[str, Any]) -> str: 1636 | """TAG[.postDISTANCE[.dev0]+gHEX] . 1637 | 1638 | The ".dev0" means dirty. Note that .dev0 sorts backwards 1639 | (a dirty tree will appear "older" than the corresponding clean one), 1640 | but you shouldn't be releasing software with -dirty anyways. 1641 | 1642 | Exceptions: 1643 | 1: no tags. 0.postDISTANCE[.dev0] 1644 | """ 1645 | if pieces["closest-tag"]: 1646 | rendered = pieces["closest-tag"] 1647 | if pieces["distance"] or pieces["dirty"]: 1648 | rendered += ".post%d" % pieces["distance"] 1649 | if pieces["dirty"]: 1650 | rendered += ".dev0" 1651 | rendered += plus_or_dot(pieces) 1652 | rendered += "g%s" % pieces["short"] 1653 | else: 1654 | # exception #1 1655 | rendered = "0.post%d" % pieces["distance"] 1656 | if pieces["dirty"]: 1657 | rendered += ".dev0" 1658 | rendered += "+g%s" % pieces["short"] 1659 | return rendered 1660 | 1661 | 1662 | def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: 1663 | """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . 1664 | 1665 | The ".dev0" means not master branch. 1666 | 1667 | Exceptions: 1668 | 1: no tags. 0.postDISTANCE[.dev0]+gHEX[.dirty] 1669 | """ 1670 | if pieces["closest-tag"]: 1671 | rendered = pieces["closest-tag"] 1672 | if pieces["distance"] or pieces["dirty"]: 1673 | rendered += ".post%d" % pieces["distance"] 1674 | if pieces["branch"] != "master": 1675 | rendered += ".dev0" 1676 | rendered += plus_or_dot(pieces) 1677 | rendered += "g%s" % pieces["short"] 1678 | if pieces["dirty"]: 1679 | rendered += ".dirty" 1680 | else: 1681 | # exception #1 1682 | rendered = "0.post%d" % pieces["distance"] 1683 | if pieces["branch"] != "master": 1684 | rendered += ".dev0" 1685 | rendered += "+g%s" % pieces["short"] 1686 | if pieces["dirty"]: 1687 | rendered += ".dirty" 1688 | return rendered 1689 | 1690 | 1691 | def render_pep440_old(pieces: Dict[str, Any]) -> str: 1692 | """TAG[.postDISTANCE[.dev0]] . 1693 | 1694 | The ".dev0" means dirty. 1695 | 1696 | Exceptions: 1697 | 1: no tags. 0.postDISTANCE[.dev0] 1698 | """ 1699 | if pieces["closest-tag"]: 1700 | rendered = pieces["closest-tag"] 1701 | if pieces["distance"] or pieces["dirty"]: 1702 | rendered += ".post%d" % pieces["distance"] 1703 | if pieces["dirty"]: 1704 | rendered += ".dev0" 1705 | else: 1706 | # exception #1 1707 | rendered = "0.post%d" % pieces["distance"] 1708 | if pieces["dirty"]: 1709 | rendered += ".dev0" 1710 | return rendered 1711 | 1712 | 1713 | def render_git_describe(pieces: Dict[str, Any]) -> str: 1714 | """TAG[-DISTANCE-gHEX][-dirty]. 1715 | 1716 | Like 'git describe --tags --dirty --always'. 1717 | 1718 | Exceptions: 1719 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 1720 | """ 1721 | if pieces["closest-tag"]: 1722 | rendered = pieces["closest-tag"] 1723 | if pieces["distance"]: 1724 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 1725 | else: 1726 | # exception #1 1727 | rendered = pieces["short"] 1728 | if pieces["dirty"]: 1729 | rendered += "-dirty" 1730 | return rendered 1731 | 1732 | 1733 | def render_git_describe_long(pieces: Dict[str, Any]) -> str: 1734 | """TAG-DISTANCE-gHEX[-dirty]. 1735 | 1736 | Like 'git describe --tags --dirty --always -long'. 1737 | The distance/hash is unconditional. 1738 | 1739 | Exceptions: 1740 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 1741 | """ 1742 | if pieces["closest-tag"]: 1743 | rendered = pieces["closest-tag"] 1744 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 1745 | else: 1746 | # exception #1 1747 | rendered = pieces["short"] 1748 | if pieces["dirty"]: 1749 | rendered += "-dirty" 1750 | return rendered 1751 | 1752 | 1753 | def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: 1754 | """Render the given version pieces into the requested style.""" 1755 | if pieces["error"]: 1756 | return {"version": "unknown", 1757 | "full-revisionid": pieces.get("long"), 1758 | "dirty": None, 1759 | "error": pieces["error"], 1760 | "date": None} 1761 | 1762 | if not style or style == "default": 1763 | style = "pep440" # the default 1764 | 1765 | if style == "pep440": 1766 | rendered = render_pep440(pieces) 1767 | elif style == "pep440-branch": 1768 | rendered = render_pep440_branch(pieces) 1769 | elif style == "pep440-pre": 1770 | rendered = render_pep440_pre(pieces) 1771 | elif style == "pep440-post": 1772 | rendered = render_pep440_post(pieces) 1773 | elif style == "pep440-post-branch": 1774 | rendered = render_pep440_post_branch(pieces) 1775 | elif style == "pep440-old": 1776 | rendered = render_pep440_old(pieces) 1777 | elif style == "git-describe": 1778 | rendered = render_git_describe(pieces) 1779 | elif style == "git-describe-long": 1780 | rendered = render_git_describe_long(pieces) 1781 | else: 1782 | raise ValueError("unknown style '%s'" % style) 1783 | 1784 | return {"version": rendered, "full-revisionid": pieces["long"], 1785 | "dirty": pieces["dirty"], "error": None, 1786 | "date": pieces.get("date")} 1787 | 1788 | 1789 | class VersioneerBadRootError(Exception): 1790 | """The project root directory is unknown or missing key files.""" 1791 | 1792 | 1793 | def get_versions(verbose: bool = False) -> Dict[str, Any]: 1794 | """Get the project version from whatever source is available. 1795 | 1796 | Returns dict with two keys: 'version' and 'full'. 1797 | """ 1798 | if "versioneer" in sys.modules: 1799 | # see the discussion in cmdclass.py:get_cmdclass() 1800 | del sys.modules["versioneer"] 1801 | 1802 | root = get_root() 1803 | cfg = get_config_from_root(root) 1804 | 1805 | assert cfg.VCS is not None, "please set [versioneer]VCS= in setup.cfg" 1806 | handlers = HANDLERS.get(cfg.VCS) 1807 | assert handlers, "unrecognized VCS '%s'" % cfg.VCS 1808 | verbose = verbose or bool(cfg.verbose) # `bool()` used to avoid `None` 1809 | assert cfg.versionfile_source is not None, \ 1810 | "please set versioneer.versionfile_source" 1811 | assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix" 1812 | 1813 | versionfile_abs = os.path.join(root, cfg.versionfile_source) 1814 | 1815 | # extract version from first of: _version.py, VCS command (e.g. 'git 1816 | # describe'), parentdir. This is meant to work for developers using a 1817 | # source checkout, for users of a tarball created by 'setup.py sdist', 1818 | # and for users of a tarball/zipball created by 'git archive' or github's 1819 | # download-from-tag feature or the equivalent in other VCSes. 1820 | 1821 | get_keywords_f = handlers.get("get_keywords") 1822 | from_keywords_f = handlers.get("keywords") 1823 | if get_keywords_f and from_keywords_f: 1824 | try: 1825 | keywords = get_keywords_f(versionfile_abs) 1826 | ver = from_keywords_f(keywords, cfg.tag_prefix, verbose) 1827 | if verbose: 1828 | print("got version from expanded keyword %s" % ver) 1829 | return ver 1830 | except NotThisMethod: 1831 | pass 1832 | 1833 | try: 1834 | ver = versions_from_file(versionfile_abs) 1835 | if verbose: 1836 | print("got version from file %s %s" % (versionfile_abs, ver)) 1837 | return ver 1838 | except NotThisMethod: 1839 | pass 1840 | 1841 | from_vcs_f = handlers.get("pieces_from_vcs") 1842 | if from_vcs_f: 1843 | try: 1844 | pieces = from_vcs_f(cfg.tag_prefix, root, verbose) 1845 | ver = render(pieces, cfg.style) 1846 | if verbose: 1847 | print("got version from VCS %s" % ver) 1848 | return ver 1849 | except NotThisMethod: 1850 | pass 1851 | 1852 | try: 1853 | if cfg.parentdir_prefix: 1854 | ver = versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 1855 | if verbose: 1856 | print("got version from parentdir %s" % ver) 1857 | return ver 1858 | except NotThisMethod: 1859 | pass 1860 | 1861 | if verbose: 1862 | print("unable to compute version") 1863 | 1864 | return {"version": "0+unknown", "full-revisionid": None, 1865 | "dirty": None, "error": "unable to compute version", 1866 | "date": None} 1867 | 1868 | 1869 | def get_version() -> str: 1870 | """Get the short version string for this project.""" 1871 | return get_versions()["version"] 1872 | 1873 | 1874 | def get_cmdclass(cmdclass: Optional[Dict[str, Any]] = None): 1875 | """Get the custom setuptools subclasses used by Versioneer. 1876 | 1877 | If the package uses a different cmdclass (e.g. one from numpy), it 1878 | should be provide as an argument. 1879 | """ 1880 | if "versioneer" in sys.modules: 1881 | del sys.modules["versioneer"] 1882 | # this fixes the "python setup.py develop" case (also 'install' and 1883 | # 'easy_install .'), in which subdependencies of the main project are 1884 | # built (using setup.py bdist_egg) in the same python process. Assume 1885 | # a main project A and a dependency B, which use different versions 1886 | # of Versioneer. A's setup.py imports A's Versioneer, leaving it in 1887 | # sys.modules by the time B's setup.py is executed, causing B to run 1888 | # with the wrong versioneer. Setuptools wraps the sub-dep builds in a 1889 | # sandbox that restores sys.modules to it's pre-build state, so the 1890 | # parent is protected against the child's "import versioneer". By 1891 | # removing ourselves from sys.modules here, before the child build 1892 | # happens, we protect the child from the parent's versioneer too. 1893 | # Also see https://github.com/python-versioneer/python-versioneer/issues/52 1894 | 1895 | cmds = {} if cmdclass is None else cmdclass.copy() 1896 | 1897 | # we add "version" to setuptools 1898 | from setuptools import Command 1899 | 1900 | class cmd_version(Command): 1901 | description = "report generated version string" 1902 | user_options: List[Tuple[str, str, str]] = [] 1903 | boolean_options: List[str] = [] 1904 | 1905 | def initialize_options(self) -> None: 1906 | pass 1907 | 1908 | def finalize_options(self) -> None: 1909 | pass 1910 | 1911 | def run(self) -> None: 1912 | vers = get_versions(verbose=True) 1913 | print("Version: %s" % vers["version"]) 1914 | print(" full-revisionid: %s" % vers.get("full-revisionid")) 1915 | print(" dirty: %s" % vers.get("dirty")) 1916 | print(" date: %s" % vers.get("date")) 1917 | if vers["error"]: 1918 | print(" error: %s" % vers["error"]) 1919 | cmds["version"] = cmd_version 1920 | 1921 | # we override "build_py" in setuptools 1922 | # 1923 | # most invocation pathways end up running build_py: 1924 | # distutils/build -> build_py 1925 | # distutils/install -> distutils/build ->.. 1926 | # setuptools/bdist_wheel -> distutils/install ->.. 1927 | # setuptools/bdist_egg -> distutils/install_lib -> build_py 1928 | # setuptools/install -> bdist_egg ->.. 1929 | # setuptools/develop -> ? 1930 | # pip install: 1931 | # copies source tree to a tempdir before running egg_info/etc 1932 | # if .git isn't copied too, 'git describe' will fail 1933 | # then does setup.py bdist_wheel, or sometimes setup.py install 1934 | # setup.py egg_info -> ? 1935 | 1936 | # pip install -e . and setuptool/editable_wheel will invoke build_py 1937 | # but the build_py command is not expected to copy any files. 1938 | 1939 | # we override different "build_py" commands for both environments 1940 | if 'build_py' in cmds: 1941 | _build_py: Any = cmds['build_py'] 1942 | else: 1943 | from setuptools.command.build_py import build_py as _build_py 1944 | 1945 | class cmd_build_py(_build_py): 1946 | def run(self) -> None: 1947 | root = get_root() 1948 | cfg = get_config_from_root(root) 1949 | versions = get_versions() 1950 | _build_py.run(self) 1951 | if getattr(self, "editable_mode", False): 1952 | # During editable installs `.py` and data files are 1953 | # not copied to build_lib 1954 | return 1955 | # now locate _version.py in the new build/ directory and replace 1956 | # it with an updated value 1957 | if cfg.versionfile_build: 1958 | target_versionfile = os.path.join(self.build_lib, 1959 | cfg.versionfile_build) 1960 | print("UPDATING %s" % target_versionfile) 1961 | write_to_version_file(target_versionfile, versions) 1962 | cmds["build_py"] = cmd_build_py 1963 | 1964 | if 'build_ext' in cmds: 1965 | _build_ext: Any = cmds['build_ext'] 1966 | else: 1967 | from setuptools.command.build_ext import build_ext as _build_ext 1968 | 1969 | class cmd_build_ext(_build_ext): 1970 | def run(self) -> None: 1971 | root = get_root() 1972 | cfg = get_config_from_root(root) 1973 | versions = get_versions() 1974 | _build_ext.run(self) 1975 | if self.inplace: 1976 | # build_ext --inplace will only build extensions in 1977 | # build/lib<..> dir with no _version.py to write to. 1978 | # As in place builds will already have a _version.py 1979 | # in the module dir, we do not need to write one. 1980 | return 1981 | # now locate _version.py in the new build/ directory and replace 1982 | # it with an updated value 1983 | if not cfg.versionfile_build: 1984 | return 1985 | target_versionfile = os.path.join(self.build_lib, 1986 | cfg.versionfile_build) 1987 | if not os.path.exists(target_versionfile): 1988 | print(f"Warning: {target_versionfile} does not exist, skipping " 1989 | "version update. This can happen if you are running build_ext " 1990 | "without first running build_py.") 1991 | return 1992 | print("UPDATING %s" % target_versionfile) 1993 | write_to_version_file(target_versionfile, versions) 1994 | cmds["build_ext"] = cmd_build_ext 1995 | 1996 | if "cx_Freeze" in sys.modules: # cx_freeze enabled? 1997 | from cx_Freeze.dist import build_exe as _build_exe # type: ignore 1998 | # nczeczulin reports that py2exe won't like the pep440-style string 1999 | # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. 2000 | # setup(console=[{ 2001 | # "version": versioneer.get_version().split("+", 1)[0], # FILEVERSION 2002 | # "product_version": versioneer.get_version(), 2003 | # ... 2004 | 2005 | class cmd_build_exe(_build_exe): 2006 | def run(self) -> None: 2007 | root = get_root() 2008 | cfg = get_config_from_root(root) 2009 | versions = get_versions() 2010 | target_versionfile = cfg.versionfile_source 2011 | print("UPDATING %s" % target_versionfile) 2012 | write_to_version_file(target_versionfile, versions) 2013 | 2014 | _build_exe.run(self) 2015 | os.unlink(target_versionfile) 2016 | with open(cfg.versionfile_source, "w") as f: 2017 | LONG = LONG_VERSION_PY[cfg.VCS] 2018 | f.write(LONG % 2019 | {"DOLLAR": "$", 2020 | "STYLE": cfg.style, 2021 | "TAG_PREFIX": cfg.tag_prefix, 2022 | "PARENTDIR_PREFIX": cfg.parentdir_prefix, 2023 | "VERSIONFILE_SOURCE": cfg.versionfile_source, 2024 | }) 2025 | cmds["build_exe"] = cmd_build_exe 2026 | del cmds["build_py"] 2027 | 2028 | if 'py2exe' in sys.modules: # py2exe enabled? 2029 | try: 2030 | from py2exe.setuptools_buildexe import py2exe as _py2exe # type: ignore 2031 | except ImportError: 2032 | from py2exe.distutils_buildexe import py2exe as _py2exe # type: ignore 2033 | 2034 | class cmd_py2exe(_py2exe): 2035 | def run(self) -> None: 2036 | root = get_root() 2037 | cfg = get_config_from_root(root) 2038 | versions = get_versions() 2039 | target_versionfile = cfg.versionfile_source 2040 | print("UPDATING %s" % target_versionfile) 2041 | write_to_version_file(target_versionfile, versions) 2042 | 2043 | _py2exe.run(self) 2044 | os.unlink(target_versionfile) 2045 | with open(cfg.versionfile_source, "w") as f: 2046 | LONG = LONG_VERSION_PY[cfg.VCS] 2047 | f.write(LONG % 2048 | {"DOLLAR": "$", 2049 | "STYLE": cfg.style, 2050 | "TAG_PREFIX": cfg.tag_prefix, 2051 | "PARENTDIR_PREFIX": cfg.parentdir_prefix, 2052 | "VERSIONFILE_SOURCE": cfg.versionfile_source, 2053 | }) 2054 | cmds["py2exe"] = cmd_py2exe 2055 | 2056 | # sdist farms its file list building out to egg_info 2057 | if 'egg_info' in cmds: 2058 | _egg_info: Any = cmds['egg_info'] 2059 | else: 2060 | from setuptools.command.egg_info import egg_info as _egg_info 2061 | 2062 | class cmd_egg_info(_egg_info): 2063 | def find_sources(self) -> None: 2064 | # egg_info.find_sources builds the manifest list and writes it 2065 | # in one shot 2066 | super().find_sources() 2067 | 2068 | # Modify the filelist and normalize it 2069 | root = get_root() 2070 | cfg = get_config_from_root(root) 2071 | self.filelist.append('versioneer.py') 2072 | if cfg.versionfile_source: 2073 | # There are rare cases where versionfile_source might not be 2074 | # included by default, so we must be explicit 2075 | self.filelist.append(cfg.versionfile_source) 2076 | self.filelist.sort() 2077 | self.filelist.remove_duplicates() 2078 | 2079 | # The write method is hidden in the manifest_maker instance that 2080 | # generated the filelist and was thrown away 2081 | # We will instead replicate their final normalization (to unicode, 2082 | # and POSIX-style paths) 2083 | from setuptools import unicode_utils 2084 | normalized = [unicode_utils.filesys_decode(f).replace(os.sep, '/') 2085 | for f in self.filelist.files] 2086 | 2087 | manifest_filename = os.path.join(self.egg_info, 'SOURCES.txt') 2088 | with open(manifest_filename, 'w') as fobj: 2089 | fobj.write('\n'.join(normalized)) 2090 | 2091 | cmds['egg_info'] = cmd_egg_info 2092 | 2093 | # we override different "sdist" commands for both environments 2094 | if 'sdist' in cmds: 2095 | _sdist: Any = cmds['sdist'] 2096 | else: 2097 | from setuptools.command.sdist import sdist as _sdist 2098 | 2099 | class cmd_sdist(_sdist): 2100 | def run(self) -> None: 2101 | versions = get_versions() 2102 | self._versioneer_generated_versions = versions 2103 | # unless we update this, the command will keep using the old 2104 | # version 2105 | self.distribution.metadata.version = versions["version"] 2106 | return _sdist.run(self) 2107 | 2108 | def make_release_tree(self, base_dir: str, files: List[str]) -> None: 2109 | root = get_root() 2110 | cfg = get_config_from_root(root) 2111 | _sdist.make_release_tree(self, base_dir, files) 2112 | # now locate _version.py in the new base_dir directory 2113 | # (remembering that it may be a hardlink) and replace it with an 2114 | # updated value 2115 | target_versionfile = os.path.join(base_dir, cfg.versionfile_source) 2116 | print("UPDATING %s" % target_versionfile) 2117 | write_to_version_file(target_versionfile, 2118 | self._versioneer_generated_versions) 2119 | cmds["sdist"] = cmd_sdist 2120 | 2121 | return cmds 2122 | 2123 | 2124 | CONFIG_ERROR = """ 2125 | setup.cfg is missing the necessary Versioneer configuration. You need 2126 | a section like: 2127 | 2128 | [versioneer] 2129 | VCS = git 2130 | style = pep440 2131 | versionfile_source = src/myproject/_version.py 2132 | versionfile_build = myproject/_version.py 2133 | tag_prefix = 2134 | parentdir_prefix = myproject- 2135 | 2136 | You will also need to edit your setup.py to use the results: 2137 | 2138 | import versioneer 2139 | setup(version=versioneer.get_version(), 2140 | cmdclass=versioneer.get_cmdclass(), ...) 2141 | 2142 | Please read the docstring in ./versioneer.py for configuration instructions, 2143 | edit setup.cfg, and re-run the installer or 'python versioneer.py setup'. 2144 | """ 2145 | 2146 | SAMPLE_CONFIG = """ 2147 | # See the docstring in versioneer.py for instructions. Note that you must 2148 | # re-run 'versioneer.py setup' after changing this section, and commit the 2149 | # resulting files. 2150 | 2151 | [versioneer] 2152 | #VCS = git 2153 | #style = pep440 2154 | #versionfile_source = 2155 | #versionfile_build = 2156 | #tag_prefix = 2157 | #parentdir_prefix = 2158 | 2159 | """ 2160 | 2161 | OLD_SNIPPET = """ 2162 | from ._version import get_versions 2163 | __version__ = get_versions()['version'] 2164 | del get_versions 2165 | """ 2166 | 2167 | INIT_PY_SNIPPET = """ 2168 | from . import {0} 2169 | __version__ = {0}.get_versions()['version'] 2170 | """ 2171 | 2172 | 2173 | def do_setup() -> int: 2174 | """Do main VCS-independent setup function for installing Versioneer.""" 2175 | root = get_root() 2176 | try: 2177 | cfg = get_config_from_root(root) 2178 | except (OSError, configparser.NoSectionError, 2179 | configparser.NoOptionError) as e: 2180 | if isinstance(e, (OSError, configparser.NoSectionError)): 2181 | print("Adding sample versioneer config to setup.cfg", 2182 | file=sys.stderr) 2183 | with open(os.path.join(root, "setup.cfg"), "a") as f: 2184 | f.write(SAMPLE_CONFIG) 2185 | print(CONFIG_ERROR, file=sys.stderr) 2186 | return 1 2187 | 2188 | print(" creating %s" % cfg.versionfile_source) 2189 | with open(cfg.versionfile_source, "w") as f: 2190 | LONG = LONG_VERSION_PY[cfg.VCS] 2191 | f.write(LONG % {"DOLLAR": "$", 2192 | "STYLE": cfg.style, 2193 | "TAG_PREFIX": cfg.tag_prefix, 2194 | "PARENTDIR_PREFIX": cfg.parentdir_prefix, 2195 | "VERSIONFILE_SOURCE": cfg.versionfile_source, 2196 | }) 2197 | 2198 | ipy = os.path.join(os.path.dirname(cfg.versionfile_source), 2199 | "__init__.py") 2200 | maybe_ipy: Optional[str] = ipy 2201 | if os.path.exists(ipy): 2202 | try: 2203 | with open(ipy, "r") as f: 2204 | old = f.read() 2205 | except OSError: 2206 | old = "" 2207 | module = os.path.splitext(os.path.basename(cfg.versionfile_source))[0] 2208 | snippet = INIT_PY_SNIPPET.format(module) 2209 | if OLD_SNIPPET in old: 2210 | print(" replacing boilerplate in %s" % ipy) 2211 | with open(ipy, "w") as f: 2212 | f.write(old.replace(OLD_SNIPPET, snippet)) 2213 | elif snippet not in old: 2214 | print(" appending to %s" % ipy) 2215 | with open(ipy, "a") as f: 2216 | f.write(snippet) 2217 | else: 2218 | print(" %s unmodified" % ipy) 2219 | else: 2220 | print(" %s doesn't exist, ok" % ipy) 2221 | maybe_ipy = None 2222 | 2223 | # Make VCS-specific changes. For git, this means creating/changing 2224 | # .gitattributes to mark _version.py for export-subst keyword 2225 | # substitution. 2226 | do_vcs_install(cfg.versionfile_source, maybe_ipy) 2227 | return 0 2228 | 2229 | 2230 | def scan_setup_py() -> int: 2231 | """Validate the contents of setup.py against Versioneer's expectations.""" 2232 | found = set() 2233 | setters = False 2234 | errors = 0 2235 | with open("setup.py", "r") as f: 2236 | for line in f.readlines(): 2237 | if "import versioneer" in line: 2238 | found.add("import") 2239 | if "versioneer.get_cmdclass()" in line: 2240 | found.add("cmdclass") 2241 | if "versioneer.get_version()" in line: 2242 | found.add("get_version") 2243 | if "versioneer.VCS" in line: 2244 | setters = True 2245 | if "versioneer.versionfile_source" in line: 2246 | setters = True 2247 | if len(found) != 3: 2248 | print("") 2249 | print("Your setup.py appears to be missing some important items") 2250 | print("(but I might be wrong). Please make sure it has something") 2251 | print("roughly like the following:") 2252 | print("") 2253 | print(" import versioneer") 2254 | print(" setup( version=versioneer.get_version(),") 2255 | print(" cmdclass=versioneer.get_cmdclass(), ...)") 2256 | print("") 2257 | errors += 1 2258 | if setters: 2259 | print("You should remove lines like 'versioneer.VCS = ' and") 2260 | print("'versioneer.versionfile_source = ' . This configuration") 2261 | print("now lives in setup.cfg, and should be removed from setup.py") 2262 | print("") 2263 | errors += 1 2264 | return errors 2265 | 2266 | 2267 | def setup_command() -> NoReturn: 2268 | """Set up Versioneer and exit with appropriate error code.""" 2269 | errors = do_setup() 2270 | errors += scan_setup_py() 2271 | sys.exit(1 if errors else 0) 2272 | 2273 | 2274 | if __name__ == "__main__": 2275 | cmd = sys.argv[1] 2276 | if cmd == "setup": 2277 | setup_command() 2278 | --------------------------------------------------------------------------------