├── pre_commit_hooks ├── tests │ ├── __init__.py │ ├── data │ │ ├── __init__.py │ │ ├── version-3.1.4024.22.tsproj │ │ ├── version-3.1.4024.55.tsproj │ │ ├── no-minimize-id-changes.plcproj │ │ └── minimize-id-changes-enabled.plcproj │ ├── test_minimize_id_changes.py │ └── test_check_twincat_versions.py ├── exceptions.py ├── __init__.py ├── twincat_st_newline.py ├── twincat_lineids_remover.py ├── minimize_id_changes.py ├── no_product_version.py ├── leading_tabs_remover.py ├── check_fixed_library_versions.py ├── xml_format.py ├── trailing_whitespace_fixer.py ├── check_twincat_versions.py └── _version.py ├── requirements.txt ├── .gitattributes ├── MANIFEST.in ├── setup.cfg ├── forTwinCatRepos ├── local │ ├── commit-helper.cmd │ └── .pre-commit-config.yaml └── .pre-commit-config.yaml ├── .gitignore ├── .github ├── CODEOWNERS └── workflows │ └── standard.yml ├── forPythonRepos └── .pre-commit-config.yaml ├── .pre-commit-config.yaml ├── setup.py ├── LICENSE.md ├── .pre-commit-hooks.yaml ├── README.md └── versioneer.py /pre_commit_hooks/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | lxml 2 | pytest 3 | -------------------------------------------------------------------------------- /pre_commit_hooks/tests/data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | pre_commit_hooks/_version.py export-subst 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include versioneer.py 2 | include pre_commit_hooks/_version.py 3 | -------------------------------------------------------------------------------- /pre_commit_hooks/exceptions.py: -------------------------------------------------------------------------------- 1 | class PreCommitException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /pre_commit_hooks/__init__.py: -------------------------------------------------------------------------------- 1 | from ._version import get_versions 2 | 3 | __version__ = get_versions()["version"] 4 | del get_versions 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [versioneer] 2 | VCS = git 3 | style = pep440 4 | versionfile_source = pre_commit_hooks/_version.py 5 | versionfile_build = pre_commit_hooks/_version.py 6 | tag_prefix = v 7 | 8 | [flake8] 9 | exclude = versioneer.py 10 | -------------------------------------------------------------------------------- /forTwinCatRepos/local/commit-helper.cmd: -------------------------------------------------------------------------------- 1 | C:\miniconda\scripts\conda activate plc-pre-commit 2 | C:\miniconda\envs\plc-pre-commit\scripts\pre-commit run --all-files -c C:\repos\pcds-pre-commit-hooks\forTwinCatRepos\local\.pre-commit-config.yaml 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | __pycache__ 3 | build 4 | *.pyc 5 | 6 | #Coverage Reports 7 | htmlcov 8 | .coverage 9 | .cache 10 | 11 | *.egg-info 12 | logs 13 | 14 | # Sphinx 15 | docs/source/generated/* 16 | 17 | #pytest 18 | .pytest_cache 19 | .vscode 20 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # default group 2 | * @pcdshub/python-reviewers 3 | 4 | # language-specific group(s) 5 | ## PYTHON files 6 | *.py @pcdshub/python-reviewers 7 | *.pyi @pcdshub/python-reviewers 8 | *.pyc @pcdshub/python-reviewers 9 | *.pyw @pcdshub/python-reviewers 10 | *.pyx @pcdshub/python-reviewers 11 | *.pyd @pcdshub/python-reviewers 12 | 13 | # github folder holds administrative files 14 | .github/** @pcdshub/software-admin 15 | -------------------------------------------------------------------------------- /pre_commit_hooks/tests/test_minimize_id_changes.py: -------------------------------------------------------------------------------- 1 | from importlib.resources import files 2 | 3 | import pytest 4 | 5 | import pre_commit_hooks.tests.data as test_data 6 | from pre_commit_hooks.minimize_id_changes import ( 7 | PreCommitException, 8 | minimize_id_changes_checked, 9 | ) 10 | 11 | 12 | def test_missing_minimize_id_changes(): 13 | plcproj_filename = files(test_data).joinpath("no-minimize-id-changes.plcproj") 14 | 15 | with pytest.raises(PreCommitException): 16 | minimize_id_changes_checked(plcproj_filename) 17 | 18 | 19 | def test_enabled_minimize_id_changes(): 20 | plcproj_filename = files(test_data).joinpath("minimize-id-changes-enabled.plcproj") 21 | 22 | # Doesnt raise PreCommitException 23 | minimize_id_changes_checked(plcproj_filename) 24 | -------------------------------------------------------------------------------- /forPythonRepos/.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks.git 5 | rev: v2.5.0 6 | hooks: 7 | - id: no-commit-to-branch 8 | - id: trailing-whitespace 9 | - id: end-of-file-fixer 10 | - id: check-ast 11 | - id: check-case-conflict 12 | - id: check-json 13 | - id: check-merge-conflict 14 | - id: check-symlinks 15 | - id: check-xml 16 | - id: check-yaml 17 | exclude: '^(conda-recipe/meta.yaml)$' 18 | - id: debug-statements 19 | 20 | - repo: https://github.com/pycqa/flake8.git 21 | rev: 3.7.9 22 | hooks: 23 | - id: flake8 24 | 25 | - repo: https://github.com/timothycrosley/isort.git 26 | rev: 4.3.21-2 27 | hooks: 28 | - id: isort 29 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks.git 5 | rev: v2.5.0 6 | hooks: 7 | - id: no-commit-to-branch 8 | - id: trailing-whitespace 9 | - id: end-of-file-fixer 10 | - id: check-ast 11 | - id: check-case-conflict 12 | - id: check-json 13 | - id: check-merge-conflict 14 | - id: check-symlinks 15 | - id: check-xml 16 | - id: check-yaml 17 | exclude: '^(conda-recipe/meta.yaml)$' 18 | - id: debug-statements 19 | 20 | - repo: https://github.com/astral-sh/ruff-pre-commit 21 | rev: v0.4.10 22 | hooks: 23 | # Run the linter. 24 | - id: ruff 25 | # --select I sorts imports 26 | args: [ --fix, --select, I ] 27 | # Run the formatter. 28 | - id: ruff-format 29 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | import versioneer 4 | 5 | with open("requirements.txt") as fd: 6 | requirements = [fd.read().splitlines()] 7 | 8 | hook_names = [ 9 | "twincat-lineids-remover", 10 | "leading-tabs-remover", 11 | "xml-format", 12 | "check-fixed-library-versions", 13 | "no-product-version", 14 | "twincat-st-newline", 15 | "minimize-id-changes", 16 | "check-twincat-versions", 17 | ] 18 | console_scripts = [] 19 | for name in hook_names: 20 | module = name.replace("-", "_") 21 | console_scripts.append(f"{name}=pre_commit_hooks.{module}:main") 22 | 23 | setup( 24 | name="pre-commit-hooks", 25 | version=versioneer.get_version(), 26 | cmdclass=versioneer.get_cmdclass(), 27 | author="SLAC National Accelerator Laboratory", 28 | packages=find_packages(), 29 | include_package_data=True, 30 | install_requires=requirements, 31 | description="SLAC LCLS custom pre-commit-hooks", 32 | entry_points={"console_scripts": console_scripts}, 33 | ) 34 | -------------------------------------------------------------------------------- /pre_commit_hooks/twincat_st_newline.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from lxml import etree 4 | 5 | 6 | def structured_text_formatter(path: str, lines: int = 1) -> None: 7 | root = etree.parse(path).getroot() 8 | sect = root.xpath(".//Declaration|.//Implementation/ST") 9 | if len(sect) == 0: 10 | return 11 | 12 | newlines = "\n" * lines 13 | for section in sect: 14 | try: 15 | section.text = etree.CDATA(newlines + section.text.strip("\n") + newlines) 16 | except AttributeError: 17 | pass 18 | etree.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True) 19 | 20 | 21 | def main(): 22 | parser = argparse.ArgumentParser() 23 | parser.add_argument("--lines", type=int, default=1) 24 | parser.add_argument("files", nargs="*") 25 | args = parser.parse_args() 26 | 27 | try: 28 | for file in args.files: 29 | structured_text_formatter(file, args.lines) 30 | except: 31 | return 1 32 | 33 | 34 | if __name__ == "__main__": 35 | exit(main()) 36 | -------------------------------------------------------------------------------- /pre_commit_hooks/twincat_lineids_remover.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | 5 | 6 | def fix_file(filename): 7 | with open(filename, "r", encoding="utf-8") as fd: 8 | original_lines = fd.readlines() 9 | new_lines = [] 10 | changed = False 11 | 12 | for line in original_lines: 13 | if "" in line: 14 | changed = True 15 | else: 16 | new_lines.append(line) 17 | 18 | if changed: 19 | print(f"Fixing {filename}") 20 | with open(filename, "w", encoding="utf-8") as fd: 21 | fd.write("".join(new_lines)) 22 | 23 | 24 | def main(args=None): 25 | if args is None: 26 | parser = argparse.ArgumentParser() 27 | parser.add_argument("filenames", nargs="*") 28 | args = parser.parse_args() 29 | filename = None 30 | try: 31 | for filename in args.filenames: 32 | fix_file(filename) 33 | return 0 34 | except Exception as exc: 35 | if filename is not None: 36 | print(f"Error while processing {filename}") 37 | print(exc) 38 | return 1 39 | 40 | 41 | if __name__ == "__main__": 42 | exit(main()) 43 | -------------------------------------------------------------------------------- /pre_commit_hooks/minimize_id_changes.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import xml.etree.ElementTree as ET 3 | from importlib.abc import Traversable 4 | from typing import Union 5 | 6 | from pre_commit_hooks.exceptions import PreCommitException 7 | 8 | 9 | def minimize_id_changes_checked(filename: Union[Traversable, str]) -> None: 10 | tree = ET.parse(filename) 11 | root = tree.getroot() 12 | 13 | combine_ids_element = root.find(".//{*}CombineIds") 14 | 15 | if combine_ids_element is None or combine_ids_element.text.lower() == "false": 16 | raise PreCommitException( 17 | f"Minimize id changes not checked in {filename}. " 18 | f"To enable this go to Project settings > Common and select 'Minimize Id changes in TwinCAT files.'" 19 | ) 20 | 21 | 22 | def main(args=None): 23 | if args is None: 24 | parser = argparse.ArgumentParser() 25 | parser.add_argument("filenames", nargs="*") 26 | args = parser.parse_args() 27 | try: 28 | for filename in args.filenames: 29 | minimize_id_changes_checked(filename) 30 | return 0 31 | except Exception as exc: 32 | print(exc) 33 | return 1 34 | 35 | 36 | if __name__ == "__main__": 37 | exit(main()) 38 | -------------------------------------------------------------------------------- /pre_commit_hooks/no_product_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | 5 | from lxml import etree 6 | 7 | from pre_commit_hooks.exceptions import PreCommitException 8 | 9 | 10 | def check_file(filename): 11 | with open(filename, "rb") as fd: 12 | original_xml = fd.read() 13 | 14 | xml_parser = etree.XMLParser(remove_blank_text=True) 15 | parse_tree = etree.XML(original_xml, parser=xml_parser).getroottree() 16 | 17 | tc_plc_object = list(parse_tree.iter("TcPlcObject"))[0].attrib 18 | # Check if it contains a product version attribute 19 | if "ProductVersion" in tc_plc_object: 20 | raise PreCommitException( 21 | f"Detected product version ({tc_plc_object['ProductVersion']}) in {filename}. " 22 | f"To disable this go to Project settings > Advanced and disable 'Write product version in files.'" 23 | ) 24 | 25 | 26 | def main(args=None): 27 | if args is None: 28 | parser = argparse.ArgumentParser() 29 | parser.add_argument("filenames", nargs="*") 30 | args = parser.parse_args() 31 | try: 32 | for filename in args.filenames: 33 | check_file(filename) 34 | return 0 35 | except Exception as exc: 36 | print(exc) 37 | return 1 38 | 39 | 40 | if __name__ == "__main__": 41 | exit(main()) 42 | -------------------------------------------------------------------------------- /pre_commit_hooks/leading_tabs_remover.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import re 5 | 6 | TAB_WIDTH = 4 7 | 8 | 9 | def fix_file(filename, tab_width=TAB_WIDTH): 10 | with open(filename, "r", encoding="utf-8") as fd: 11 | original_lines = fd.readlines() 12 | new_lines = [] 13 | changed = False 14 | # Match all leading whitespace and group it 15 | regex = re.compile(r"^(\s+)") 16 | for line in original_lines: 17 | match = regex.match(line) 18 | if match: 19 | leading_whitespace = match.groups()[0] 20 | # Fix if leading whitespace contain tabs 21 | if "\t" in leading_whitespace: 22 | changed = True 23 | line = leading_whitespace.replace("\t", " " * tab_width) + line.lstrip() 24 | new_lines.append(line) 25 | if changed: 26 | print(f"Fixing {filename}") 27 | with open(filename, "w", encoding="utf-8") as fd: 28 | fd.write("".join(new_lines)) 29 | 30 | 31 | def main(args=None): 32 | if args is None: 33 | parser = argparse.ArgumentParser() 34 | parser.add_argument("filenames", nargs="*") 35 | parser.add_argument("--tab-width", type=int, default=TAB_WIDTH) 36 | args = parser.parse_args() 37 | try: 38 | for filename in args.filenames: 39 | fix_file(filename, tab_width=args.tab_width) 40 | return 0 41 | except Exception as exc: 42 | print(exc) 43 | return 1 44 | 45 | 46 | if __name__ == "__main__": 47 | exit(main()) 48 | -------------------------------------------------------------------------------- /pre_commit_hooks/tests/data/version-3.1.4024.22.tsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | PlcTask 11 | 12 | 13 | 14 | 15 | 16 | 17 | PlcTcProberTests Instance 18 | {08500001-0000-0000-F000-000000000064} 19 | 20 | 21 | 0 22 | PlcTask 23 | 24 | #x02010030 25 | 26 | 20 27 | 10000000 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /forTwinCatRepos/.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks.git 5 | rev: v2.5.0 6 | hooks: 7 | - id: no-commit-to-branch 8 | - id: trailing-whitespace 9 | files: \.(TcPOU|TcDUT|TcGVL)$ 10 | 11 | - repo: https://github.com/pcdshub/pre-commit-hooks.git 12 | rev: v1.7.1 13 | hooks: 14 | - id: twincat-leading-tabs-remover 15 | - id: twincat-lineids-remover 16 | - id: twincat-xml-format 17 | - id: check-fixed-library-versions 18 | - id: no-product-version 19 | - id: twincat-st-newline 20 | # Check if minimize id changes is selected in the plc project file. 21 | # See https://www.youtube.com/watch?v=KKpBtaYjfWo&t=935s why to do this. 22 | - id: minimize-id-changes 23 | # Checks if TwinCAT versions match in different tsproj files, or if it matches the targeted one. 24 | - id: check-twincat-versions 25 | # Possible optional arguments 26 | # --target-version: Set a version that you want the tsproj file to have 27 | # --fix: Fix the version numbers if a target version is set 28 | # --reason: Add a reason to the error message in case of a non-matching version. 29 | # --pinned: Require the TwinCAT version to be pinned. Apply pinning if combined with --fix. 30 | # --no-pinned: Require the TwinCAT version to not be pinned. Remove pinning if combined with --fix. 31 | args: [--target-version=3.1.4024.20, --pinned, --fix, --reason="This version has a crucial new feature"] 32 | # Optional, if you use pytmc to generate EPICS IOCs: 33 | # - id: pytmc-pragma-linter 34 | -------------------------------------------------------------------------------- /forTwinCatRepos/local/.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: local 5 | hooks: 6 | - id: twincat-lineids-remover 7 | name: TwinCAT LineID Remover 8 | description: Eliminate TwinCAT line ID lines 9 | entry: C:/repos/pcds-pre-commit-hooks/pre_commit_hooks/twincat_lineids_remover.py 10 | language: python 11 | files: .*\.TcPOU$ 12 | - id: leading-tabs-remover 13 | name: Leading Tabs Remover 14 | description: Replace leading tabs with 4 spaces 15 | entry: C:/repos/pcds-pre-commit-hooks/pre_commit_hooks/leading_tabs_remover.py 16 | language: python 17 | - id: twincat-leading-tabs-remover 18 | name: TwinCAT Leading Tabs Remover 19 | description: leading-tabs-remover configured for TwinCAT 20 | entry: C:/repos/pcds-pre-commit-hooks/pre_commit_hooks/leading_tabs_remover.py 21 | language: python 22 | files: .*\.(TcPOU|TcDUT|TcGVL)$ 23 | - id: xml-format 24 | name: XML Formatter 25 | description: Use lxml to beautify xml files 26 | entry: C:/repos/pcds-pre-commit-hooks/pre_commit_hooks/xml_format.py 27 | language: python 28 | types: [xml] 29 | - id: twincat-xml-format 30 | name: TwinCAT XML Formatter 31 | description: xml-format configured for TwinCAT 32 | entry: C:/repos/pcds-pre-commit-hooks/pre_commit_hooks/xml_format.py 33 | language: python 34 | files: .*\.(tmc|tpy|xml)$ 35 | - id: trailing-whitespace 36 | files: \.(TcPOU|TcDUT|TcGVL)$ 37 | description: Fix trailing whitespace for TwinCAT source 38 | name: Trailing whitespace 39 | entry: C:/repos/pcds-pre-commit-hooks/pre_commit_hooks/trailing_whitespace_fixer.py 40 | language: python 41 | -------------------------------------------------------------------------------- /pre_commit_hooks/check_fixed_library_versions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | 5 | from lxml import etree 6 | 7 | from pre_commit_hooks.exceptions import PreCommitException 8 | 9 | 10 | def check_file(filename): 11 | with open(filename, "rb") as fd: 12 | original_xml = fd.read() 13 | 14 | xml_parser = etree.XMLParser(remove_blank_text=True) 15 | parse_tree = etree.XML(original_xml, parser=xml_parser).getroottree() 16 | 17 | added_libraries = set( 18 | el.attrib["Include"] for el in parse_tree.iter("{*}PlaceholderReference") 19 | ) 20 | fixed_version_libraries = set( 21 | el.attrib["Include"] for el in parse_tree.iter("{*}PlaceholderResolution") 22 | ) 23 | 24 | non_fixed_library_versions = added_libraries - fixed_version_libraries 25 | 26 | if len(non_fixed_library_versions) == 1: 27 | raise PreCommitException( 28 | ( 29 | f"Library version of {list(non_fixed_library_versions)[0]} is " 30 | f"not fixed! File parsed: {filename}" 31 | ) 32 | ) 33 | elif len(non_fixed_library_versions) > 1: 34 | raise PreCommitException( 35 | ( 36 | f"Library version of {', '.join(non_fixed_library_versions)} " 37 | f"are not fixed! File parsed: {filename}" 38 | ) 39 | ) 40 | 41 | 42 | def main(args=None): 43 | if args is None: 44 | parser = argparse.ArgumentParser() 45 | parser.add_argument("filenames", nargs="*") 46 | args = parser.parse_args() 47 | try: 48 | for filename in args.filenames: 49 | check_file(filename) 50 | return 0 51 | except Exception as exc: 52 | print(exc) 53 | return 1 54 | 55 | 56 | if __name__ == "__main__": 57 | exit(main()) 58 | -------------------------------------------------------------------------------- /pre_commit_hooks/tests/test_check_twincat_versions.py: -------------------------------------------------------------------------------- 1 | from importlib.resources import files 2 | 3 | import pytest 4 | 5 | import pre_commit_hooks.tests.data as test_data 6 | from pre_commit_hooks.check_twincat_versions import ( 7 | fix_pinned_version, 8 | fix_tc_version, 9 | get_tc_version, 10 | tc_version_pinned, 11 | ) 12 | 13 | 14 | @pytest.fixture 15 | def pinned_4024_44(): 16 | return files(test_data).joinpath("pinned-version-3.1.4024.44.tsproj").read_text() 17 | 18 | 19 | @pytest.fixture 20 | def not_pinned_4024_22(): 21 | return files(test_data).joinpath("version-3.1.4024.22.tsproj").read_text() 22 | 23 | 24 | @pytest.fixture 25 | def not_pinned_4024_55(): 26 | return files(test_data).joinpath("version-3.1.4024.55.tsproj").read_text() 27 | 28 | 29 | @pytest.fixture 30 | def not_pinned_4024_44(): 31 | return ( 32 | files(test_data).joinpath("not-pinned-version-3.1.4024.44.tsproj").read_text() 33 | ) 34 | 35 | 36 | def test_pinned_version(pinned_4024_44, not_pinned_4024_44): 37 | assert tc_version_pinned(pinned_4024_44) 38 | assert not tc_version_pinned(not_pinned_4024_44) 39 | 40 | 41 | def test_absent_pinned_version(not_pinned_4024_22): 42 | assert not tc_version_pinned(not_pinned_4024_22) 43 | 44 | 45 | def test_get_tc_version(pinned_4024_44, not_pinned_4024_22, not_pinned_4024_55): 46 | assert get_tc_version(pinned_4024_44) == "3.1.4024.44" 47 | assert get_tc_version(not_pinned_4024_22) == "3.1.4024.22" 48 | assert get_tc_version(not_pinned_4024_55) == "3.1.4024.55" 49 | 50 | 51 | def test_fix_tc_version(pinned_4024_44, not_pinned_4024_22): 52 | changed_version1 = fix_tc_version(pinned_4024_44, "3.1.4024.55") 53 | assert get_tc_version(changed_version1) == "3.1.4024.55" 54 | 55 | changed_version2 = fix_tc_version(not_pinned_4024_22, "3.1.4024.55") 56 | assert get_tc_version(changed_version2) == "3.1.4024.55" 57 | 58 | 59 | def test_fix_pinned_version(not_pinned_4024_22, not_pinned_4024_44, pinned_4024_44): 60 | changed_version = fix_pinned_version(not_pinned_4024_22, True) 61 | assert tc_version_pinned(changed_version) 62 | 63 | changed_version2 = fix_pinned_version(not_pinned_4024_44, True) 64 | assert tc_version_pinned(changed_version2) 65 | 66 | changed_version3 = fix_pinned_version(pinned_4024_44, False) 67 | assert not tc_version_pinned(changed_version3) 68 | -------------------------------------------------------------------------------- /.github/workflows/standard.yml: -------------------------------------------------------------------------------- 1 | name: PCDS pre-commit Testing 2 | 3 | on: 4 | push: 5 | pull_request: 6 | release: 7 | types: 8 | - created 9 | 10 | jobs: 11 | pre_commit: 12 | uses: pcdshub/pcds-ci-helpers/.github/workflows/pre-commit.yml@master 13 | with: 14 | args: "--all-files" 15 | 16 | test: 17 | name: "Python 3.12: pip" 18 | runs-on: ubuntu-latest 19 | 20 | defaults: 21 | run: 22 | # The following allows for each run step to utilize ~/.bash_profile 23 | # for setting up the per-step initial state. 24 | # --login: a login shell. Source ~/.bash_profile 25 | # -e: exit on first error 26 | # -o pipefail: piped processes are important; fail if they fail 27 | shell: bash --login -eo pipefail {0} 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 0 33 | submodules: 'recursive' 34 | 35 | - name: Check version to be built 36 | run: | 37 | # NOTE: If you run CI on your own fork, you may not have the right version 38 | # number for the package. Synchronize your tags with the upstream, 39 | # otherwise cross-dependencies may result in confusing build failure. 40 | (echo "Package version: $(git describe --tags)" | tee "$GITHUB_STEP_SUMMARY") || \ 41 | echo "::warning::Git tags not found in repository. Build may fail!" 42 | 43 | - name: Check environment variables for issues 44 | run: | 45 | echo "* Package to be built: pre-commit" 46 | 47 | - name: Prepare for log files 48 | run: | 49 | mkdir $HOME/logs 50 | 51 | - uses: actions/setup-python@v5 52 | with: 53 | python-version: "3.12" 54 | 55 | - name: Upgrade pip 56 | run: | 57 | pip install --upgrade pip 58 | 59 | - name: Install package 60 | run: | 61 | python -m pip install . 62 | 63 | - name: Check the pip packages in the test env 64 | run: | 65 | pip list 66 | 67 | - name: Run tests 68 | run: | 69 | pytest -v \ 70 | --log-file="$HOME/logs/debug_log.txt" \ 71 | --log-format='%(asctime)s.%(msecs)03d %(module)-15s %(levelname)-8s %(threadName)-10s %(message)s' \ 72 | --log-file-date-format='%H:%M:%S' \ 73 | --log-level=DEBUG \ 74 | 2>&1 | tee "$HOME/logs/pytest_log.txt" 75 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, The Board of Trustees of the Leland Stanford Junior 2 | University, through SLAC National Accelerator Laboratory (subject to receipt 3 | of any required approvals from the U.S. Dept. of Energy). All rights reserved. 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | (1) Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | (2) Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | (3) Neither the name of the Leland Stanford Junior University, SLAC National 15 | Accelerator Laboratory, U.S. Dept. of Energy nor the names of its 16 | contributors may be used to endorse or promote products derived from this 17 | software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER, THE UNITED STATES GOVERNMENT, 23 | OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 24 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT 25 | OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 26 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 27 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 28 | IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY 29 | OF SUCH DAMAGE. 30 | 31 | You are under no obligation whatsoever to provide any bug fixes, patches, or 32 | upgrades to the features, functionality or performance of the source code 33 | ("Enhancements") to anyone; however, if you choose to make your Enhancements 34 | available either publicly, or directly to SLAC National Accelerator Laboratory, 35 | without imposing a separate written license agreement for such Enhancements, 36 | then you hereby grant the following license: a non-exclusive, royalty-free 37 | perpetual license to install, use, modify, prepare derivative works, incorporate 38 | into other computer software, distribute, and sublicense such Enhancements or 39 | derivative works thereof, in binary and source code form. 40 | -------------------------------------------------------------------------------- /pre_commit_hooks/xml_format.py: -------------------------------------------------------------------------------- 1 | #!C:/miniconda/envs/plc-pre-commit/python.exe 2 | 3 | import argparse 4 | 5 | from lxml import etree 6 | 7 | TAB_WIDTH = 2 8 | RETRIES = 5 9 | 10 | 11 | def fix_file( 12 | filename: str, 13 | tab_width: int = TAB_WIDTH, 14 | retries: int = RETRIES, 15 | ) -> None: 16 | """ 17 | Read a file, fix it, write the fix back to the file handle 18 | """ 19 | # lxml throws encoding errors unless we work in binary mode 20 | with open(filename, "rb") as fd: 21 | original_xml = fd.read() 22 | iter_xml = original_xml 23 | 24 | for _ in range(retries): 25 | new_xml = xml_once( 26 | original_xml=iter_xml, 27 | tab_width=tab_width, 28 | ) 29 | if new_xml == iter_xml: 30 | break 31 | iter_xml = new_xml 32 | 33 | if new_xml != original_xml: 34 | print(f"Fixing {filename}") 35 | with open(filename, "wb") as fd: 36 | fd.write(new_xml) 37 | 38 | 39 | def xml_once(original_xml: str, tab_width: int) -> str: 40 | """ 41 | One iteration of the xml formatting. 42 | 43 | This may need to run several times due to inconsistencies in lxml. 44 | """ 45 | # lxml is the easiest cross-platform way to do this using pre-commit 46 | # xmllint is cross-platform but pre-commit does not help us set it up 47 | xml_parser = etree.XMLParser(remove_blank_text=True) 48 | parse_tree = etree.XML(original_xml, parser=xml_parser).getroottree() 49 | etree.indent(parse_tree, space=" " * tab_width) 50 | new_xml = etree.tostring( 51 | parse_tree, 52 | pretty_print=True, 53 | xml_declaration=True, 54 | encoding=parse_tree.docinfo.encoding, 55 | ) 56 | 57 | # lxml does not preserve line endings, so we must do it ourselves. 58 | # lxml always outputs with unix line endings (LF) 59 | if b"\r\n" in original_xml: 60 | new_xml = new_xml.replace(b"\n", b"\r\n") 61 | return new_xml 62 | 63 | 64 | def main(args=None): 65 | if args is None: 66 | parser = argparse.ArgumentParser() 67 | parser.add_argument("filenames", nargs="*") 68 | parser.add_argument("--tab-width", type=int, default=TAB_WIDTH) 69 | parser.add_argument("--retries", type=int, default=RETRIES) 70 | args = parser.parse_args() 71 | try: 72 | for filename in args.filenames: 73 | fix_file(filename, tab_width=args.tab_width) 74 | return 0 75 | except Exception as exc: 76 | print(exc) 77 | return 1 78 | 79 | 80 | if __name__ == "__main__": 81 | exit(main()) 82 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: twincat-lineids-remover 2 | name: TwinCAT LineID Remover 3 | description: Eliminate TwinCAT line ID lines 4 | entry: twincat-lineids-remover 5 | language: python 6 | files: .*\.TcPOU$ 7 | - id: leading-tabs-remover 8 | name: Leading Tabs Remover 9 | description: Replace leading tabs with 4 spaces 10 | entry: leading-tabs-remover 11 | language: python 12 | - id: twincat-leading-tabs-remover 13 | name: TwinCAT Leading Tabs Remover 14 | description: leading-tabs-remover configured for TwinCAT 15 | entry: leading-tabs-remover 16 | language: python 17 | files: .*\.(TcPOU|TcDUT|TcGVL)$ 18 | - id: xml-format 19 | name: XML Formatter 20 | description: Use lxml to beautify xml files 21 | entry: xml-format 22 | language: python 23 | types: [xml] 24 | - id: twincat-xml-format 25 | name: TwinCAT XML Formatter 26 | description: xml-format configured for TwinCAT 27 | entry: xml-format 28 | language: python 29 | files: .*\.(tmc|tpy|xml)$ 30 | - id: check-fixed-library-versions 31 | name: Check fixed library versions 32 | description: Checks if there are PLC libraries whos versions are not fixed. 33 | entry: check-fixed-library-versions 34 | language: python 35 | files: .*\.plcproj$ 36 | - id: pytmc-pragma-linter 37 | name: pytmc-pragma-linter 38 | description: Lint pytmc pragmas 39 | entry: bash -c 'set -ex; for fn in "$@"; do pytmc pragmalint "$fn"; done' -- 40 | language: python 41 | files: .*\.(TcPOU|TcGVL|TcDUT)$ 42 | additional_dependencies: ["pytmc"] 43 | - id: no-product-version 44 | name: Check for product version 45 | description: Checks if the product version is saved in the TwinCAT source file. 46 | entry: no-product-version 47 | language: python 48 | files: .*\.(TcPOU|TcDUT|TcGVL)$ 49 | - id: twincat-st-newline 50 | name: TwinCAT ST Newline Formatter 51 | description: Affixes newlines to ST segments 52 | entry: twincat-st-newline 53 | language: python 54 | files: .*\.(TcPOU|TcGVL|TcDUT)$ 55 | - id: minimize-id-changes 56 | name: Check minimize id changes checked 57 | description: Checks if the minimize id changes option is checked in the plcproj file. 58 | entry: minimize-id-changes 59 | language: python 60 | files: .*\.plcproj$ 61 | - id: check-twincat-versions 62 | name: Check if all TwinCAT versions match 63 | description: Checks if TwinCAT versions match in different tsproj and tspproj files, or if it matches the targeted one. 64 | entry: check-twincat-versions 65 | # All files need to be passed at once, else not all files are compared to eachother 66 | require_serial: true 67 | language: python 68 | files: .*\.(tsproj|tspproj)$ 69 | -------------------------------------------------------------------------------- /pre_commit_hooks/trailing_whitespace_fixer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import os 5 | from typing import Optional, Sequence 6 | 7 | 8 | def _fix_file( 9 | filename: str, 10 | is_markdown: bool, 11 | chars: Optional[bytes], 12 | ) -> bool: 13 | with open(filename, mode="rb") as file_processed: 14 | lines = file_processed.readlines() 15 | newlines = [_process_line(line, is_markdown, chars) for line in lines] 16 | if newlines != lines: 17 | with open(filename, mode="wb") as file_processed: 18 | for line in newlines: 19 | file_processed.write(line) 20 | return True 21 | else: 22 | return False 23 | 24 | 25 | def _process_line( 26 | line: bytes, 27 | is_markdown: bool, 28 | chars: Optional[bytes], 29 | ) -> bytes: 30 | if line[-2:] == b"\r\n": 31 | eol = b"\r\n" 32 | line = line[:-2] 33 | elif line[-1:] == b"\n": 34 | eol = b"\n" 35 | line = line[:-1] 36 | else: 37 | eol = b"" 38 | # preserve trailing two-space for non-blank lines in markdown files 39 | if is_markdown and (not line.isspace()) and line.endswith(b" "): 40 | return line[:-2].rstrip(chars) + b" " + eol 41 | return line.rstrip(chars) + eol 42 | 43 | 44 | def main(argv: Optional[Sequence[str]] = None) -> int: 45 | parser = argparse.ArgumentParser() 46 | parser.add_argument( 47 | "--no-markdown-linebreak-ext", 48 | action="store_true", 49 | help=argparse.SUPPRESS, 50 | ) 51 | parser.add_argument( 52 | "--markdown-linebreak-ext", 53 | action="append", 54 | default=[], 55 | metavar="*|EXT[,EXT,...]", 56 | help=( 57 | "Markdown extensions (or *) to not strip linebreak spaces. " 58 | "default: %(default)s" 59 | ), 60 | ) 61 | parser.add_argument( 62 | "--chars", 63 | help=( 64 | "The set of characters to strip from the end of lines. " 65 | "Defaults to all whitespace characters." 66 | ), 67 | ) 68 | parser.add_argument("filenames", nargs="*", help="Filenames to fix") 69 | args = parser.parse_args(argv) 70 | 71 | if args.no_markdown_linebreak_ext: 72 | print("--no-markdown-linebreak-ext now does nothing!") 73 | 74 | md_args = args.markdown_linebreak_ext 75 | if "" in md_args: 76 | parser.error("--markdown-linebreak-ext requires a non-empty argument") 77 | all_markdown = "*" in md_args 78 | # normalize extensions; split at ',', lowercase, and force 1 leading '.' 79 | md_exts = ["." + x.lower().lstrip(".") for x in ",".join(md_args).split(",")] 80 | 81 | # reject probable "eaten" filename as extension: skip leading '.' with [1:] 82 | for ext in md_exts: 83 | if any(c in ext[1:] for c in r"./\:"): 84 | parser.error( 85 | f"bad --markdown-linebreak-ext extension " 86 | f"{ext!r} (has . / \\ :)\n" 87 | f" (probably filename; use '--markdown-linebreak-ext=EXT')", 88 | ) 89 | chars = None if args.chars is None else args.chars.encode() 90 | return_code = 0 91 | for filename in args.filenames: 92 | _, extension = os.path.splitext(filename.lower()) 93 | md = all_markdown or extension in md_exts 94 | if _fix_file(filename, md, chars): 95 | print(f"Fixing {filename}") 96 | return_code = 1 97 | return return_code 98 | 99 | 100 | if __name__ == "__main__": 101 | exit(main()) 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pre-commit-hooks 2 | Pre-commit hooks for PCDS projects (https://pre-commit.com/) 3 | 4 | 5 | ### To install pre-commit on your machine: 6 | 7 | **On Linux,** use `$ pip install pre-commit` or `conda install pre-commit -c conda-forge` from your favorite python environment. 8 | **On Mac,** use `$ brew install pre-commit`, or follow the Linux instructions. 9 | **On Windows,** set up python either your favorite way or by using https://docs.conda.io/projects/conda/en/latest/user-guide/install/windows.html, and then follow the Linux instructions. 10 | 11 | ### To install pre-commit hooks to a local repository: 12 | 13 | If `.pre-config-config.yaml` does not already exist in the repository, copy 14 | the appropriate file from this repository to the top-level of your local 15 | repository, or add the folowing to an existing `.pre-config-config.yaml` 16 | file and commit the addition. 17 | 18 | ```yaml 19 | repos: 20 | - repo: https://github.com/pre-commit/pre-commit-hooks.git 21 | rev: v2.5.0 22 | hooks: 23 | - id: no-commit-to-branch 24 | - id: trailing-whitespace 25 | files: \.(TcPOU|TcDUT|TcGVL)$ 26 | 27 | - repo: https://github.com/pcdshub/pre-commit-hooks.git 28 | rev: v1.7.1 29 | hooks: 30 | - id: twincat-leading-tabs-remover 31 | - id: twincat-lineids-remover 32 | - id: twincat-xml-format 33 | - id: check-fixed-library-versions 34 | - id: no-product-version 35 | - id: twincat-st-newline 36 | # Check if minimize id changes is selected in the plc project file. 37 | # See https://www.youtube.com/watch?v=KKpBtaYjfWo&t=935s why to do this. 38 | - id: minimize-id-changes 39 | # Checks if TwinCAT versions match in different tsproj files, or if it matches the targeted one. 40 | - id: check-twincat-versions 41 | # Possible optional arguments 42 | # --target-version: Set a version that you want the tsproj file to have 43 | # --fix: Fix the version numbers if a target version is set 44 | # --reason: Add a reason to the error message in case of a non-matching version. 45 | # --pinned: Require the TwinCAT version to be pinned. Apply pinning if combined with --fix. 46 | # --no-pinned: Require the TwinCAT version to not be pinned. Remove pinning if combined with --fix. 47 | args: [--target-version=3.1.4024.20, --pinned, --fix, --reason="This version has a crucial new feature"] 48 | # Optional, if you use pytmc to generate EPICS IOCs: 49 | # - id: pytmc-pragma-linter 50 | ``` 51 | 52 | Once the file is there, run the following from inside your repository: 53 | ```bash 54 | $ pre-commit install # install for this repo based on the config 55 | $ pre-commit run --all-files # run on everything 56 | $ pre-commit run # run on staged 57 | $ git commit -am "test" # run pre-commit and - if successful - commit 58 | ``` 59 | 60 | ### Issues and client integration 61 | 62 | If pre-commit is not an available command, you may need to look into platform-specific configuration. Generally, you'll need to be in a shell environment that has access to python and with pre-commit installed as directed in the above sections. Typical issues include a misconfigured PATH variable and not having python available. For specific help on integrating with various clients and on various operation systems, see the sections below. If you solve other client integration problems for your favorite workflow, please expand this section in a pull request. 63 | 64 | #### Shell Integration on Windows 65 | 66 | - Make sure git is set up for normal shell use (a git installation option) if you want to use cmd or powershell 67 | - I have had success using the Anaconda Powershell Prompt and a conda environment with pre-commit installed 68 | 69 | #### Git Bash Integration on Windows 70 | 71 | - Add the following to your `~/.bash_profile`: `alias python='winpty python'`, to allow python to run without hanging. 72 | - If using conda, run `$ conda init bash`, using the conda.exe in your `~/miniconda3/scripts` folder. You may also want to set up your `~/.bash_profile` to `conda activate` your `pre-commit` environment. 73 | - Restart your shell after doing the above. 74 | 75 | #### TwinCAT Integrated Git on Windows 76 | 77 | - Someone needs to investigate this one. I don't have this running locally yet and am happy enough with the shells for now. 78 | 79 | #### VSCode Integration on Windows 80 | 81 | - Someone needs to figure out how to get the git extension to work here. 82 | - If your shell integration works, you can connect to this in the integrated terminal by setting `terminal.integrated.shellArgs.windows` to the same arguments as used in the Anaconda Powershell prompt shortcut's properties. 83 | -------------------------------------------------------------------------------- /pre_commit_hooks/tests/data/version-3.1.4024.55.tsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ARRAY [0..7] OF BOOL 6 | 64 7 | BOOL 8 | 9 | 0 10 | 8 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | PlcTask 19 | 20 | 21 | PlcTask1 22 | 23 | 24 | PlcTask2 25 | 26 | 27 | 28 | 29 | 30 | 31 | TcoCore Instance 32 | {08500001-0000-0000-F000-000000000064} 33 | 34 | 35 | 0 36 | PlcTask 37 | 38 | #x02010030 39 | 40 | 20 41 | 10000000 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | TcoCoreTests Instance 52 | {08500001-0000-0000-F000-000000000064} 53 | 54 | 55 | 0 56 | PlcTask1 57 | 58 | #x02010040 59 | 60 | 21 61 | 10000000 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | TcoCoreExamples Instance 72 | {08500001-0000-0000-F000-000000000064} 73 | 74 | PlcTask Inputs 75 | 76 | gMANIPULATOR_IO.Inputs 77 | ARRAY [0..7] OF BOOL 78 | 79 | 80 | 81 | PlcTask Outputs 82 | 83 | gMANIPULATOR_IO.Outputs 84 | ARRAY [0..7] OF BOOL 85 | 86 | 87 | 88 | 89 | 0 90 | PlcTask 91 | 92 | #x02010050 93 | 94 | 22 95 | 10000000 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /pre_commit_hooks/check_twincat_versions.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import re 3 | import xml.etree.ElementTree as ET 4 | 5 | from pre_commit_hooks.exceptions import PreCommitException 6 | 7 | 8 | def tc_version_pinned(xml_content: str) -> bool: 9 | root = ET.fromstring(xml_content) 10 | 11 | return ( 12 | "TcVersionFixed" in root.attrib and root.attrib.get("TcVersionFixed") == "true" 13 | ) 14 | 15 | 16 | def get_tc_version(xml_content: str) -> str: 17 | root = ET.fromstring(xml_content) 18 | 19 | return root.attrib.get("TcVersion") 20 | 21 | 22 | def fix_tc_version(xml_content: str, new_version: str) -> str: 23 | pattern = r'(TcVersion=")([^"]*)(")' 24 | new_xml_content = re.sub(pattern, r"\g<1>" + new_version + r"\g<3>", xml_content) 25 | 26 | return new_xml_content 27 | 28 | 29 | def fix_pinned_version(xml_content: str, pin_version: bool) -> str: 30 | new_value = "true" if pin_version else "false" 31 | 32 | pattern = r'(TcVersionFixed=")([^"]*)(")' 33 | 34 | if re.search(pattern, xml_content): 35 | new_xml_content = re.sub(pattern, r"\g<1>" + new_value + r"\g<3>", xml_content) 36 | else: 37 | version_pattern = r'(TcVersion="[^"]*")' 38 | new_xml_content = re.sub( 39 | version_pattern, r'\g<1> TcVersionFixed="' + new_value + r'"', xml_content 40 | ) 41 | 42 | return new_xml_content 43 | 44 | 45 | def main(args=None): 46 | if args is None: 47 | parser = argparse.ArgumentParser() 48 | parser.add_argument( 49 | "filenames", 50 | nargs="+", 51 | help="List of tsproj and tspproj filenames to process.", 52 | ) 53 | parser.add_argument( 54 | "--target-version", type=str, help="Target TwinCAT version to enforce." 55 | ) 56 | parser.add_argument( 57 | "--fix", 58 | action="store_true", 59 | help="Fix the versions if they do not match the target version and fix the pinned state if combined with --pinned/no-pinned.", 60 | ) 61 | parser.add_argument( 62 | "--reason", type=str, help="Reason for targeting a specific version." 63 | ) 64 | parser.add_argument( 65 | "--pinned", 66 | action=argparse.BooleanOptionalAction, 67 | help="Check if the TwinCAT version should be pinned. Applies or removes pinning if combined with --fix.", 68 | ) 69 | 70 | args = parser.parse_args() 71 | 72 | try: 73 | versions = {} 74 | pinned = {} 75 | for filename in args.filenames: 76 | with open(filename, "r", encoding="utf-8") as file: 77 | xml_content = file.read() 78 | versions[filename] = get_tc_version(xml_content) 79 | pinned[filename] = tc_version_pinned(xml_content) 80 | 81 | itemize = "\n -" 82 | exception_message = "" 83 | if args.target_version: 84 | mismatched_files = [ 85 | fname for fname, ver in versions.items() if ver != args.target_version 86 | ] 87 | if mismatched_files: 88 | reason_msg = f"\nReason: {args.reason}" if args.reason else "" 89 | if args.fix: 90 | for filename in mismatched_files: 91 | with open(filename, "r", encoding="utf-8") as file: 92 | xml_content = file.read() 93 | fixed_content = fix_tc_version(xml_content, args.target_version) 94 | with open(filename, "w", encoding="utf-8") as file: 95 | file.write(fixed_content) 96 | 97 | print( 98 | f"Fixed TwinCAT versions for:{itemize}{itemize.join(mismatched_files)}{reason_msg}" 99 | ) 100 | else: 101 | exception_message += ( 102 | "The following files are not set to the targeted TwinCAT version " 103 | f"{args.target_version}:{itemize}{itemize.join(mismatched_files)}{reason_msg}" 104 | ) 105 | else: 106 | unique_versions = set(versions.values()) 107 | if len(unique_versions) > 1: 108 | exception_message += ( 109 | "Not all files have the same TwinCAT version:" 110 | f"{itemize}" 111 | + itemize.join(f"{fname}: {ver}" for fname, ver in versions.items()) 112 | ) 113 | 114 | if args.pinned is not None: 115 | mismatched_files = [ 116 | fname for fname, pin in pinned.items() if pin != args.pinned 117 | ] 118 | if mismatched_files: 119 | if args.fix: 120 | for filename in mismatched_files: 121 | with open(filename, "r", encoding="utf-8") as file: 122 | xml_content = file.read() 123 | fixed_content = fix_pinned_version(xml_content, args.pinned) 124 | with open(filename, "w", encoding="utf-8") as file: 125 | file.write(fixed_content) 126 | print( 127 | f"Fixed pinned state for:{itemize}{itemize.join(mismatched_files)}" 128 | ) 129 | else: 130 | should_be_pinned_message = ( 131 | "The following files should have a pinned TwinCAT version" 132 | if args.pinned 133 | else "The following files should NOT have a pinned TwinCAT version" 134 | ) 135 | exception_message += "\n\n" if len(exception_message) > 0 else "" 136 | exception_message += f"{should_be_pinned_message}{itemize}{itemize.join(mismatched_files)}" 137 | if len(exception_message) > 0: 138 | raise PreCommitException(exception_message) 139 | 140 | return 0 141 | except Exception as exc: 142 | print(exc) 143 | return 1 144 | 145 | 146 | if __name__ == "__main__": 147 | exit(main()) 148 | -------------------------------------------------------------------------------- /pre_commit_hooks/tests/data/no-minimize-id-changes.plcproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1.0.0.0 5 | 2.0 6 | {1361c63e-c3f2-4555-94a3-e0395dcf0a4c} 7 | True 8 | true 9 | true 10 | false 11 | PLC 12 | 3.1.4024.0 13 | {83206d8f-62b4-4fa8-ab05-c17a527dee56} 14 | {a1a8971a-d41a-466d-9815-2a15bc6b2b6a} 15 | {73d4ce07-8d5e-4312-9201-83b075010509} 16 | {3f7c3e20-243c-4cff-be01-d0b2a5768750} 17 | {342ed85c-270a-468b-aba0-643e68b731fd} 18 | {a0dc4117-ad2d-4e28-9ee2-42c77070a57d} 19 | 20 | 21 | 22 | Code 23 | 24 | 25 | Code 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | Tc2_Standard, * (Beckhoff Automation GmbH) 37 | Tc2_Standard 38 | 39 | 40 | Tc2_System, * (Beckhoff Automation GmbH) 41 | Tc2_System 42 | 43 | 44 | Tc3_Module, * (Beckhoff Automation GmbH) 45 | Tc3_Module 46 | 47 | 48 | TcUnit, * (www.tcunit.org) 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | "<ProjectRoot>" 57 | 58 | {192FAD59-8248-4824-A8DE-9177C94C195A} 59 | 60 | "{192FAD59-8248-4824-A8DE-9177C94C195A}" 61 | 62 | 63 | 64 | {8F99A816-E488-41E4-9FA3-846536012284} 65 | 66 | "{8F99A816-E488-41E4-9FA3-846536012284}" 67 | 68 | 69 | 70 | {40450F57-0AA3-4216-96F3-5444ECB29763} 71 | 72 | "{40450F57-0AA3-4216-96F3-5444ECB29763}" 73 | 74 | 75 | ActiveVisuProfile 76 | IR0whWr8bwfwBwAAiD2qpQAAAABVAgAA37x72QAAAAABAAAAAAAAAAEaUwB5AHMAdABlAG0ALgBTAHQAcgBpAG4AZwACTHsAZgA5ADUAYgBiADQAMgA2AC0ANQA1ADIANAAtADQAYgA0ADUALQA5ADQAMAAwAC0AZgBiADAAZgAyAGUANwA3AGUANQAxAGIAfQADCE4AYQBtAGUABDBUAHcAaQBuAEMAQQBUACAAMwAuADEAIABCAHUAaQBsAGQAIAA0ADAAMgA0AC4ANwAFFlAAcgBvAGYAaQBsAGUARABhAHQAYQAGTHsAMQA2AGUANQA1AGIANgAwAC0ANwAwADQAMwAtADQAYQA2ADMALQBiADYANQBiAC0ANgAxADQANwAxADMAOAA3ADgAZAA0ADIAfQAHEkwAaQBiAHIAYQByAGkAZQBzAAhMewAzAGIAZgBkADUANAA1ADkALQBiADAANwBmAC0ANABkADYAZQAtAGEAZQAxAGEALQBhADgAMwAzADUANgBhADUANQAxADQAMgB9AAlMewA5AGMAOQA1ADgAOQA2ADgALQAyAGMAOAA1AC0ANAAxAGIAYgAtADgAOAA3ADEALQA4ADkANQBmAGYAMQBmAGUAZABlADEAYQB9AAoOVgBlAHIAcwBpAG8AbgALBmkAbgB0AAwKVQBzAGEAZwBlAA0KVABpAHQAbABlAA4aVgBpAHMAdQBFAGwAZQBtAE0AZQB0AGUAcgAPDkMAbwBtAHAAYQBuAHkAEAxTAHkAcwB0AGUAbQARElYAaQBzAHUARQBsAGUAbQBzABIwVgBpAHMAdQBFAGwAZQBtAHMAUwBwAGUAYwBpAGEAbABDAG8AbgB0AHIAbwBsAHMAEyhWAGkAcwB1AEUAbABlAG0AcwBXAGkAbgBDAG8AbgB0AHIAbwBsAHMAFCRWAGkAcwB1AEUAbABlAG0AVABlAHgAdABFAGQAaQB0AG8AcgAVIlYAaQBzAHUATgBhAHQAaQB2AGUAQwBvAG4AdAByAG8AbAAWFHYAaQBzAHUAaQBuAHAAdQB0AHMAFwxzAHkAcwB0AGUAbQAYGFYAaQBzAHUARQBsAGUAbQBCAGEAcwBlABkmRABlAHYAUABsAGEAYwBlAGgAbwBsAGQAZQByAHMAVQBzAGUAZAAaCGIAbwBvAGwAGyJQAGwAdQBnAGkAbgBDAG8AbgBzAHQAcgBhAGkAbgB0AHMAHEx7ADQAMwBkADUAMgBiAGMAZQAtADkANAAyAGMALQA0ADQAZAA3AC0AOQBlADkANAAtADEAYgBmAGQAZgAzADEAMABlADYAMwBjAH0AHRxBAHQATABlAGEAcwB0AFYAZQByAHMAaQBvAG4AHhRQAGwAdQBnAGkAbgBHAHUAaQBkAB8WUwB5AHMAdABlAG0ALgBHAHUAaQBkACBIYQBmAGMAZAA1ADQANAA2AC0ANAA5ADEANAAtADQAZgBlADcALQBiAGIANwA4AC0AOQBiAGYAZgBlAGIANwAwAGYAZAAxADcAIRRVAHAAZABhAHQAZQBJAG4AZgBvACJMewBiADAAMwAzADYANgBhADgALQBiADUAYwAwAC0ANABiADkAYQAtAGEAMAAwAGUALQBlAGIAOAA2ADAAMQAxADEAMAA0AGMAMwB9ACMOVQBwAGQAYQB0AGUAcwAkTHsAMQA4ADYAOABmAGYAYwA5AC0AZQA0AGYAYwAtADQANQAzADIALQBhAGMAMAA2AC0AMQBlADMAOQBiAGIANQA1ADcAYgA2ADkAfQAlTHsAYQA1AGIAZAA0ADgAYwAzAC0AMABkADEANwAtADQAMQBiADUALQBiADEANgA0AC0ANQBmAGMANgBhAGQAMgBiADkANgBiADcAfQAmFk8AYgBqAGUAYwB0AHMAVAB5AHAAZQAnVFUAcABkAGEAdABlAEwAYQBuAGcAdQBhAGcAZQBNAG8AZABlAGwARgBvAHIAQwBvAG4AdgBlAHIAdABpAGIAbABlAEwAaQBiAHIAYQByAGkAZQBzACgQTABpAGIAVABpAHQAbABlACkUTABpAGIAQwBvAG0AcABhAG4AeQAqHlUAcABkAGEAdABlAFAAcgBvAHYAaQBkAGUAcgBzACs4UwB5AHMAdABlAG0ALgBDAG8AbABsAGUAYwB0AGkAbwBuAHMALgBIAGEAcwBoAHQAYQBiAGwAZQAsEnYAaQBzAHUAZQBsAGUAbQBzAC1INgBjAGIAMQBjAGQAZQAxAC0AZAA1AGQAYwAtADQAYQAzAGIALQA5ADAANQA0AC0AMgAxAGYAYQA3ADUANgBhADMAZgBhADQALihJAG4AdABlAHIAZgBhAGMAZQBWAGUAcgBzAGkAbwBuAEkAbgBmAG8AL0x7AGMANgAxADEAZQA0ADAAMAAtADcAZgBiADkALQA0AGMAMwA1AC0AYgA5AGEAYwAtADQAZQAzADEANABiADUAOQA5ADYANAAzAH0AMBhNAGEAagBvAHIAVgBlAHIAcwBpAG8AbgAxGE0AaQBuAG8AcgBWAGUAcgBzAGkAbwBuADIMTABlAGcAYQBjAHkAMzBMAGEAbgBnAHUAYQBnAGUATQBvAGQAZQBsAFYAZQByAHMAaQBvAG4ASQBuAGYAbwA0MEwAbwBhAGQATABpAGIAcgBhAHIAaQBlAHMASQBuAHQAbwBQAHIAbwBqAGUAYwB0ADUaQwBvAG0AcABhAHQAaQBiAGkAbABpAHQAeQDQAAIaA9ADAS0E0AUGGgfQBwgaAUUHCQjQAAkaBEUKCwQDAAAABQAAAA0AAAAAAAAA0AwLrQIAAADQDQEtDtAPAS0Q0AAJGgRFCgsEAwAAAAUAAAANAAAAKAAAANAMC60BAAAA0A0BLRHQDwEtENAACRoERQoLBAMAAAAFAAAADQAAAAAAAADQDAutAgAAANANAS0S0A8BLRDQAAkaBEUKCwQDAAAABQAAAA0AAAAUAAAA0AwLrQIAAADQDQEtE9APAS0Q0AAJGgRFCgsEAwAAAAUAAAANAAAAAAAAANAMC60CAAAA0A0BLRTQDwEtENAACRoERQoLBAMAAAAFAAAADQAAAAAAAADQDAutAgAAANANAS0V0A8BLRDQAAkaBEUKCwQDAAAABQAAAA0AAAAAAAAA0AwLrQIAAADQDQEtFtAPAS0X0AAJGgRFCgsEAwAAAAUAAAANAAAAKAAAANAMC60EAAAA0A0BLRjQDwEtENAZGq0BRRscAdAAHBoCRR0LBAMAAAAFAAAADQAAAAAAAADQHh8tINAhIhoCRSMkAtAAJRoFRQoLBAMAAAADAAAAAAAAAAoAAADQJgutAAAAANADAS0n0CgBLRHQKQEtENAAJRoFRQoLBAMAAAADAAAAAAAAAAoAAADQJgutAQAAANADAS0n0CgBLRHQKQEtEJoqKwFFAAEC0AABLSzQAAEtF9AAHy0t0C4vGgPQMAutAQAAANAxC60XAAAA0DIarQDQMy8aA9AwC60CAAAA0DELrQMAAADQMhqtANA0Gq0A0DUarQA= 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | System.Collections.Hashtable 85 | {54dd0eac-a6d8-46f2-8c27-2f43c7e49861} 86 | System.String 87 | 88 | 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /pre_commit_hooks/tests/data/minimize-id-changes-enabled.plcproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1.0.0.0 5 | 2.0 6 | {1361c63e-c3f2-4555-94a3-e0395dcf0a4c} 7 | True 8 | true 9 | true 10 | false 11 | PLC 12 | 3.1.4024.0 13 | {83206d8f-62b4-4fa8-ab05-c17a527dee56} 14 | {a1a8971a-d41a-466d-9815-2a15bc6b2b6a} 15 | {73d4ce07-8d5e-4312-9201-83b075010509} 16 | {3f7c3e20-243c-4cff-be01-d0b2a5768750} 17 | {342ed85c-270a-468b-aba0-643e68b731fd} 18 | {a0dc4117-ad2d-4e28-9ee2-42c77070a57d} 19 | false 20 | true 21 | 22 | 23 | 24 | Code 25 | 26 | 27 | Code 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | Tc2_Standard, * (Beckhoff Automation GmbH) 39 | Tc2_Standard 40 | 41 | 42 | Tc2_System, * (Beckhoff Automation GmbH) 43 | Tc2_System 44 | 45 | 46 | Tc3_Module, * (Beckhoff Automation GmbH) 47 | Tc3_Module 48 | 49 | 50 | TcUnit, * (www.tcunit.org) 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | "<ProjectRoot>" 59 | 60 | {192FAD59-8248-4824-A8DE-9177C94C195A} 61 | 62 | "{192FAD59-8248-4824-A8DE-9177C94C195A}" 63 | 64 | 65 | 66 | {8F99A816-E488-41E4-9FA3-846536012284} 67 | 68 | "{8F99A816-E488-41E4-9FA3-846536012284}" 69 | 70 | 71 | 72 | {40450F57-0AA3-4216-96F3-5444ECB29763} 73 | 74 | "{40450F57-0AA3-4216-96F3-5444ECB29763}" 75 | 76 | 77 | ActiveVisuProfile 78 | IR0whWr8bwfwBwAAiD2qpQAAAABVAgAA37x72QAAAAABAAAAAAAAAAEaUwB5AHMAdABlAG0ALgBTAHQAcgBpAG4AZwACTHsAZgA5ADUAYgBiADQAMgA2AC0ANQA1ADIANAAtADQAYgA0ADUALQA5ADQAMAAwAC0AZgBiADAAZgAyAGUANwA3AGUANQAxAGIAfQADCE4AYQBtAGUABDBUAHcAaQBuAEMAQQBUACAAMwAuADEAIABCAHUAaQBsAGQAIAA0ADAAMgA0AC4ANwAFFlAAcgBvAGYAaQBsAGUARABhAHQAYQAGTHsAMQA2AGUANQA1AGIANgAwAC0ANwAwADQAMwAtADQAYQA2ADMALQBiADYANQBiAC0ANgAxADQANwAxADMAOAA3ADgAZAA0ADIAfQAHEkwAaQBiAHIAYQByAGkAZQBzAAhMewAzAGIAZgBkADUANAA1ADkALQBiADAANwBmAC0ANABkADYAZQAtAGEAZQAxAGEALQBhADgAMwAzADUANgBhADUANQAxADQAMgB9AAlMewA5AGMAOQA1ADgAOQA2ADgALQAyAGMAOAA1AC0ANAAxAGIAYgAtADgAOAA3ADEALQA4ADkANQBmAGYAMQBmAGUAZABlADEAYQB9AAoOVgBlAHIAcwBpAG8AbgALBmkAbgB0AAwKVQBzAGEAZwBlAA0KVABpAHQAbABlAA4aVgBpAHMAdQBFAGwAZQBtAE0AZQB0AGUAcgAPDkMAbwBtAHAAYQBuAHkAEAxTAHkAcwB0AGUAbQARElYAaQBzAHUARQBsAGUAbQBzABIwVgBpAHMAdQBFAGwAZQBtAHMAUwBwAGUAYwBpAGEAbABDAG8AbgB0AHIAbwBsAHMAEyhWAGkAcwB1AEUAbABlAG0AcwBXAGkAbgBDAG8AbgB0AHIAbwBsAHMAFCRWAGkAcwB1AEUAbABlAG0AVABlAHgAdABFAGQAaQB0AG8AcgAVIlYAaQBzAHUATgBhAHQAaQB2AGUAQwBvAG4AdAByAG8AbAAWFHYAaQBzAHUAaQBuAHAAdQB0AHMAFwxzAHkAcwB0AGUAbQAYGFYAaQBzAHUARQBsAGUAbQBCAGEAcwBlABkmRABlAHYAUABsAGEAYwBlAGgAbwBsAGQAZQByAHMAVQBzAGUAZAAaCGIAbwBvAGwAGyJQAGwAdQBnAGkAbgBDAG8AbgBzAHQAcgBhAGkAbgB0AHMAHEx7ADQAMwBkADUAMgBiAGMAZQAtADkANAAyAGMALQA0ADQAZAA3AC0AOQBlADkANAAtADEAYgBmAGQAZgAzADEAMABlADYAMwBjAH0AHRxBAHQATABlAGEAcwB0AFYAZQByAHMAaQBvAG4AHhRQAGwAdQBnAGkAbgBHAHUAaQBkAB8WUwB5AHMAdABlAG0ALgBHAHUAaQBkACBIYQBmAGMAZAA1ADQANAA2AC0ANAA5ADEANAAtADQAZgBlADcALQBiAGIANwA4AC0AOQBiAGYAZgBlAGIANwAwAGYAZAAxADcAIRRVAHAAZABhAHQAZQBJAG4AZgBvACJMewBiADAAMwAzADYANgBhADgALQBiADUAYwAwAC0ANABiADkAYQAtAGEAMAAwAGUALQBlAGIAOAA2ADAAMQAxADEAMAA0AGMAMwB9ACMOVQBwAGQAYQB0AGUAcwAkTHsAMQA4ADYAOABmAGYAYwA5AC0AZQA0AGYAYwAtADQANQAzADIALQBhAGMAMAA2AC0AMQBlADMAOQBiAGIANQA1ADcAYgA2ADkAfQAlTHsAYQA1AGIAZAA0ADgAYwAzAC0AMABkADEANwAtADQAMQBiADUALQBiADEANgA0AC0ANQBmAGMANgBhAGQAMgBiADkANgBiADcAfQAmFk8AYgBqAGUAYwB0AHMAVAB5AHAAZQAnVFUAcABkAGEAdABlAEwAYQBuAGcAdQBhAGcAZQBNAG8AZABlAGwARgBvAHIAQwBvAG4AdgBlAHIAdABpAGIAbABlAEwAaQBiAHIAYQByAGkAZQBzACgQTABpAGIAVABpAHQAbABlACkUTABpAGIAQwBvAG0AcABhAG4AeQAqHlUAcABkAGEAdABlAFAAcgBvAHYAaQBkAGUAcgBzACs4UwB5AHMAdABlAG0ALgBDAG8AbABsAGUAYwB0AGkAbwBuAHMALgBIAGEAcwBoAHQAYQBiAGwAZQAsEnYAaQBzAHUAZQBsAGUAbQBzAC1INgBjAGIAMQBjAGQAZQAxAC0AZAA1AGQAYwAtADQAYQAzAGIALQA5ADAANQA0AC0AMgAxAGYAYQA3ADUANgBhADMAZgBhADQALihJAG4AdABlAHIAZgBhAGMAZQBWAGUAcgBzAGkAbwBuAEkAbgBmAG8AL0x7AGMANgAxADEAZQA0ADAAMAAtADcAZgBiADkALQA0AGMAMwA1AC0AYgA5AGEAYwAtADQAZQAzADEANABiADUAOQA5ADYANAAzAH0AMBhNAGEAagBvAHIAVgBlAHIAcwBpAG8AbgAxGE0AaQBuAG8AcgBWAGUAcgBzAGkAbwBuADIMTABlAGcAYQBjAHkAMzBMAGEAbgBnAHUAYQBnAGUATQBvAGQAZQBsAFYAZQByAHMAaQBvAG4ASQBuAGYAbwA0MEwAbwBhAGQATABpAGIAcgBhAHIAaQBlAHMASQBuAHQAbwBQAHIAbwBqAGUAYwB0ADUaQwBvAG0AcABhAHQAaQBiAGkAbABpAHQAeQDQAAIaA9ADAS0E0AUGGgfQBwgaAUUHCQjQAAkaBEUKCwQDAAAABQAAAA0AAAAAAAAA0AwLrQIAAADQDQEtDtAPAS0Q0AAJGgRFCgsEAwAAAAUAAAANAAAAKAAAANAMC60BAAAA0A0BLRHQDwEtENAACRoERQoLBAMAAAAFAAAADQAAAAAAAADQDAutAgAAANANAS0S0A8BLRDQAAkaBEUKCwQDAAAABQAAAA0AAAAUAAAA0AwLrQIAAADQDQEtE9APAS0Q0AAJGgRFCgsEAwAAAAUAAAANAAAAAAAAANAMC60CAAAA0A0BLRTQDwEtENAACRoERQoLBAMAAAAFAAAADQAAAAAAAADQDAutAgAAANANAS0V0A8BLRDQAAkaBEUKCwQDAAAABQAAAA0AAAAAAAAA0AwLrQIAAADQDQEtFtAPAS0X0AAJGgRFCgsEAwAAAAUAAAANAAAAKAAAANAMC60EAAAA0A0BLRjQDwEtENAZGq0BRRscAdAAHBoCRR0LBAMAAAAFAAAADQAAAAAAAADQHh8tINAhIhoCRSMkAtAAJRoFRQoLBAMAAAADAAAAAAAAAAoAAADQJgutAAAAANADAS0n0CgBLRHQKQEtENAAJRoFRQoLBAMAAAADAAAAAAAAAAoAAADQJgutAQAAANADAS0n0CgBLRHQKQEtEJoqKwFFAAEC0AABLSzQAAEtF9AAHy0t0C4vGgPQMAutAQAAANAxC60XAAAA0DIarQDQMy8aA9AwC60CAAAA0DELrQMAAADQMhqtANA0Gq0A0DUarQA= 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | System.Collections.Hashtable 87 | {54dd0eac-a6d8-46f2-8c27-2f43c7e49861} 88 | System.String 89 | 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /pre_commit_hooks/_version.py: -------------------------------------------------------------------------------- 1 | # This file helps to compute a version number in source trees obtained from 2 | # git-archive tarball (such as those provided by githubs download-from-tag 3 | # feature). Distribution tarballs (built by setup.py sdist) and build 4 | # directories (produced by setup.py build) will contain a much shorter file 5 | # that just contains the computed version number. 6 | 7 | # This file is released into the public domain. Generated by 8 | # versioneer-0.18 (https://github.com/warner/python-versioneer) 9 | 10 | """Git implementation of _version.py.""" 11 | 12 | import errno 13 | import os 14 | import re 15 | import subprocess 16 | import sys 17 | 18 | 19 | def get_keywords(): 20 | """Get the keywords needed to look up the version information.""" 21 | # these strings will be replaced by git during git-archive. 22 | # setup.py/versioneer.py will grep for the variable names, so they must 23 | # each be defined on a line of their own. _version.py will just call 24 | # get_keywords(). 25 | git_refnames = " (HEAD -> master, tag: v1.7.2)" 26 | git_full = "ee731f486a9e711a804637e73904e302aec1dcc8" 27 | git_date = "2025-10-07 13:34:09 -0700" 28 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} 29 | return keywords 30 | 31 | 32 | class VersioneerConfig: 33 | """Container for Versioneer configuration parameters.""" 34 | 35 | 36 | def get_config(): 37 | """Create, populate and return the VersioneerConfig() object.""" 38 | # these strings are filled in when 'setup.py versioneer' creates 39 | # _version.py 40 | cfg = VersioneerConfig() 41 | cfg.VCS = "git" 42 | cfg.style = "pep440" 43 | cfg.tag_prefix = "v" 44 | cfg.parentdir_prefix = "None" 45 | cfg.versionfile_source = "pre_commit_hooks/_version.py" 46 | cfg.verbose = False 47 | return cfg 48 | 49 | 50 | class NotThisMethod(Exception): 51 | """Exception raised if a method is not valid for the current scenario.""" 52 | 53 | 54 | LONG_VERSION_PY = {} 55 | HANDLERS = {} 56 | 57 | 58 | def register_vcs_handler(vcs, method): # decorator 59 | """Decorator to mark a method as the handler for a particular VCS.""" 60 | 61 | def decorate(f): 62 | """Store f in HANDLERS[vcs][method].""" 63 | if vcs not in HANDLERS: 64 | HANDLERS[vcs] = {} 65 | HANDLERS[vcs][method] = f 66 | return f 67 | 68 | return decorate 69 | 70 | 71 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): 72 | """Call the given command(s).""" 73 | assert isinstance(commands, list) 74 | p = None 75 | for c in commands: 76 | try: 77 | dispcmd = str([c] + args) 78 | # remember shell=False, so use git.cmd on windows, not just git 79 | p = subprocess.Popen( 80 | [c] + args, 81 | cwd=cwd, 82 | env=env, 83 | stdout=subprocess.PIPE, 84 | stderr=(subprocess.PIPE if hide_stderr else None), 85 | ) 86 | break 87 | except EnvironmentError: 88 | e = sys.exc_info()[1] 89 | if e.errno == errno.ENOENT: 90 | continue 91 | if verbose: 92 | print("unable to run %s" % dispcmd) 93 | print(e) 94 | return None, None 95 | else: 96 | if verbose: 97 | print("unable to find command, tried %s" % (commands,)) 98 | return None, None 99 | stdout = p.communicate()[0].strip() 100 | if sys.version_info[0] >= 3: 101 | stdout = stdout.decode() 102 | if p.returncode != 0: 103 | if verbose: 104 | print("unable to run %s (error)" % dispcmd) 105 | print("stdout was %s" % stdout) 106 | return None, p.returncode 107 | return stdout, p.returncode 108 | 109 | 110 | def versions_from_parentdir(parentdir_prefix, root, verbose): 111 | """Try to determine the version from the parent directory name. 112 | 113 | Source tarballs conventionally unpack into a directory that includes both 114 | the project name and a version string. We will also support searching up 115 | two directory levels for an appropriately named parent directory 116 | """ 117 | rootdirs = [] 118 | 119 | for i in range(3): 120 | dirname = os.path.basename(root) 121 | if dirname.startswith(parentdir_prefix): 122 | return { 123 | "version": dirname[len(parentdir_prefix) :], 124 | "full-revisionid": None, 125 | "dirty": False, 126 | "error": None, 127 | "date": None, 128 | } 129 | else: 130 | rootdirs.append(root) 131 | root = os.path.dirname(root) # up a level 132 | 133 | if verbose: 134 | print( 135 | "Tried directories %s but none started with prefix %s" 136 | % (str(rootdirs), parentdir_prefix) 137 | ) 138 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 139 | 140 | 141 | @register_vcs_handler("git", "get_keywords") 142 | def git_get_keywords(versionfile_abs): 143 | """Extract version information from the given file.""" 144 | # the code embedded in _version.py can just fetch the value of these 145 | # keywords. When used from setup.py, we don't want to import _version.py, 146 | # so we do it with a regexp instead. This function is not used from 147 | # _version.py. 148 | keywords = {} 149 | try: 150 | f = open(versionfile_abs, "r") 151 | for line in f.readlines(): 152 | if line.strip().startswith("git_refnames ="): 153 | mo = re.search(r'=\s*"(.*)"', line) 154 | if mo: 155 | keywords["refnames"] = mo.group(1) 156 | if line.strip().startswith("git_full ="): 157 | mo = re.search(r'=\s*"(.*)"', line) 158 | if mo: 159 | keywords["full"] = mo.group(1) 160 | if line.strip().startswith("git_date ="): 161 | mo = re.search(r'=\s*"(.*)"', line) 162 | if mo: 163 | keywords["date"] = mo.group(1) 164 | f.close() 165 | except EnvironmentError: 166 | pass 167 | return keywords 168 | 169 | 170 | @register_vcs_handler("git", "keywords") 171 | def git_versions_from_keywords(keywords, tag_prefix, verbose): 172 | """Get version information from git keywords.""" 173 | if not keywords: 174 | raise NotThisMethod("no keywords at all, weird") 175 | date = keywords.get("date") 176 | if date is not None: 177 | # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant 178 | # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 179 | # -like" string, which we must then edit to make compliant), because 180 | # it's been around since git-1.5.3, and it's too difficult to 181 | # discover which version we're using, or to work around using an 182 | # older one. 183 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 184 | refnames = keywords["refnames"].strip() 185 | if refnames.startswith("$Format"): 186 | if verbose: 187 | print("keywords are unexpanded, not using") 188 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 189 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 190 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 191 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 192 | TAG = "tag: " 193 | tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) 194 | if not tags: 195 | # Either we're using git < 1.8.3, or there really are no tags. We use 196 | # a heuristic: assume all version tags have a digit. The old git %d 197 | # expansion behaves like git log --decorate=short and strips out the 198 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 199 | # between branches and tags. By ignoring refnames without digits, we 200 | # filter out many common branch names like "release" and 201 | # "stabilization", as well as "HEAD" and "master". 202 | tags = set([r for r in refs if re.search(r"\d", r)]) 203 | if verbose: 204 | print("discarding '%s', no digits" % ",".join(refs - tags)) 205 | if verbose: 206 | print("likely tags: %s" % ",".join(sorted(tags))) 207 | for ref in sorted(tags): 208 | # sorting will prefer e.g. "2.0" over "2.0rc1" 209 | if ref.startswith(tag_prefix): 210 | r = ref[len(tag_prefix) :] 211 | if verbose: 212 | print("picking %s" % r) 213 | return { 214 | "version": r, 215 | "full-revisionid": keywords["full"].strip(), 216 | "dirty": False, 217 | "error": None, 218 | "date": date, 219 | } 220 | # no suitable tags, so version is "0+unknown", but full hex is still there 221 | if verbose: 222 | print("no suitable tags, using unknown + full revision id") 223 | return { 224 | "version": "0+unknown", 225 | "full-revisionid": keywords["full"].strip(), 226 | "dirty": False, 227 | "error": "no suitable tags", 228 | "date": None, 229 | } 230 | 231 | 232 | @register_vcs_handler("git", "pieces_from_vcs") 233 | def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): 234 | """Get version from 'git describe' in the root of the source tree. 235 | 236 | This only gets called if the git-archive 'subst' keywords were *not* 237 | expanded, and _version.py hasn't already been rewritten with a short 238 | version string, meaning we're inside a checked out source tree. 239 | """ 240 | GITS = ["git"] 241 | if sys.platform == "win32": 242 | GITS = ["git.cmd", "git.exe"] 243 | 244 | out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) 245 | if rc != 0: 246 | if verbose: 247 | print("Directory %s not under git control" % root) 248 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 249 | 250 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 251 | # if there isn't one, this yields HEX[-dirty] (no NUM) 252 | describe_out, rc = run_command( 253 | GITS, 254 | [ 255 | "describe", 256 | "--tags", 257 | "--dirty", 258 | "--always", 259 | "--long", 260 | "--match", 261 | "%s*" % tag_prefix, 262 | ], 263 | cwd=root, 264 | ) 265 | # --long was added in git-1.5.5 266 | if describe_out is None: 267 | raise NotThisMethod("'git describe' failed") 268 | describe_out = describe_out.strip() 269 | full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 270 | if full_out is None: 271 | raise NotThisMethod("'git rev-parse' failed") 272 | full_out = full_out.strip() 273 | 274 | pieces = {} 275 | pieces["long"] = full_out 276 | pieces["short"] = full_out[:7] # maybe improved later 277 | pieces["error"] = None 278 | 279 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 280 | # TAG might have hyphens. 281 | git_describe = describe_out 282 | 283 | # look for -dirty suffix 284 | dirty = git_describe.endswith("-dirty") 285 | pieces["dirty"] = dirty 286 | if dirty: 287 | git_describe = git_describe[: git_describe.rindex("-dirty")] 288 | 289 | # now we have TAG-NUM-gHEX or HEX 290 | 291 | if "-" in git_describe: 292 | # TAG-NUM-gHEX 293 | mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) 294 | if not mo: 295 | # unparseable. Maybe git-describe is misbehaving? 296 | pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out 297 | return pieces 298 | 299 | # tag 300 | full_tag = mo.group(1) 301 | if not full_tag.startswith(tag_prefix): 302 | if verbose: 303 | fmt = "tag '%s' doesn't start with prefix '%s'" 304 | print(fmt % (full_tag, tag_prefix)) 305 | pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % ( 306 | full_tag, 307 | tag_prefix, 308 | ) 309 | return pieces 310 | pieces["closest-tag"] = full_tag[len(tag_prefix) :] 311 | 312 | # distance: number of commits since tag 313 | pieces["distance"] = int(mo.group(2)) 314 | 315 | # commit: short hex revision ID 316 | pieces["short"] = mo.group(3) 317 | 318 | else: 319 | # HEX: no tags 320 | pieces["closest-tag"] = None 321 | count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) 322 | pieces["distance"] = int(count_out) # total number of commits 323 | 324 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 325 | date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[ 326 | 0 327 | ].strip() 328 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 329 | 330 | return pieces 331 | 332 | 333 | def plus_or_dot(pieces): 334 | """Return a + if we don't already have one, else return a .""" 335 | if "+" in pieces.get("closest-tag", ""): 336 | return "." 337 | return "+" 338 | 339 | 340 | def render_pep440(pieces): 341 | """Build up version string, with post-release "local version identifier". 342 | 343 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 344 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 345 | 346 | Exceptions: 347 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 348 | """ 349 | if pieces["closest-tag"]: 350 | rendered = pieces["closest-tag"] 351 | if pieces["distance"] or pieces["dirty"]: 352 | rendered += plus_or_dot(pieces) 353 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 354 | if pieces["dirty"]: 355 | rendered += ".dirty" 356 | else: 357 | # exception #1 358 | rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) 359 | if pieces["dirty"]: 360 | rendered += ".dirty" 361 | return rendered 362 | 363 | 364 | def render_pep440_pre(pieces): 365 | """TAG[.post.devDISTANCE] -- No -dirty. 366 | 367 | Exceptions: 368 | 1: no tags. 0.post.devDISTANCE 369 | """ 370 | if pieces["closest-tag"]: 371 | rendered = pieces["closest-tag"] 372 | if pieces["distance"]: 373 | rendered += ".post.dev%d" % pieces["distance"] 374 | else: 375 | # exception #1 376 | rendered = "0.post.dev%d" % pieces["distance"] 377 | return rendered 378 | 379 | 380 | def render_pep440_post(pieces): 381 | """TAG[.postDISTANCE[.dev0]+gHEX] . 382 | 383 | The ".dev0" means dirty. Note that .dev0 sorts backwards 384 | (a dirty tree will appear "older" than the corresponding clean one), 385 | but you shouldn't be releasing software with -dirty anyways. 386 | 387 | Exceptions: 388 | 1: no tags. 0.postDISTANCE[.dev0] 389 | """ 390 | if pieces["closest-tag"]: 391 | rendered = pieces["closest-tag"] 392 | if pieces["distance"] or pieces["dirty"]: 393 | rendered += ".post%d" % pieces["distance"] 394 | if pieces["dirty"]: 395 | rendered += ".dev0" 396 | rendered += plus_or_dot(pieces) 397 | rendered += "g%s" % pieces["short"] 398 | else: 399 | # exception #1 400 | rendered = "0.post%d" % pieces["distance"] 401 | if pieces["dirty"]: 402 | rendered += ".dev0" 403 | rendered += "+g%s" % pieces["short"] 404 | return rendered 405 | 406 | 407 | def render_pep440_old(pieces): 408 | """TAG[.postDISTANCE[.dev0]] . 409 | 410 | The ".dev0" means dirty. 411 | 412 | Eexceptions: 413 | 1: no tags. 0.postDISTANCE[.dev0] 414 | """ 415 | if pieces["closest-tag"]: 416 | rendered = pieces["closest-tag"] 417 | if pieces["distance"] or pieces["dirty"]: 418 | rendered += ".post%d" % pieces["distance"] 419 | if pieces["dirty"]: 420 | rendered += ".dev0" 421 | else: 422 | # exception #1 423 | rendered = "0.post%d" % pieces["distance"] 424 | if pieces["dirty"]: 425 | rendered += ".dev0" 426 | return rendered 427 | 428 | 429 | def render_git_describe(pieces): 430 | """TAG[-DISTANCE-gHEX][-dirty]. 431 | 432 | Like 'git describe --tags --dirty --always'. 433 | 434 | Exceptions: 435 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 436 | """ 437 | if pieces["closest-tag"]: 438 | rendered = pieces["closest-tag"] 439 | if pieces["distance"]: 440 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 441 | else: 442 | # exception #1 443 | rendered = pieces["short"] 444 | if pieces["dirty"]: 445 | rendered += "-dirty" 446 | return rendered 447 | 448 | 449 | def render_git_describe_long(pieces): 450 | """TAG-DISTANCE-gHEX[-dirty]. 451 | 452 | Like 'git describe --tags --dirty --always -long'. 453 | The distance/hash is unconditional. 454 | 455 | Exceptions: 456 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 457 | """ 458 | if pieces["closest-tag"]: 459 | rendered = pieces["closest-tag"] 460 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 461 | else: 462 | # exception #1 463 | rendered = pieces["short"] 464 | if pieces["dirty"]: 465 | rendered += "-dirty" 466 | return rendered 467 | 468 | 469 | def render(pieces, style): 470 | """Render the given version pieces into the requested style.""" 471 | if pieces["error"]: 472 | return { 473 | "version": "unknown", 474 | "full-revisionid": pieces.get("long"), 475 | "dirty": None, 476 | "error": pieces["error"], 477 | "date": None, 478 | } 479 | 480 | if not style or style == "default": 481 | style = "pep440" # the default 482 | 483 | if style == "pep440": 484 | rendered = render_pep440(pieces) 485 | elif style == "pep440-pre": 486 | rendered = render_pep440_pre(pieces) 487 | elif style == "pep440-post": 488 | rendered = render_pep440_post(pieces) 489 | elif style == "pep440-old": 490 | rendered = render_pep440_old(pieces) 491 | elif style == "git-describe": 492 | rendered = render_git_describe(pieces) 493 | elif style == "git-describe-long": 494 | rendered = render_git_describe_long(pieces) 495 | else: 496 | raise ValueError("unknown style '%s'" % style) 497 | 498 | return { 499 | "version": rendered, 500 | "full-revisionid": pieces["long"], 501 | "dirty": pieces["dirty"], 502 | "error": None, 503 | "date": pieces.get("date"), 504 | } 505 | 506 | 507 | def get_versions(): 508 | """Get version information or return default if unable to do so.""" 509 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 510 | # __file__, we can work backwards from there to the root. Some 511 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 512 | # case we can only use expanded keywords. 513 | 514 | cfg = get_config() 515 | verbose = cfg.verbose 516 | 517 | try: 518 | return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, verbose) 519 | except NotThisMethod: 520 | pass 521 | 522 | try: 523 | root = os.path.realpath(__file__) 524 | # versionfile_source is the relative path from the top of the source 525 | # tree (where the .git directory might live) to this file. Invert 526 | # this to find the root from __file__. 527 | for i in cfg.versionfile_source.split("/"): 528 | root = os.path.dirname(root) 529 | except NameError: 530 | return { 531 | "version": "0+unknown", 532 | "full-revisionid": None, 533 | "dirty": None, 534 | "error": "unable to find root of source tree", 535 | "date": None, 536 | } 537 | 538 | try: 539 | pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) 540 | return render(pieces, cfg.style) 541 | except NotThisMethod: 542 | pass 543 | 544 | try: 545 | if cfg.parentdir_prefix: 546 | return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 547 | except NotThisMethod: 548 | pass 549 | 550 | return { 551 | "version": "0+unknown", 552 | "full-revisionid": None, 553 | "dirty": None, 554 | "error": "unable to compute version", 555 | "date": None, 556 | } 557 | -------------------------------------------------------------------------------- /versioneer.py: -------------------------------------------------------------------------------- 1 | # Version: 0.18 2 | 3 | """The Versioneer - like a rocketeer, but for versions. 4 | 5 | The Versioneer 6 | ============== 7 | 8 | * like a rocketeer, but for versions! 9 | * https://github.com/warner/python-versioneer 10 | * Brian Warner 11 | * License: Public Domain 12 | * Compatible With: python2.6, 2.7, 3.2, 3.3, 3.4, 3.5, 3.6, and pypy 13 | * [![Latest Version] 14 | (https://pypip.in/version/versioneer/badge.svg?style=flat) 15 | ](https://pypi.python.org/pypi/versioneer/) 16 | * [![Build Status] 17 | (https://travis-ci.org/warner/python-versioneer.png?branch=master) 18 | ](https://travis-ci.org/warner/python-versioneer) 19 | 20 | This is a tool for managing a recorded version number in distutils-based 21 | python projects. The goal is to remove the tedious and error-prone "update 22 | the embedded version string" step from your release process. Making a new 23 | release should be as easy as recording a new tag in your version-control 24 | system, and maybe making new tarballs. 25 | 26 | 27 | ## Quick Install 28 | 29 | * `pip install versioneer` to somewhere to your $PATH 30 | * add a `[versioneer]` section to your setup.cfg (see below) 31 | * run `versioneer install` in your source tree, commit the results 32 | 33 | ## Version Identifiers 34 | 35 | Source trees come from a variety of places: 36 | 37 | * a version-control system checkout (mostly used by developers) 38 | * a nightly tarball, produced by build automation 39 | * a snapshot tarball, produced by a web-based VCS browser, like github's 40 | "tarball from tag" feature 41 | * a release tarball, produced by "setup.py sdist", distributed through PyPI 42 | 43 | Within each source tree, the version identifier (either a string or a number, 44 | this tool is format-agnostic) can come from a variety of places: 45 | 46 | * ask the VCS tool itself, e.g. "git describe" (for checkouts), which knows 47 | about recent "tags" and an absolute revision-id 48 | * the name of the directory into which the tarball was unpacked 49 | * an expanded VCS keyword ($Id$, etc) 50 | * a `_version.py` created by some earlier build step 51 | 52 | For released software, the version identifier is closely related to a VCS 53 | tag. Some projects use tag names that include more than just the version 54 | string (e.g. "myproject-1.2" instead of just "1.2"), in which case the tool 55 | needs to strip the tag prefix to extract the version identifier. For 56 | unreleased software (between tags), the version identifier should provide 57 | enough information to help developers recreate the same tree, while also 58 | giving them an idea of roughly how old the tree is (after version 1.2, before 59 | version 1.3). Many VCS systems can report a description that captures this, 60 | for example `git describe --tags --dirty --always` reports things like 61 | "0.7-1-g574ab98-dirty" to indicate that the checkout is one revision past the 62 | 0.7 tag, has a unique revision id of "574ab98", and is "dirty" (it has 63 | uncommitted changes. 64 | 65 | The version identifier is used for multiple purposes: 66 | 67 | * to allow the module to self-identify its version: `myproject.__version__` 68 | * to choose a name and prefix for a 'setup.py sdist' tarball 69 | 70 | ## Theory of Operation 71 | 72 | Versioneer works by adding a special `_version.py` file into your source 73 | tree, where your `__init__.py` can import it. This `_version.py` knows how to 74 | dynamically ask the VCS tool for version information at import time. 75 | 76 | `_version.py` also contains `$Revision$` markers, and the installation 77 | process marks `_version.py` to have this marker rewritten with a tag name 78 | during the `git archive` command. As a result, generated tarballs will 79 | contain enough information to get the proper version. 80 | 81 | To allow `setup.py` to compute a version too, a `versioneer.py` is added to 82 | the top level of your source tree, next to `setup.py` and the `setup.cfg` 83 | that configures it. This overrides several distutils/setuptools commands to 84 | compute the version when invoked, and changes `setup.py build` and `setup.py 85 | sdist` to replace `_version.py` with a small static file that contains just 86 | the generated version data. 87 | 88 | ## Installation 89 | 90 | See [INSTALL.md](./INSTALL.md) for detailed installation instructions. 91 | 92 | ## Version-String Flavors 93 | 94 | Code which uses Versioneer can learn about its version string at runtime by 95 | importing `_version` from your main `__init__.py` file and running the 96 | `get_versions()` function. From the "outside" (e.g. in `setup.py`), you can 97 | import the top-level `versioneer.py` and run `get_versions()`. 98 | 99 | Both functions return a dictionary with different flavors of version 100 | information: 101 | 102 | * `['version']`: A condensed version string, rendered using the selected 103 | style. This is the most commonly used value for the project's version 104 | string. The default "pep440" style yields strings like `0.11`, 105 | `0.11+2.g1076c97`, or `0.11+2.g1076c97.dirty`. See the "Styles" section 106 | below for alternative styles. 107 | 108 | * `['full-revisionid']`: detailed revision identifier. For Git, this is the 109 | full SHA1 commit id, e.g. "1076c978a8d3cfc70f408fe5974aa6c092c949ac". 110 | 111 | * `['date']`: Date and time of the latest `HEAD` commit. For Git, it is the 112 | commit date in ISO 8601 format. This will be None if the date is not 113 | available. 114 | 115 | * `['dirty']`: a boolean, True if the tree has uncommitted changes. Note that 116 | this is only accurate if run in a VCS checkout, otherwise it is likely to 117 | be False or None 118 | 119 | * `['error']`: if the version string could not be computed, this will be set 120 | to a string describing the problem, otherwise it will be None. It may be 121 | useful to throw an exception in setup.py if this is set, to avoid e.g. 122 | creating tarballs with a version string of "unknown". 123 | 124 | Some variants are more useful than others. Including `full-revisionid` in a 125 | bug report should allow developers to reconstruct the exact code being tested 126 | (or indicate the presence of local changes that should be shared with the 127 | developers). `version` is suitable for display in an "about" box or a CLI 128 | `--version` output: it can be easily compared against release notes and lists 129 | of bugs fixed in various releases. 130 | 131 | The installer adds the following text to your `__init__.py` to place a basic 132 | version in `YOURPROJECT.__version__`: 133 | 134 | from ._version import get_versions 135 | __version__ = get_versions()['version'] 136 | del get_versions 137 | 138 | ## Styles 139 | 140 | The setup.cfg `style=` configuration controls how the VCS information is 141 | rendered into a version string. 142 | 143 | The default style, "pep440", produces a PEP440-compliant string, equal to the 144 | un-prefixed tag name for actual releases, and containing an additional "local 145 | version" section with more detail for in-between builds. For Git, this is 146 | TAG[+DISTANCE.gHEX[.dirty]] , using information from `git describe --tags 147 | --dirty --always`. For example "0.11+2.g1076c97.dirty" indicates that the 148 | tree is like the "1076c97" commit but has uncommitted changes (".dirty"), and 149 | that this commit is two revisions ("+2") beyond the "0.11" tag. For released 150 | software (exactly equal to a known tag), the identifier will only contain the 151 | stripped tag, e.g. "0.11". 152 | 153 | Other styles are available. See [details.md](details.md) in the Versioneer 154 | source tree for descriptions. 155 | 156 | ## Debugging 157 | 158 | Versioneer tries to avoid fatal errors: if something goes wrong, it will tend 159 | to return a version of "0+unknown". To investigate the problem, run `setup.py 160 | version`, which will run the version-lookup code in a verbose mode, and will 161 | display the full contents of `get_versions()` (including the `error` string, 162 | which may help identify what went wrong). 163 | 164 | ## Known Limitations 165 | 166 | Some situations are known to cause problems for Versioneer. This details the 167 | most significant ones. More can be found on Github 168 | [issues page](https://github.com/warner/python-versioneer/issues). 169 | 170 | ### Subprojects 171 | 172 | Versioneer has limited support for source trees in which `setup.py` is not in 173 | the root directory (e.g. `setup.py` and `.git/` are *not* siblings). The are 174 | two common reasons why `setup.py` might not be in the root: 175 | 176 | * Source trees which contain multiple subprojects, such as 177 | [Buildbot](https://github.com/buildbot/buildbot), which contains both 178 | "master" and "slave" subprojects, each with their own `setup.py`, 179 | `setup.cfg`, and `tox.ini`. Projects like these produce multiple PyPI 180 | distributions (and upload multiple independently-installable tarballs). 181 | * Source trees whose main purpose is to contain a C library, but which also 182 | provide bindings to Python (and perhaps other langauges) in subdirectories. 183 | 184 | Versioneer will look for `.git` in parent directories, and most operations 185 | should get the right version string. However `pip` and `setuptools` have bugs 186 | and implementation details which frequently cause `pip install .` from a 187 | subproject directory to fail to find a correct version string (so it usually 188 | defaults to `0+unknown`). 189 | 190 | `pip install --editable .` should work correctly. `setup.py install` might 191 | work too. 192 | 193 | Pip-8.1.1 is known to have this problem, but hopefully it will get fixed in 194 | some later version. 195 | 196 | [Bug #38](https://github.com/warner/python-versioneer/issues/38) is tracking 197 | this issue. The discussion in 198 | [PR #61](https://github.com/warner/python-versioneer/pull/61) describes the 199 | issue from the Versioneer side in more detail. 200 | [pip PR#3176](https://github.com/pypa/pip/pull/3176) and 201 | [pip PR#3615](https://github.com/pypa/pip/pull/3615) contain work to improve 202 | pip to let Versioneer work correctly. 203 | 204 | Versioneer-0.16 and earlier only looked for a `.git` directory next to the 205 | `setup.cfg`, so subprojects were completely unsupported with those releases. 206 | 207 | ### Editable installs with setuptools <= 18.5 208 | 209 | `setup.py develop` and `pip install --editable .` allow you to install a 210 | project into a virtualenv once, then continue editing the source code (and 211 | test) without re-installing after every change. 212 | 213 | "Entry-point scripts" (`setup(entry_points={"console_scripts": ..})`) are a 214 | convenient way to specify executable scripts that should be installed along 215 | with the python package. 216 | 217 | These both work as expected when using modern setuptools. When using 218 | setuptools-18.5 or earlier, however, certain operations will cause 219 | `pkg_resources.DistributionNotFound` errors when running the entrypoint 220 | script, which must be resolved by re-installing the package. This happens 221 | when the install happens with one version, then the egg_info data is 222 | regenerated while a different version is checked out. Many setup.py commands 223 | cause egg_info to be rebuilt (including `sdist`, `wheel`, and installing into 224 | a different virtualenv), so this can be surprising. 225 | 226 | [Bug #83](https://github.com/warner/python-versioneer/issues/83) describes 227 | this one, but upgrading to a newer version of setuptools should probably 228 | resolve it. 229 | 230 | ### Unicode version strings 231 | 232 | While Versioneer works (and is continually tested) with both Python 2 and 233 | Python 3, it is not entirely consistent with bytes-vs-unicode distinctions. 234 | Newer releases probably generate unicode version strings on py2. It's not 235 | clear that this is wrong, but it may be surprising for applications when then 236 | write these strings to a network connection or include them in bytes-oriented 237 | APIs like cryptographic checksums. 238 | 239 | [Bug #71](https://github.com/warner/python-versioneer/issues/71) investigates 240 | this question. 241 | 242 | 243 | ## Updating Versioneer 244 | 245 | To upgrade your project to a new release of Versioneer, do the following: 246 | 247 | * install the new Versioneer (`pip install -U versioneer` or equivalent) 248 | * edit `setup.cfg`, if necessary, to include any new configuration settings 249 | indicated by the release notes. See [UPGRADING](./UPGRADING.md) for details. 250 | * re-run `versioneer install` in your source tree, to replace 251 | `SRC/_version.py` 252 | * commit any changed files 253 | 254 | ## Future Directions 255 | 256 | This tool is designed to make it easily extended to other version-control 257 | systems: all VCS-specific components are in separate directories like 258 | src/git/ . The top-level `versioneer.py` script is assembled from these 259 | components by running make-versioneer.py . In the future, make-versioneer.py 260 | will take a VCS name as an argument, and will construct a version of 261 | `versioneer.py` that is specific to the given VCS. It might also take the 262 | configuration arguments that are currently provided manually during 263 | installation by editing setup.py . Alternatively, it might go the other 264 | direction and include code from all supported VCS systems, reducing the 265 | number of intermediate scripts. 266 | 267 | 268 | ## License 269 | 270 | To make Versioneer easier to embed, all its code is dedicated to the public 271 | domain. The `_version.py` that it creates is also in the public domain. 272 | Specifically, both are released under the Creative Commons "Public Domain 273 | Dedication" license (CC0-1.0), as described in 274 | https://creativecommons.org/publicdomain/zero/1.0/ . 275 | 276 | """ 277 | 278 | from __future__ import print_function 279 | 280 | import errno 281 | import json 282 | import os 283 | import re 284 | import subprocess 285 | import sys 286 | 287 | try: 288 | import configparser 289 | except ImportError: 290 | import ConfigParser as configparser 291 | 292 | 293 | class VersioneerConfig: 294 | """Container for Versioneer configuration parameters.""" 295 | 296 | 297 | def get_root(): 298 | """Get the project root directory. 299 | 300 | We require that all commands are run from the project root, i.e. the 301 | directory that contains setup.py, setup.cfg, and versioneer.py . 302 | """ 303 | root = os.path.realpath(os.path.abspath(os.getcwd())) 304 | setup_py = os.path.join(root, "setup.py") 305 | versioneer_py = os.path.join(root, "versioneer.py") 306 | if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): 307 | # allow 'python path/to/setup.py COMMAND' 308 | root = os.path.dirname(os.path.realpath(os.path.abspath(sys.argv[0]))) 309 | setup_py = os.path.join(root, "setup.py") 310 | versioneer_py = os.path.join(root, "versioneer.py") 311 | if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): 312 | err = ( 313 | "Versioneer was unable to run the project root directory. " 314 | "Versioneer requires setup.py to be executed from " 315 | "its immediate directory (like 'python setup.py COMMAND'), " 316 | "or in a way that lets it use sys.argv[0] to find the root " 317 | "(like 'python path/to/setup.py COMMAND')." 318 | ) 319 | raise VersioneerBadRootError(err) 320 | try: 321 | # Certain runtime workflows (setup.py install/develop in a setuptools 322 | # tree) execute all dependencies in a single python process, so 323 | # "versioneer" may be imported multiple times, and python's shared 324 | # module-import table will cache the first one. So we can't use 325 | # os.path.dirname(__file__), as that will find whichever 326 | # versioneer.py was first imported, even in later projects. 327 | me = os.path.realpath(os.path.abspath(__file__)) 328 | me_dir = os.path.normcase(os.path.splitext(me)[0]) 329 | vsr_dir = os.path.normcase(os.path.splitext(versioneer_py)[0]) 330 | if me_dir != vsr_dir: 331 | print( 332 | "Warning: build in %s is using versioneer.py from %s" 333 | % (os.path.dirname(me), versioneer_py) 334 | ) 335 | except NameError: 336 | pass 337 | return root 338 | 339 | 340 | def get_config_from_root(root): 341 | """Read the project setup.cfg file to determine Versioneer config.""" 342 | # This might raise EnvironmentError (if setup.cfg is missing), or 343 | # configparser.NoSectionError (if it lacks a [versioneer] section), or 344 | # configparser.NoOptionError (if it lacks "VCS="). See the docstring at 345 | # the top of versioneer.py for instructions on writing your setup.cfg . 346 | setup_cfg = os.path.join(root, "setup.cfg") 347 | parser = configparser.ConfigParser() 348 | parser.read(setup_cfg) 349 | VCS = parser.get("versioneer", "VCS") # mandatory 350 | 351 | def get(parser, name): 352 | if parser.has_option("versioneer", name): 353 | return parser.get("versioneer", name) 354 | return None 355 | 356 | cfg = VersioneerConfig() 357 | cfg.VCS = VCS 358 | cfg.style = get(parser, "style") or "" 359 | cfg.versionfile_source = get(parser, "versionfile_source") 360 | cfg.versionfile_build = get(parser, "versionfile_build") 361 | cfg.tag_prefix = get(parser, "tag_prefix") 362 | if cfg.tag_prefix in ("''", '""'): 363 | cfg.tag_prefix = "" 364 | cfg.parentdir_prefix = get(parser, "parentdir_prefix") 365 | cfg.verbose = get(parser, "verbose") 366 | return cfg 367 | 368 | 369 | class NotThisMethod(Exception): 370 | """Exception raised if a method is not valid for the current scenario.""" 371 | 372 | 373 | # these dictionaries contain VCS-specific tools 374 | LONG_VERSION_PY = {} 375 | HANDLERS = {} 376 | 377 | 378 | def register_vcs_handler(vcs, method): # decorator 379 | """Decorator to mark a method as the handler for a particular VCS.""" 380 | 381 | def decorate(f): 382 | """Store f in HANDLERS[vcs][method].""" 383 | if vcs not in HANDLERS: 384 | HANDLERS[vcs] = {} 385 | HANDLERS[vcs][method] = f 386 | return f 387 | 388 | return decorate 389 | 390 | 391 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=None): 392 | """Call the given command(s).""" 393 | assert isinstance(commands, list) 394 | p = None 395 | for c in commands: 396 | try: 397 | dispcmd = str([c] + args) 398 | # remember shell=False, so use git.cmd on windows, not just git 399 | p = subprocess.Popen( 400 | [c] + args, 401 | cwd=cwd, 402 | env=env, 403 | stdout=subprocess.PIPE, 404 | stderr=(subprocess.PIPE if hide_stderr else None), 405 | ) 406 | break 407 | except EnvironmentError: 408 | e = sys.exc_info()[1] 409 | if e.errno == errno.ENOENT: 410 | continue 411 | if verbose: 412 | print("unable to run %s" % dispcmd) 413 | print(e) 414 | return None, None 415 | else: 416 | if verbose: 417 | print("unable to find command, tried %s" % (commands,)) 418 | return None, None 419 | stdout = p.communicate()[0].strip() 420 | if sys.version_info[0] >= 3: 421 | stdout = stdout.decode() 422 | if p.returncode != 0: 423 | if verbose: 424 | print("unable to run %s (error)" % dispcmd) 425 | print("stdout was %s" % stdout) 426 | return None, p.returncode 427 | return stdout, p.returncode 428 | 429 | 430 | LONG_VERSION_PY["git"] = ''' 431 | # This file helps to compute a version number in source trees obtained from 432 | # git-archive tarball (such as those provided by githubs download-from-tag 433 | # feature). Distribution tarballs (built by setup.py sdist) and build 434 | # directories (produced by setup.py build) will contain a much shorter file 435 | # that just contains the computed version number. 436 | 437 | # This file is released into the public domain. Generated by 438 | # versioneer-0.18 (https://github.com/warner/python-versioneer) 439 | 440 | """Git implementation of _version.py.""" 441 | 442 | import errno 443 | import os 444 | import re 445 | import subprocess 446 | import sys 447 | 448 | 449 | def get_keywords(): 450 | """Get the keywords needed to look up the version information.""" 451 | # these strings will be replaced by git during git-archive. 452 | # setup.py/versioneer.py will grep for the variable names, so they must 453 | # each be defined on a line of their own. _version.py will just call 454 | # get_keywords(). 455 | git_refnames = "%(DOLLAR)sFormat:%%d%(DOLLAR)s" 456 | git_full = "%(DOLLAR)sFormat:%%H%(DOLLAR)s" 457 | git_date = "%(DOLLAR)sFormat:%%ci%(DOLLAR)s" 458 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} 459 | return keywords 460 | 461 | 462 | class VersioneerConfig: 463 | """Container for Versioneer configuration parameters.""" 464 | 465 | 466 | def get_config(): 467 | """Create, populate and return the VersioneerConfig() object.""" 468 | # these strings are filled in when 'setup.py versioneer' creates 469 | # _version.py 470 | cfg = VersioneerConfig() 471 | cfg.VCS = "git" 472 | cfg.style = "%(STYLE)s" 473 | cfg.tag_prefix = "%(TAG_PREFIX)s" 474 | cfg.parentdir_prefix = "%(PARENTDIR_PREFIX)s" 475 | cfg.versionfile_source = "%(VERSIONFILE_SOURCE)s" 476 | cfg.verbose = False 477 | return cfg 478 | 479 | 480 | class NotThisMethod(Exception): 481 | """Exception raised if a method is not valid for the current scenario.""" 482 | 483 | 484 | LONG_VERSION_PY = {} 485 | HANDLERS = {} 486 | 487 | 488 | def register_vcs_handler(vcs, method): # decorator 489 | """Decorator to mark a method as the handler for a particular VCS.""" 490 | def decorate(f): 491 | """Store f in HANDLERS[vcs][method].""" 492 | if vcs not in HANDLERS: 493 | HANDLERS[vcs] = {} 494 | HANDLERS[vcs][method] = f 495 | return f 496 | return decorate 497 | 498 | 499 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, 500 | env=None): 501 | """Call the given command(s).""" 502 | assert isinstance(commands, list) 503 | p = None 504 | for c in commands: 505 | try: 506 | dispcmd = str([c] + args) 507 | # remember shell=False, so use git.cmd on windows, not just git 508 | p = subprocess.Popen([c] + args, cwd=cwd, env=env, 509 | stdout=subprocess.PIPE, 510 | stderr=(subprocess.PIPE if hide_stderr 511 | else None)) 512 | break 513 | except EnvironmentError: 514 | e = sys.exc_info()[1] 515 | if e.errno == errno.ENOENT: 516 | continue 517 | if verbose: 518 | print("unable to run %%s" %% dispcmd) 519 | print(e) 520 | return None, None 521 | else: 522 | if verbose: 523 | print("unable to find command, tried %%s" %% (commands,)) 524 | return None, None 525 | stdout = p.communicate()[0].strip() 526 | if sys.version_info[0] >= 3: 527 | stdout = stdout.decode() 528 | if p.returncode != 0: 529 | if verbose: 530 | print("unable to run %%s (error)" %% dispcmd) 531 | print("stdout was %%s" %% stdout) 532 | return None, p.returncode 533 | return stdout, p.returncode 534 | 535 | 536 | def versions_from_parentdir(parentdir_prefix, root, verbose): 537 | """Try to determine the version from the parent directory name. 538 | 539 | Source tarballs conventionally unpack into a directory that includes both 540 | the project name and a version string. We will also support searching up 541 | two directory levels for an appropriately named parent directory 542 | """ 543 | rootdirs = [] 544 | 545 | for i in range(3): 546 | dirname = os.path.basename(root) 547 | if dirname.startswith(parentdir_prefix): 548 | return {"version": dirname[len(parentdir_prefix):], 549 | "full-revisionid": None, 550 | "dirty": False, "error": None, "date": None} 551 | else: 552 | rootdirs.append(root) 553 | root = os.path.dirname(root) # up a level 554 | 555 | if verbose: 556 | print("Tried directories %%s but none started with prefix %%s" %% 557 | (str(rootdirs), parentdir_prefix)) 558 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 559 | 560 | 561 | @register_vcs_handler("git", "get_keywords") 562 | def git_get_keywords(versionfile_abs): 563 | """Extract version information from the given file.""" 564 | # the code embedded in _version.py can just fetch the value of these 565 | # keywords. When used from setup.py, we don't want to import _version.py, 566 | # so we do it with a regexp instead. This function is not used from 567 | # _version.py. 568 | keywords = {} 569 | try: 570 | f = open(versionfile_abs, "r") 571 | for line in f.readlines(): 572 | if line.strip().startswith("git_refnames ="): 573 | mo = re.search(r'=\s*"(.*)"', line) 574 | if mo: 575 | keywords["refnames"] = mo.group(1) 576 | if line.strip().startswith("git_full ="): 577 | mo = re.search(r'=\s*"(.*)"', line) 578 | if mo: 579 | keywords["full"] = mo.group(1) 580 | if line.strip().startswith("git_date ="): 581 | mo = re.search(r'=\s*"(.*)"', line) 582 | if mo: 583 | keywords["date"] = mo.group(1) 584 | f.close() 585 | except EnvironmentError: 586 | pass 587 | return keywords 588 | 589 | 590 | @register_vcs_handler("git", "keywords") 591 | def git_versions_from_keywords(keywords, tag_prefix, verbose): 592 | """Get version information from git keywords.""" 593 | if not keywords: 594 | raise NotThisMethod("no keywords at all, weird") 595 | date = keywords.get("date") 596 | if date is not None: 597 | # git-2.2.0 added "%%cI", which expands to an ISO-8601 -compliant 598 | # datestamp. However we prefer "%%ci" (which expands to an "ISO-8601 599 | # -like" string, which we must then edit to make compliant), because 600 | # it's been around since git-1.5.3, and it's too difficult to 601 | # discover which version we're using, or to work around using an 602 | # older one. 603 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 604 | refnames = keywords["refnames"].strip() 605 | if refnames.startswith("$Format"): 606 | if verbose: 607 | print("keywords are unexpanded, not using") 608 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 609 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 610 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 611 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 612 | TAG = "tag: " 613 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 614 | if not tags: 615 | # Either we're using git < 1.8.3, or there really are no tags. We use 616 | # a heuristic: assume all version tags have a digit. The old git %%d 617 | # expansion behaves like git log --decorate=short and strips out the 618 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 619 | # between branches and tags. By ignoring refnames without digits, we 620 | # filter out many common branch names like "release" and 621 | # "stabilization", as well as "HEAD" and "master". 622 | tags = set([r for r in refs if re.search(r'\d', r)]) 623 | if verbose: 624 | print("discarding '%%s', no digits" %% ",".join(refs - tags)) 625 | if verbose: 626 | print("likely tags: %%s" %% ",".join(sorted(tags))) 627 | for ref in sorted(tags): 628 | # sorting will prefer e.g. "2.0" over "2.0rc1" 629 | if ref.startswith(tag_prefix): 630 | r = ref[len(tag_prefix):] 631 | if verbose: 632 | print("picking %%s" %% r) 633 | return {"version": r, 634 | "full-revisionid": keywords["full"].strip(), 635 | "dirty": False, "error": None, 636 | "date": date} 637 | # no suitable tags, so version is "0+unknown", but full hex is still there 638 | if verbose: 639 | print("no suitable tags, using unknown + full revision id") 640 | return {"version": "0+unknown", 641 | "full-revisionid": keywords["full"].strip(), 642 | "dirty": False, "error": "no suitable tags", "date": None} 643 | 644 | 645 | @register_vcs_handler("git", "pieces_from_vcs") 646 | def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): 647 | """Get version from 'git describe' in the root of the source tree. 648 | 649 | This only gets called if the git-archive 'subst' keywords were *not* 650 | expanded, and _version.py hasn't already been rewritten with a short 651 | version string, meaning we're inside a checked out source tree. 652 | """ 653 | GITS = ["git"] 654 | if sys.platform == "win32": 655 | GITS = ["git.cmd", "git.exe"] 656 | 657 | out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, 658 | hide_stderr=True) 659 | if rc != 0: 660 | if verbose: 661 | print("Directory %%s not under git control" %% root) 662 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 663 | 664 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 665 | # if there isn't one, this yields HEX[-dirty] (no NUM) 666 | describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", 667 | "--always", "--long", 668 | "--match", "%%s*" %% tag_prefix], 669 | cwd=root) 670 | # --long was added in git-1.5.5 671 | if describe_out is None: 672 | raise NotThisMethod("'git describe' failed") 673 | describe_out = describe_out.strip() 674 | full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 675 | if full_out is None: 676 | raise NotThisMethod("'git rev-parse' failed") 677 | full_out = full_out.strip() 678 | 679 | pieces = {} 680 | pieces["long"] = full_out 681 | pieces["short"] = full_out[:7] # maybe improved later 682 | pieces["error"] = None 683 | 684 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 685 | # TAG might have hyphens. 686 | git_describe = describe_out 687 | 688 | # look for -dirty suffix 689 | dirty = git_describe.endswith("-dirty") 690 | pieces["dirty"] = dirty 691 | if dirty: 692 | git_describe = git_describe[:git_describe.rindex("-dirty")] 693 | 694 | # now we have TAG-NUM-gHEX or HEX 695 | 696 | if "-" in git_describe: 697 | # TAG-NUM-gHEX 698 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 699 | if not mo: 700 | # unparseable. Maybe git-describe is misbehaving? 701 | pieces["error"] = ("unable to parse git-describe output: '%%s'" 702 | %% describe_out) 703 | return pieces 704 | 705 | # tag 706 | full_tag = mo.group(1) 707 | if not full_tag.startswith(tag_prefix): 708 | if verbose: 709 | fmt = "tag '%%s' doesn't start with prefix '%%s'" 710 | print(fmt %% (full_tag, tag_prefix)) 711 | pieces["error"] = ("tag '%%s' doesn't start with prefix '%%s'" 712 | %% (full_tag, tag_prefix)) 713 | return pieces 714 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 715 | 716 | # distance: number of commits since tag 717 | pieces["distance"] = int(mo.group(2)) 718 | 719 | # commit: short hex revision ID 720 | pieces["short"] = mo.group(3) 721 | 722 | else: 723 | # HEX: no tags 724 | pieces["closest-tag"] = None 725 | count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], 726 | cwd=root) 727 | pieces["distance"] = int(count_out) # total number of commits 728 | 729 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 730 | date = run_command(GITS, ["show", "-s", "--format=%%ci", "HEAD"], 731 | cwd=root)[0].strip() 732 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 733 | 734 | return pieces 735 | 736 | 737 | def plus_or_dot(pieces): 738 | """Return a + if we don't already have one, else return a .""" 739 | if "+" in pieces.get("closest-tag", ""): 740 | return "." 741 | return "+" 742 | 743 | 744 | def render_pep440(pieces): 745 | """Build up version string, with post-release "local version identifier". 746 | 747 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 748 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 749 | 750 | Exceptions: 751 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 752 | """ 753 | if pieces["closest-tag"]: 754 | rendered = pieces["closest-tag"] 755 | if pieces["distance"] or pieces["dirty"]: 756 | rendered += plus_or_dot(pieces) 757 | rendered += "%%d.g%%s" %% (pieces["distance"], pieces["short"]) 758 | if pieces["dirty"]: 759 | rendered += ".dirty" 760 | else: 761 | # exception #1 762 | rendered = "0+untagged.%%d.g%%s" %% (pieces["distance"], 763 | pieces["short"]) 764 | if pieces["dirty"]: 765 | rendered += ".dirty" 766 | return rendered 767 | 768 | 769 | def render_pep440_pre(pieces): 770 | """TAG[.post.devDISTANCE] -- No -dirty. 771 | 772 | Exceptions: 773 | 1: no tags. 0.post.devDISTANCE 774 | """ 775 | if pieces["closest-tag"]: 776 | rendered = pieces["closest-tag"] 777 | if pieces["distance"]: 778 | rendered += ".post.dev%%d" %% pieces["distance"] 779 | else: 780 | # exception #1 781 | rendered = "0.post.dev%%d" %% pieces["distance"] 782 | return rendered 783 | 784 | 785 | def render_pep440_post(pieces): 786 | """TAG[.postDISTANCE[.dev0]+gHEX] . 787 | 788 | The ".dev0" means dirty. Note that .dev0 sorts backwards 789 | (a dirty tree will appear "older" than the corresponding clean one), 790 | but you shouldn't be releasing software with -dirty anyways. 791 | 792 | Exceptions: 793 | 1: no tags. 0.postDISTANCE[.dev0] 794 | """ 795 | if pieces["closest-tag"]: 796 | rendered = pieces["closest-tag"] 797 | if pieces["distance"] or pieces["dirty"]: 798 | rendered += ".post%%d" %% pieces["distance"] 799 | if pieces["dirty"]: 800 | rendered += ".dev0" 801 | rendered += plus_or_dot(pieces) 802 | rendered += "g%%s" %% pieces["short"] 803 | else: 804 | # exception #1 805 | rendered = "0.post%%d" %% pieces["distance"] 806 | if pieces["dirty"]: 807 | rendered += ".dev0" 808 | rendered += "+g%%s" %% pieces["short"] 809 | return rendered 810 | 811 | 812 | def render_pep440_old(pieces): 813 | """TAG[.postDISTANCE[.dev0]] . 814 | 815 | The ".dev0" means dirty. 816 | 817 | Eexceptions: 818 | 1: no tags. 0.postDISTANCE[.dev0] 819 | """ 820 | if pieces["closest-tag"]: 821 | rendered = pieces["closest-tag"] 822 | if pieces["distance"] or pieces["dirty"]: 823 | rendered += ".post%%d" %% pieces["distance"] 824 | if pieces["dirty"]: 825 | rendered += ".dev0" 826 | else: 827 | # exception #1 828 | rendered = "0.post%%d" %% pieces["distance"] 829 | if pieces["dirty"]: 830 | rendered += ".dev0" 831 | return rendered 832 | 833 | 834 | def render_git_describe(pieces): 835 | """TAG[-DISTANCE-gHEX][-dirty]. 836 | 837 | Like 'git describe --tags --dirty --always'. 838 | 839 | Exceptions: 840 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 841 | """ 842 | if pieces["closest-tag"]: 843 | rendered = pieces["closest-tag"] 844 | if pieces["distance"]: 845 | rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) 846 | else: 847 | # exception #1 848 | rendered = pieces["short"] 849 | if pieces["dirty"]: 850 | rendered += "-dirty" 851 | return rendered 852 | 853 | 854 | def render_git_describe_long(pieces): 855 | """TAG-DISTANCE-gHEX[-dirty]. 856 | 857 | Like 'git describe --tags --dirty --always -long'. 858 | The distance/hash is unconditional. 859 | 860 | Exceptions: 861 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 862 | """ 863 | if pieces["closest-tag"]: 864 | rendered = pieces["closest-tag"] 865 | rendered += "-%%d-g%%s" %% (pieces["distance"], pieces["short"]) 866 | else: 867 | # exception #1 868 | rendered = pieces["short"] 869 | if pieces["dirty"]: 870 | rendered += "-dirty" 871 | return rendered 872 | 873 | 874 | def render(pieces, style): 875 | """Render the given version pieces into the requested style.""" 876 | if pieces["error"]: 877 | return {"version": "unknown", 878 | "full-revisionid": pieces.get("long"), 879 | "dirty": None, 880 | "error": pieces["error"], 881 | "date": None} 882 | 883 | if not style or style == "default": 884 | style = "pep440" # the default 885 | 886 | if style == "pep440": 887 | rendered = render_pep440(pieces) 888 | elif style == "pep440-pre": 889 | rendered = render_pep440_pre(pieces) 890 | elif style == "pep440-post": 891 | rendered = render_pep440_post(pieces) 892 | elif style == "pep440-old": 893 | rendered = render_pep440_old(pieces) 894 | elif style == "git-describe": 895 | rendered = render_git_describe(pieces) 896 | elif style == "git-describe-long": 897 | rendered = render_git_describe_long(pieces) 898 | else: 899 | raise ValueError("unknown style '%%s'" %% style) 900 | 901 | return {"version": rendered, "full-revisionid": pieces["long"], 902 | "dirty": pieces["dirty"], "error": None, 903 | "date": pieces.get("date")} 904 | 905 | 906 | def get_versions(): 907 | """Get version information or return default if unable to do so.""" 908 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 909 | # __file__, we can work backwards from there to the root. Some 910 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 911 | # case we can only use expanded keywords. 912 | 913 | cfg = get_config() 914 | verbose = cfg.verbose 915 | 916 | try: 917 | return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, 918 | verbose) 919 | except NotThisMethod: 920 | pass 921 | 922 | try: 923 | root = os.path.realpath(__file__) 924 | # versionfile_source is the relative path from the top of the source 925 | # tree (where the .git directory might live) to this file. Invert 926 | # this to find the root from __file__. 927 | for i in cfg.versionfile_source.split('/'): 928 | root = os.path.dirname(root) 929 | except NameError: 930 | return {"version": "0+unknown", "full-revisionid": None, 931 | "dirty": None, 932 | "error": "unable to find root of source tree", 933 | "date": None} 934 | 935 | try: 936 | pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) 937 | return render(pieces, cfg.style) 938 | except NotThisMethod: 939 | pass 940 | 941 | try: 942 | if cfg.parentdir_prefix: 943 | return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 944 | except NotThisMethod: 945 | pass 946 | 947 | return {"version": "0+unknown", "full-revisionid": None, 948 | "dirty": None, 949 | "error": "unable to compute version", "date": None} 950 | ''' 951 | 952 | 953 | @register_vcs_handler("git", "get_keywords") 954 | def git_get_keywords(versionfile_abs): 955 | """Extract version information from the given file.""" 956 | # the code embedded in _version.py can just fetch the value of these 957 | # keywords. When used from setup.py, we don't want to import _version.py, 958 | # so we do it with a regexp instead. This function is not used from 959 | # _version.py. 960 | keywords = {} 961 | try: 962 | f = open(versionfile_abs, "r") 963 | for line in f.readlines(): 964 | if line.strip().startswith("git_refnames ="): 965 | mo = re.search(r'=\s*"(.*)"', line) 966 | if mo: 967 | keywords["refnames"] = mo.group(1) 968 | if line.strip().startswith("git_full ="): 969 | mo = re.search(r'=\s*"(.*)"', line) 970 | if mo: 971 | keywords["full"] = mo.group(1) 972 | if line.strip().startswith("git_date ="): 973 | mo = re.search(r'=\s*"(.*)"', line) 974 | if mo: 975 | keywords["date"] = mo.group(1) 976 | f.close() 977 | except EnvironmentError: 978 | pass 979 | return keywords 980 | 981 | 982 | @register_vcs_handler("git", "keywords") 983 | def git_versions_from_keywords(keywords, tag_prefix, verbose): 984 | """Get version information from git keywords.""" 985 | if not keywords: 986 | raise NotThisMethod("no keywords at all, weird") 987 | date = keywords.get("date") 988 | if date is not None: 989 | # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant 990 | # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 991 | # -like" string, which we must then edit to make compliant), because 992 | # it's been around since git-1.5.3, and it's too difficult to 993 | # discover which version we're using, or to work around using an 994 | # older one. 995 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 996 | refnames = keywords["refnames"].strip() 997 | if refnames.startswith("$Format"): 998 | if verbose: 999 | print("keywords are unexpanded, not using") 1000 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 1001 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 1002 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 1003 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 1004 | TAG = "tag: " 1005 | tags = set([r[len(TAG) :] for r in refs if r.startswith(TAG)]) 1006 | if not tags: 1007 | # Either we're using git < 1.8.3, or there really are no tags. We use 1008 | # a heuristic: assume all version tags have a digit. The old git %d 1009 | # expansion behaves like git log --decorate=short and strips out the 1010 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 1011 | # between branches and tags. By ignoring refnames without digits, we 1012 | # filter out many common branch names like "release" and 1013 | # "stabilization", as well as "HEAD" and "master". 1014 | tags = set([r for r in refs if re.search(r"\d", r)]) 1015 | if verbose: 1016 | print("discarding '%s', no digits" % ",".join(refs - tags)) 1017 | if verbose: 1018 | print("likely tags: %s" % ",".join(sorted(tags))) 1019 | for ref in sorted(tags): 1020 | # sorting will prefer e.g. "2.0" over "2.0rc1" 1021 | if ref.startswith(tag_prefix): 1022 | r = ref[len(tag_prefix) :] 1023 | if verbose: 1024 | print("picking %s" % r) 1025 | return { 1026 | "version": r, 1027 | "full-revisionid": keywords["full"].strip(), 1028 | "dirty": False, 1029 | "error": None, 1030 | "date": date, 1031 | } 1032 | # no suitable tags, so version is "0+unknown", but full hex is still there 1033 | if verbose: 1034 | print("no suitable tags, using unknown + full revision id") 1035 | return { 1036 | "version": "0+unknown", 1037 | "full-revisionid": keywords["full"].strip(), 1038 | "dirty": False, 1039 | "error": "no suitable tags", 1040 | "date": None, 1041 | } 1042 | 1043 | 1044 | @register_vcs_handler("git", "pieces_from_vcs") 1045 | def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): 1046 | """Get version from 'git describe' in the root of the source tree. 1047 | 1048 | This only gets called if the git-archive 'subst' keywords were *not* 1049 | expanded, and _version.py hasn't already been rewritten with a short 1050 | version string, meaning we're inside a checked out source tree. 1051 | """ 1052 | GITS = ["git"] 1053 | if sys.platform == "win32": 1054 | GITS = ["git.cmd", "git.exe"] 1055 | 1056 | out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) 1057 | if rc != 0: 1058 | if verbose: 1059 | print("Directory %s not under git control" % root) 1060 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 1061 | 1062 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 1063 | # if there isn't one, this yields HEX[-dirty] (no NUM) 1064 | describe_out, rc = run_command( 1065 | GITS, 1066 | [ 1067 | "describe", 1068 | "--tags", 1069 | "--dirty", 1070 | "--always", 1071 | "--long", 1072 | "--match", 1073 | "%s*" % tag_prefix, 1074 | ], 1075 | cwd=root, 1076 | ) 1077 | # --long was added in git-1.5.5 1078 | if describe_out is None: 1079 | raise NotThisMethod("'git describe' failed") 1080 | describe_out = describe_out.strip() 1081 | full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 1082 | if full_out is None: 1083 | raise NotThisMethod("'git rev-parse' failed") 1084 | full_out = full_out.strip() 1085 | 1086 | pieces = {} 1087 | pieces["long"] = full_out 1088 | pieces["short"] = full_out[:7] # maybe improved later 1089 | pieces["error"] = None 1090 | 1091 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 1092 | # TAG might have hyphens. 1093 | git_describe = describe_out 1094 | 1095 | # look for -dirty suffix 1096 | dirty = git_describe.endswith("-dirty") 1097 | pieces["dirty"] = dirty 1098 | if dirty: 1099 | git_describe = git_describe[: git_describe.rindex("-dirty")] 1100 | 1101 | # now we have TAG-NUM-gHEX or HEX 1102 | 1103 | if "-" in git_describe: 1104 | # TAG-NUM-gHEX 1105 | mo = re.search(r"^(.+)-(\d+)-g([0-9a-f]+)$", git_describe) 1106 | if not mo: 1107 | # unparseable. Maybe git-describe is misbehaving? 1108 | pieces["error"] = "unable to parse git-describe output: '%s'" % describe_out 1109 | return pieces 1110 | 1111 | # tag 1112 | full_tag = mo.group(1) 1113 | if not full_tag.startswith(tag_prefix): 1114 | if verbose: 1115 | fmt = "tag '%s' doesn't start with prefix '%s'" 1116 | print(fmt % (full_tag, tag_prefix)) 1117 | pieces["error"] = "tag '%s' doesn't start with prefix '%s'" % ( 1118 | full_tag, 1119 | tag_prefix, 1120 | ) 1121 | return pieces 1122 | pieces["closest-tag"] = full_tag[len(tag_prefix) :] 1123 | 1124 | # distance: number of commits since tag 1125 | pieces["distance"] = int(mo.group(2)) 1126 | 1127 | # commit: short hex revision ID 1128 | pieces["short"] = mo.group(3) 1129 | 1130 | else: 1131 | # HEX: no tags 1132 | pieces["closest-tag"] = None 1133 | count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], cwd=root) 1134 | pieces["distance"] = int(count_out) # total number of commits 1135 | 1136 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 1137 | date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[ 1138 | 0 1139 | ].strip() 1140 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 1141 | 1142 | return pieces 1143 | 1144 | 1145 | def do_vcs_install(manifest_in, versionfile_source, ipy): 1146 | """Git-specific installation logic for Versioneer. 1147 | 1148 | For Git, this means creating/changing .gitattributes to mark _version.py 1149 | for export-subst keyword substitution. 1150 | """ 1151 | GITS = ["git"] 1152 | if sys.platform == "win32": 1153 | GITS = ["git.cmd", "git.exe"] 1154 | files = [manifest_in, versionfile_source] 1155 | if ipy: 1156 | files.append(ipy) 1157 | try: 1158 | me = __file__ 1159 | if me.endswith(".pyc") or me.endswith(".pyo"): 1160 | me = os.path.splitext(me)[0] + ".py" 1161 | versioneer_file = os.path.relpath(me) 1162 | except NameError: 1163 | versioneer_file = "versioneer.py" 1164 | files.append(versioneer_file) 1165 | present = False 1166 | try: 1167 | f = open(".gitattributes", "r") 1168 | for line in f.readlines(): 1169 | if line.strip().startswith(versionfile_source): 1170 | if "export-subst" in line.strip().split()[1:]: 1171 | present = True 1172 | f.close() 1173 | except EnvironmentError: 1174 | pass 1175 | if not present: 1176 | f = open(".gitattributes", "a+") 1177 | f.write("%s export-subst\n" % versionfile_source) 1178 | f.close() 1179 | files.append(".gitattributes") 1180 | run_command(GITS, ["add", "--"] + files) 1181 | 1182 | 1183 | def versions_from_parentdir(parentdir_prefix, root, verbose): 1184 | """Try to determine the version from the parent directory name. 1185 | 1186 | Source tarballs conventionally unpack into a directory that includes both 1187 | the project name and a version string. We will also support searching up 1188 | two directory levels for an appropriately named parent directory 1189 | """ 1190 | rootdirs = [] 1191 | 1192 | for i in range(3): 1193 | dirname = os.path.basename(root) 1194 | if dirname.startswith(parentdir_prefix): 1195 | return { 1196 | "version": dirname[len(parentdir_prefix) :], 1197 | "full-revisionid": None, 1198 | "dirty": False, 1199 | "error": None, 1200 | "date": None, 1201 | } 1202 | else: 1203 | rootdirs.append(root) 1204 | root = os.path.dirname(root) # up a level 1205 | 1206 | if verbose: 1207 | print( 1208 | "Tried directories %s but none started with prefix %s" 1209 | % (str(rootdirs), parentdir_prefix) 1210 | ) 1211 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 1212 | 1213 | 1214 | SHORT_VERSION_PY = """ 1215 | # This file was generated by 'versioneer.py' (0.18) from 1216 | # revision-control system data, or from the parent directory name of an 1217 | # unpacked source archive. Distribution tarballs contain a pre-generated copy 1218 | # of this file. 1219 | 1220 | import json 1221 | 1222 | version_json = ''' 1223 | %s 1224 | ''' # END VERSION_JSON 1225 | 1226 | 1227 | def get_versions(): 1228 | return json.loads(version_json) 1229 | """ 1230 | 1231 | 1232 | def versions_from_file(filename): 1233 | """Try to determine the version from _version.py if present.""" 1234 | try: 1235 | with open(filename) as f: 1236 | contents = f.read() 1237 | except EnvironmentError: 1238 | raise NotThisMethod("unable to read _version.py") 1239 | mo = re.search( 1240 | r"version_json = '''\n(.*)''' # END VERSION_JSON", contents, re.M | re.S 1241 | ) 1242 | if not mo: 1243 | mo = re.search( 1244 | r"version_json = '''\r\n(.*)''' # END VERSION_JSON", contents, re.M | re.S 1245 | ) 1246 | if not mo: 1247 | raise NotThisMethod("no version_json in _version.py") 1248 | return json.loads(mo.group(1)) 1249 | 1250 | 1251 | def write_to_version_file(filename, versions): 1252 | """Write the given version number to the given _version.py file.""" 1253 | os.unlink(filename) 1254 | contents = json.dumps(versions, sort_keys=True, indent=1, separators=(",", ": ")) 1255 | with open(filename, "w") as f: 1256 | f.write(SHORT_VERSION_PY % contents) 1257 | 1258 | print("set %s to '%s'" % (filename, versions["version"])) 1259 | 1260 | 1261 | def plus_or_dot(pieces): 1262 | """Return a + if we don't already have one, else return a .""" 1263 | if "+" in pieces.get("closest-tag", ""): 1264 | return "." 1265 | return "+" 1266 | 1267 | 1268 | def render_pep440(pieces): 1269 | """Build up version string, with post-release "local version identifier". 1270 | 1271 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 1272 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 1273 | 1274 | Exceptions: 1275 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 1276 | """ 1277 | if pieces["closest-tag"]: 1278 | rendered = pieces["closest-tag"] 1279 | if pieces["distance"] or pieces["dirty"]: 1280 | rendered += plus_or_dot(pieces) 1281 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 1282 | if pieces["dirty"]: 1283 | rendered += ".dirty" 1284 | else: 1285 | # exception #1 1286 | rendered = "0+untagged.%d.g%s" % (pieces["distance"], pieces["short"]) 1287 | if pieces["dirty"]: 1288 | rendered += ".dirty" 1289 | return rendered 1290 | 1291 | 1292 | def render_pep440_pre(pieces): 1293 | """TAG[.post.devDISTANCE] -- No -dirty. 1294 | 1295 | Exceptions: 1296 | 1: no tags. 0.post.devDISTANCE 1297 | """ 1298 | if pieces["closest-tag"]: 1299 | rendered = pieces["closest-tag"] 1300 | if pieces["distance"]: 1301 | rendered += ".post.dev%d" % pieces["distance"] 1302 | else: 1303 | # exception #1 1304 | rendered = "0.post.dev%d" % pieces["distance"] 1305 | return rendered 1306 | 1307 | 1308 | def render_pep440_post(pieces): 1309 | """TAG[.postDISTANCE[.dev0]+gHEX] . 1310 | 1311 | The ".dev0" means dirty. Note that .dev0 sorts backwards 1312 | (a dirty tree will appear "older" than the corresponding clean one), 1313 | but you shouldn't be releasing software with -dirty anyways. 1314 | 1315 | Exceptions: 1316 | 1: no tags. 0.postDISTANCE[.dev0] 1317 | """ 1318 | if pieces["closest-tag"]: 1319 | rendered = pieces["closest-tag"] 1320 | if pieces["distance"] or pieces["dirty"]: 1321 | rendered += ".post%d" % pieces["distance"] 1322 | if pieces["dirty"]: 1323 | rendered += ".dev0" 1324 | rendered += plus_or_dot(pieces) 1325 | rendered += "g%s" % pieces["short"] 1326 | else: 1327 | # exception #1 1328 | rendered = "0.post%d" % pieces["distance"] 1329 | if pieces["dirty"]: 1330 | rendered += ".dev0" 1331 | rendered += "+g%s" % pieces["short"] 1332 | return rendered 1333 | 1334 | 1335 | def render_pep440_old(pieces): 1336 | """TAG[.postDISTANCE[.dev0]] . 1337 | 1338 | The ".dev0" means dirty. 1339 | 1340 | Eexceptions: 1341 | 1: no tags. 0.postDISTANCE[.dev0] 1342 | """ 1343 | if pieces["closest-tag"]: 1344 | rendered = pieces["closest-tag"] 1345 | if pieces["distance"] or pieces["dirty"]: 1346 | rendered += ".post%d" % pieces["distance"] 1347 | if pieces["dirty"]: 1348 | rendered += ".dev0" 1349 | else: 1350 | # exception #1 1351 | rendered = "0.post%d" % pieces["distance"] 1352 | if pieces["dirty"]: 1353 | rendered += ".dev0" 1354 | return rendered 1355 | 1356 | 1357 | def render_git_describe(pieces): 1358 | """TAG[-DISTANCE-gHEX][-dirty]. 1359 | 1360 | Like 'git describe --tags --dirty --always'. 1361 | 1362 | Exceptions: 1363 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 1364 | """ 1365 | if pieces["closest-tag"]: 1366 | rendered = pieces["closest-tag"] 1367 | if pieces["distance"]: 1368 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 1369 | else: 1370 | # exception #1 1371 | rendered = pieces["short"] 1372 | if pieces["dirty"]: 1373 | rendered += "-dirty" 1374 | return rendered 1375 | 1376 | 1377 | def render_git_describe_long(pieces): 1378 | """TAG-DISTANCE-gHEX[-dirty]. 1379 | 1380 | Like 'git describe --tags --dirty --always -long'. 1381 | The distance/hash is unconditional. 1382 | 1383 | Exceptions: 1384 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 1385 | """ 1386 | if pieces["closest-tag"]: 1387 | rendered = pieces["closest-tag"] 1388 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 1389 | else: 1390 | # exception #1 1391 | rendered = pieces["short"] 1392 | if pieces["dirty"]: 1393 | rendered += "-dirty" 1394 | return rendered 1395 | 1396 | 1397 | def render(pieces, style): 1398 | """Render the given version pieces into the requested style.""" 1399 | if pieces["error"]: 1400 | return { 1401 | "version": "unknown", 1402 | "full-revisionid": pieces.get("long"), 1403 | "dirty": None, 1404 | "error": pieces["error"], 1405 | "date": None, 1406 | } 1407 | 1408 | if not style or style == "default": 1409 | style = "pep440" # the default 1410 | 1411 | if style == "pep440": 1412 | rendered = render_pep440(pieces) 1413 | elif style == "pep440-pre": 1414 | rendered = render_pep440_pre(pieces) 1415 | elif style == "pep440-post": 1416 | rendered = render_pep440_post(pieces) 1417 | elif style == "pep440-old": 1418 | rendered = render_pep440_old(pieces) 1419 | elif style == "git-describe": 1420 | rendered = render_git_describe(pieces) 1421 | elif style == "git-describe-long": 1422 | rendered = render_git_describe_long(pieces) 1423 | else: 1424 | raise ValueError("unknown style '%s'" % style) 1425 | 1426 | return { 1427 | "version": rendered, 1428 | "full-revisionid": pieces["long"], 1429 | "dirty": pieces["dirty"], 1430 | "error": None, 1431 | "date": pieces.get("date"), 1432 | } 1433 | 1434 | 1435 | class VersioneerBadRootError(Exception): 1436 | """The project root directory is unknown or missing key files.""" 1437 | 1438 | 1439 | def get_versions(verbose=False): 1440 | """Get the project version from whatever source is available. 1441 | 1442 | Returns dict with two keys: 'version' and 'full'. 1443 | """ 1444 | if "versioneer" in sys.modules: 1445 | # see the discussion in cmdclass.py:get_cmdclass() 1446 | del sys.modules["versioneer"] 1447 | 1448 | root = get_root() 1449 | cfg = get_config_from_root(root) 1450 | 1451 | assert cfg.VCS is not None, "please set [versioneer]VCS= in setup.cfg" 1452 | handlers = HANDLERS.get(cfg.VCS) 1453 | assert handlers, "unrecognized VCS '%s'" % cfg.VCS 1454 | verbose = verbose or cfg.verbose 1455 | assert ( 1456 | cfg.versionfile_source is not None 1457 | ), "please set versioneer.versionfile_source" 1458 | assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix" 1459 | 1460 | versionfile_abs = os.path.join(root, cfg.versionfile_source) 1461 | 1462 | # extract version from first of: _version.py, VCS command (e.g. 'git 1463 | # describe'), parentdir. This is meant to work for developers using a 1464 | # source checkout, for users of a tarball created by 'setup.py sdist', 1465 | # and for users of a tarball/zipball created by 'git archive' or github's 1466 | # download-from-tag feature or the equivalent in other VCSes. 1467 | 1468 | get_keywords_f = handlers.get("get_keywords") 1469 | from_keywords_f = handlers.get("keywords") 1470 | if get_keywords_f and from_keywords_f: 1471 | try: 1472 | keywords = get_keywords_f(versionfile_abs) 1473 | ver = from_keywords_f(keywords, cfg.tag_prefix, verbose) 1474 | if verbose: 1475 | print("got version from expanded keyword %s" % ver) 1476 | return ver 1477 | except NotThisMethod: 1478 | pass 1479 | 1480 | try: 1481 | ver = versions_from_file(versionfile_abs) 1482 | if verbose: 1483 | print("got version from file %s %s" % (versionfile_abs, ver)) 1484 | return ver 1485 | except NotThisMethod: 1486 | pass 1487 | 1488 | from_vcs_f = handlers.get("pieces_from_vcs") 1489 | if from_vcs_f: 1490 | try: 1491 | pieces = from_vcs_f(cfg.tag_prefix, root, verbose) 1492 | ver = render(pieces, cfg.style) 1493 | if verbose: 1494 | print("got version from VCS %s" % ver) 1495 | return ver 1496 | except NotThisMethod: 1497 | pass 1498 | 1499 | try: 1500 | if cfg.parentdir_prefix: 1501 | ver = versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 1502 | if verbose: 1503 | print("got version from parentdir %s" % ver) 1504 | return ver 1505 | except NotThisMethod: 1506 | pass 1507 | 1508 | if verbose: 1509 | print("unable to compute version") 1510 | 1511 | return { 1512 | "version": "0+unknown", 1513 | "full-revisionid": None, 1514 | "dirty": None, 1515 | "error": "unable to compute version", 1516 | "date": None, 1517 | } 1518 | 1519 | 1520 | def get_version(): 1521 | """Get the short version string for this project.""" 1522 | return get_versions()["version"] 1523 | 1524 | 1525 | def get_cmdclass(): 1526 | """Get the custom setuptools/distutils subclasses used by Versioneer.""" 1527 | if "versioneer" in sys.modules: 1528 | del sys.modules["versioneer"] 1529 | # this fixes the "python setup.py develop" case (also 'install' and 1530 | # 'easy_install .'), in which subdependencies of the main project are 1531 | # built (using setup.py bdist_egg) in the same python process. Assume 1532 | # a main project A and a dependency B, which use different versions 1533 | # of Versioneer. A's setup.py imports A's Versioneer, leaving it in 1534 | # sys.modules by the time B's setup.py is executed, causing B to run 1535 | # with the wrong versioneer. Setuptools wraps the sub-dep builds in a 1536 | # sandbox that restores sys.modules to it's pre-build state, so the 1537 | # parent is protected against the child's "import versioneer". By 1538 | # removing ourselves from sys.modules here, before the child build 1539 | # happens, we protect the child from the parent's versioneer too. 1540 | # Also see https://github.com/warner/python-versioneer/issues/52 1541 | 1542 | cmds = {} 1543 | 1544 | # we add "version" to both distutils and setuptools 1545 | from distutils.core import Command 1546 | 1547 | class cmd_version(Command): 1548 | description = "report generated version string" 1549 | user_options = [] 1550 | boolean_options = [] 1551 | 1552 | def initialize_options(self): 1553 | pass 1554 | 1555 | def finalize_options(self): 1556 | pass 1557 | 1558 | def run(self): 1559 | vers = get_versions(verbose=True) 1560 | print("Version: %s" % vers["version"]) 1561 | print(" full-revisionid: %s" % vers.get("full-revisionid")) 1562 | print(" dirty: %s" % vers.get("dirty")) 1563 | print(" date: %s" % vers.get("date")) 1564 | if vers["error"]: 1565 | print(" error: %s" % vers["error"]) 1566 | 1567 | cmds["version"] = cmd_version 1568 | 1569 | # we override "build_py" in both distutils and setuptools 1570 | # 1571 | # most invocation pathways end up running build_py: 1572 | # distutils/build -> build_py 1573 | # distutils/install -> distutils/build ->.. 1574 | # setuptools/bdist_wheel -> distutils/install ->.. 1575 | # setuptools/bdist_egg -> distutils/install_lib -> build_py 1576 | # setuptools/install -> bdist_egg ->.. 1577 | # setuptools/develop -> ? 1578 | # pip install: 1579 | # copies source tree to a tempdir before running egg_info/etc 1580 | # if .git isn't copied too, 'git describe' will fail 1581 | # then does setup.py bdist_wheel, or sometimes setup.py install 1582 | # setup.py egg_info -> ? 1583 | 1584 | # we override different "build_py" commands for both environments 1585 | if "setuptools" in sys.modules: 1586 | from setuptools.command.build_py import build_py as _build_py 1587 | else: 1588 | from distutils.command.build_py import build_py as _build_py 1589 | 1590 | class cmd_build_py(_build_py): 1591 | def run(self): 1592 | root = get_root() 1593 | cfg = get_config_from_root(root) 1594 | versions = get_versions() 1595 | _build_py.run(self) 1596 | # now locate _version.py in the new build/ directory and replace 1597 | # it with an updated value 1598 | if cfg.versionfile_build: 1599 | target_versionfile = os.path.join(self.build_lib, cfg.versionfile_build) 1600 | print("UPDATING %s" % target_versionfile) 1601 | write_to_version_file(target_versionfile, versions) 1602 | 1603 | cmds["build_py"] = cmd_build_py 1604 | 1605 | if "cx_Freeze" in sys.modules: # cx_freeze enabled? 1606 | from cx_Freeze.dist import build_exe as _build_exe 1607 | # nczeczulin reports that py2exe won't like the pep440-style string 1608 | # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. 1609 | # setup(console=[{ 1610 | # "version": versioneer.get_version().split("+", 1)[0], # FILEVERSION 1611 | # "product_version": versioneer.get_version(), 1612 | # ... 1613 | 1614 | class cmd_build_exe(_build_exe): 1615 | def run(self): 1616 | root = get_root() 1617 | cfg = get_config_from_root(root) 1618 | versions = get_versions() 1619 | target_versionfile = cfg.versionfile_source 1620 | print("UPDATING %s" % target_versionfile) 1621 | write_to_version_file(target_versionfile, versions) 1622 | 1623 | _build_exe.run(self) 1624 | os.unlink(target_versionfile) 1625 | with open(cfg.versionfile_source, "w") as f: 1626 | LONG = LONG_VERSION_PY[cfg.VCS] 1627 | f.write( 1628 | LONG 1629 | % { 1630 | "DOLLAR": "$", 1631 | "STYLE": cfg.style, 1632 | "TAG_PREFIX": cfg.tag_prefix, 1633 | "PARENTDIR_PREFIX": cfg.parentdir_prefix, 1634 | "VERSIONFILE_SOURCE": cfg.versionfile_source, 1635 | } 1636 | ) 1637 | 1638 | cmds["build_exe"] = cmd_build_exe 1639 | del cmds["build_py"] 1640 | 1641 | if "py2exe" in sys.modules: # py2exe enabled? 1642 | try: 1643 | from py2exe.distutils_buildexe import py2exe as _py2exe # py3 1644 | except ImportError: 1645 | from py2exe.build_exe import py2exe as _py2exe # py2 1646 | 1647 | class cmd_py2exe(_py2exe): 1648 | def run(self): 1649 | root = get_root() 1650 | cfg = get_config_from_root(root) 1651 | versions = get_versions() 1652 | target_versionfile = cfg.versionfile_source 1653 | print("UPDATING %s" % target_versionfile) 1654 | write_to_version_file(target_versionfile, versions) 1655 | 1656 | _py2exe.run(self) 1657 | os.unlink(target_versionfile) 1658 | with open(cfg.versionfile_source, "w") as f: 1659 | LONG = LONG_VERSION_PY[cfg.VCS] 1660 | f.write( 1661 | LONG 1662 | % { 1663 | "DOLLAR": "$", 1664 | "STYLE": cfg.style, 1665 | "TAG_PREFIX": cfg.tag_prefix, 1666 | "PARENTDIR_PREFIX": cfg.parentdir_prefix, 1667 | "VERSIONFILE_SOURCE": cfg.versionfile_source, 1668 | } 1669 | ) 1670 | 1671 | cmds["py2exe"] = cmd_py2exe 1672 | 1673 | # we override different "sdist" commands for both environments 1674 | if "setuptools" in sys.modules: 1675 | from setuptools.command.sdist import sdist as _sdist 1676 | else: 1677 | from distutils.command.sdist import sdist as _sdist 1678 | 1679 | class cmd_sdist(_sdist): 1680 | def run(self): 1681 | versions = get_versions() 1682 | self._versioneer_generated_versions = versions 1683 | # unless we update this, the command will keep using the old 1684 | # version 1685 | self.distribution.metadata.version = versions["version"] 1686 | return _sdist.run(self) 1687 | 1688 | def make_release_tree(self, base_dir, files): 1689 | root = get_root() 1690 | cfg = get_config_from_root(root) 1691 | _sdist.make_release_tree(self, base_dir, files) 1692 | # now locate _version.py in the new base_dir directory 1693 | # (remembering that it may be a hardlink) and replace it with an 1694 | # updated value 1695 | target_versionfile = os.path.join(base_dir, cfg.versionfile_source) 1696 | print("UPDATING %s" % target_versionfile) 1697 | write_to_version_file( 1698 | target_versionfile, self._versioneer_generated_versions 1699 | ) 1700 | 1701 | cmds["sdist"] = cmd_sdist 1702 | 1703 | return cmds 1704 | 1705 | 1706 | CONFIG_ERROR = """ 1707 | setup.cfg is missing the necessary Versioneer configuration. You need 1708 | a section like: 1709 | 1710 | [versioneer] 1711 | VCS = git 1712 | style = pep440 1713 | versionfile_source = src/myproject/_version.py 1714 | versionfile_build = myproject/_version.py 1715 | tag_prefix = 1716 | parentdir_prefix = myproject- 1717 | 1718 | You will also need to edit your setup.py to use the results: 1719 | 1720 | import versioneer 1721 | setup(version=versioneer.get_version(), 1722 | cmdclass=versioneer.get_cmdclass(), ...) 1723 | 1724 | Please read the docstring in ./versioneer.py for configuration instructions, 1725 | edit setup.cfg, and re-run the installer or 'python versioneer.py setup'. 1726 | """ 1727 | 1728 | SAMPLE_CONFIG = """ 1729 | # See the docstring in versioneer.py for instructions. Note that you must 1730 | # re-run 'versioneer.py setup' after changing this section, and commit the 1731 | # resulting files. 1732 | 1733 | [versioneer] 1734 | #VCS = git 1735 | #style = pep440 1736 | #versionfile_source = 1737 | #versionfile_build = 1738 | #tag_prefix = 1739 | #parentdir_prefix = 1740 | 1741 | """ 1742 | 1743 | INIT_PY_SNIPPET = """ 1744 | from ._version import get_versions 1745 | __version__ = get_versions()['version'] 1746 | del get_versions 1747 | """ 1748 | 1749 | 1750 | def do_setup(): 1751 | """Main VCS-independent setup function for installing Versioneer.""" 1752 | root = get_root() 1753 | try: 1754 | cfg = get_config_from_root(root) 1755 | except ( 1756 | EnvironmentError, 1757 | configparser.NoSectionError, 1758 | configparser.NoOptionError, 1759 | ) as e: 1760 | if isinstance(e, (EnvironmentError, configparser.NoSectionError)): 1761 | print("Adding sample versioneer config to setup.cfg", file=sys.stderr) 1762 | with open(os.path.join(root, "setup.cfg"), "a") as f: 1763 | f.write(SAMPLE_CONFIG) 1764 | print(CONFIG_ERROR, file=sys.stderr) 1765 | return 1 1766 | 1767 | print(" creating %s" % cfg.versionfile_source) 1768 | with open(cfg.versionfile_source, "w") as f: 1769 | LONG = LONG_VERSION_PY[cfg.VCS] 1770 | f.write( 1771 | LONG 1772 | % { 1773 | "DOLLAR": "$", 1774 | "STYLE": cfg.style, 1775 | "TAG_PREFIX": cfg.tag_prefix, 1776 | "PARENTDIR_PREFIX": cfg.parentdir_prefix, 1777 | "VERSIONFILE_SOURCE": cfg.versionfile_source, 1778 | } 1779 | ) 1780 | 1781 | ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") 1782 | if os.path.exists(ipy): 1783 | try: 1784 | with open(ipy, "r") as f: 1785 | old = f.read() 1786 | except EnvironmentError: 1787 | old = "" 1788 | if INIT_PY_SNIPPET not in old: 1789 | print(" appending to %s" % ipy) 1790 | with open(ipy, "a") as f: 1791 | f.write(INIT_PY_SNIPPET) 1792 | else: 1793 | print(" %s unmodified" % ipy) 1794 | else: 1795 | print(" %s doesn't exist, ok" % ipy) 1796 | ipy = None 1797 | 1798 | # Make sure both the top-level "versioneer.py" and versionfile_source 1799 | # (PKG/_version.py, used by runtime code) are in MANIFEST.in, so 1800 | # they'll be copied into source distributions. Pip won't be able to 1801 | # install the package without this. 1802 | manifest_in = os.path.join(root, "MANIFEST.in") 1803 | simple_includes = set() 1804 | try: 1805 | with open(manifest_in, "r") as f: 1806 | for line in f: 1807 | if line.startswith("include "): 1808 | for include in line.split()[1:]: 1809 | simple_includes.add(include) 1810 | except EnvironmentError: 1811 | pass 1812 | # That doesn't cover everything MANIFEST.in can do 1813 | # (http://docs.python.org/2/distutils/sourcedist.html#commands), so 1814 | # it might give some false negatives. Appending redundant 'include' 1815 | # lines is safe, though. 1816 | if "versioneer.py" not in simple_includes: 1817 | print(" appending 'versioneer.py' to MANIFEST.in") 1818 | with open(manifest_in, "a") as f: 1819 | f.write("include versioneer.py\n") 1820 | else: 1821 | print(" 'versioneer.py' already in MANIFEST.in") 1822 | if cfg.versionfile_source not in simple_includes: 1823 | print( 1824 | " appending versionfile_source ('%s') to MANIFEST.in" 1825 | % cfg.versionfile_source 1826 | ) 1827 | with open(manifest_in, "a") as f: 1828 | f.write("include %s\n" % cfg.versionfile_source) 1829 | else: 1830 | print(" versionfile_source already in MANIFEST.in") 1831 | 1832 | # Make VCS-specific changes. For git, this means creating/changing 1833 | # .gitattributes to mark _version.py for export-subst keyword 1834 | # substitution. 1835 | do_vcs_install(manifest_in, cfg.versionfile_source, ipy) 1836 | return 0 1837 | 1838 | 1839 | def scan_setup_py(): 1840 | """Validate the contents of setup.py against Versioneer's expectations.""" 1841 | found = set() 1842 | setters = False 1843 | errors = 0 1844 | with open("setup.py", "r") as f: 1845 | for line in f.readlines(): 1846 | if "import versioneer" in line: 1847 | found.add("import") 1848 | if "versioneer.get_cmdclass()" in line: 1849 | found.add("cmdclass") 1850 | if "versioneer.get_version()" in line: 1851 | found.add("get_version") 1852 | if "versioneer.VCS" in line: 1853 | setters = True 1854 | if "versioneer.versionfile_source" in line: 1855 | setters = True 1856 | if len(found) != 3: 1857 | print("") 1858 | print("Your setup.py appears to be missing some important items") 1859 | print("(but I might be wrong). Please make sure it has something") 1860 | print("roughly like the following:") 1861 | print("") 1862 | print(" import versioneer") 1863 | print(" setup( version=versioneer.get_version(),") 1864 | print(" cmdclass=versioneer.get_cmdclass(), ...)") 1865 | print("") 1866 | errors += 1 1867 | if setters: 1868 | print("You should remove lines like 'versioneer.VCS = ' and") 1869 | print("'versioneer.versionfile_source = ' . This configuration") 1870 | print("now lives in setup.cfg, and should be removed from setup.py") 1871 | print("") 1872 | errors += 1 1873 | return errors 1874 | 1875 | 1876 | if __name__ == "__main__": 1877 | cmd = sys.argv[1] 1878 | if cmd == "setup": 1879 | errors = do_setup() 1880 | errors += scan_setup_py() 1881 | if errors: 1882 | sys.exit(1) 1883 | --------------------------------------------------------------------------------