diff --git a/.github/workflows/check-same-version.yml b/.github/workflows/check-same-version.yml index 37e198d..385a82d 100644 --- a/.github/workflows/check-same-version.yml +++ b/.github/workflows/check-same-version.yml @@ -22,11 +22,11 @@ jobs: - name: Install Python uses: actions/setup-python@v5 with: - python-version: ">=3.12" # required by same-version - cache: 'pip' # optional + python-version: ">=3.10" # required by same-version + cache: 'pip' # optional and only works for Python projects - name: Run same-version - uses: willynilly/same-version@v4.0.0 + uses: willynilly/same-version@v5.0.0 with: fail_for_missing_file: false check_github_event: true @@ -46,6 +46,7 @@ jobs: check_r_description: false check_composer_json: false check_pom_xml: false + check_nuspec: false check_cargo_toml: false check_ro_crate_metadata_json: false check_py_version_assignment: false diff --git a/CITATION.cff b/CITATION.cff index 460f7ca..e2e183f 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -34,7 +34,7 @@ keywords: - metadata - harmonization license: Apache-2.0 -version: "4.0.0" +version: "5.0.0" date-released: "2025-06-10" references: - title: Citation File Format diff --git a/README.md b/README.md index e92e12b..e8f20df 100644 --- a/README.md +++ b/README.md @@ -64,11 +64,12 @@ This workflow runs after the tag or release exists and can report problems, but - `composer.json` (PHP) - `Cargo.toml` (Rust) - `pom.xml` (Java) +- `.nuspec` (.NET/C#/NuGet) - `DESCRIPTION` (R) - `ro-crate-metadata.json` (RO-Crate) -✅ Cross-language support (e.g., Python, R, JS/TypeScript, Java, Rust, PHP) +✅ Cross-language support (e.g., Python, R, JS/TypeScript, Java, Rust, PHP, C#) ✅ Cross-standard support for FAIR and Open Science metadata (e.g., CFF, CodeMeta, RO-Crate, Zenodo) @@ -92,22 +93,24 @@ This workflow runs after the tag or release exists and can report problems, but These files are currently supported out-of-the-box: -| File | Parser used | -|------------------|-------------| +| File | Initial Version Format (translates to PEP 440 for comparison) | +|------------------|-------------------| | `CITATION.cff` | PEP 440 | | `pyproject.toml` | PEP 440 | | `setup.py` | PEP 440 | -| `package.json` | Strict SemVer (converted from canonical PEP 440 tag) | +| `package.json` | Strict SemVer | | `codemeta.json` | PEP 440 | | `.zenodo.json` | PEP 440 | | `composer.json` | PEP 440 | | `Cargo.toml` | PEP 440 | | `pom.xml` | PEP 440 | +| `.nuspec` | Strict SemVer | | R `DESCRIPTION` file | PEP 440 | | Python file with `__version__` assignment | PEP 440 | | `ro-crate-metadata.json` | PEP 440 | - +Note SemVer allows arbitrary pre-releases while PEP 440 only allows 3 kinds (a, b, rc). +During conversion, --- @@ -146,6 +149,8 @@ These files are currently supported out-of-the-box: | `--r-description-path` | `r_description_path` | Path to R `DESCRIPTION` file | No | `DESCRIPTION` | | `--check-pom-xml` | `check_pom_xml` | Check `pom.xml`? (`true/false`) | No | `true` | | `--pom-xml-path` | `pom_xml_path` | Path to `pom.xml` | No | `pom.xml` | +| `--check-nuspec` | `check_nu_spec` | Check `.nuspec`? (`true/false`) | No | `true` | +| `--nuspec-path` | `nuspec_path` | Path to `.nuspec` | No | `.nuspec` | @@ -200,11 +205,10 @@ jobs: - name: Install Python uses: actions/setup-python@v5 with: - python-version: ">=3.12" - cache: 'pip' + python-version: ">=3.10" - name: Run same-version - uses: willynilly/same-version@v4.0.0 + uses: willynilly/same-version@v5.0.0 with: fail_for_missing_file: false check_github_event: true @@ -220,6 +224,7 @@ jobs: check_cargo_toml: false check_py_version_assignment: false check_pom_xml: false + check_nuspec: false check_composer_json: false check_ro_crate_metadata_json: false ``` @@ -249,11 +254,10 @@ jobs: - name: Install Python uses: actions/setup-python@v5 with: - python-version: ">=3.12" - cache: 'pip' + python-version: ">=3.10" - name: Run same-version - uses: willynilly/same-version@v4.0.0 + uses: willynilly/same-version@v5.0.0 with: fail_for_missing_file: false check_github_event: true @@ -269,6 +273,7 @@ jobs: check_cargo_toml: false check_py_version_assignment: false check_pom_xml: false + check_nuspec: false check_composer_json: false check_ro_crate_metadata_json: false ``` @@ -289,7 +294,7 @@ Add to your `.pre-commit-config.yaml`: ```yaml repos: - repo: https://github.com/willynilly/same-version - rev: v4.0.0 # Use latest tag + rev: v5.0.0 # Use latest tag hooks: - id: same-version stages: [pre-commit, pre-push] @@ -357,7 +362,7 @@ To set up your development environment: ```bash git clone https://github.com/willynilly/same-version.git cd same-version -pip install -e . +pip install -e .[testing,dev] pre-commit install -t pre-commit -t pre-push pre-commit run --all-files ``` diff --git a/example.pre-commit-config.yaml b/example.pre-commit-config.yaml index 59ab8e6..aeacc5c 100644 --- a/example.pre-commit-config.yaml +++ b/example.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/willynilly/same-version - rev: v4.0.0 # Use latest tag + rev: v5.0.0 # Use latest tag hooks: - id: same-version stages: [pre-commit, pre-push] diff --git a/pyproject.toml b/pyproject.toml index 7a5590e..38ea286 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,11 @@ build-backend = "hatchling.build" [project] name = "same-version" -version = "4.0.0" +version = "5.0.0" description = "Automatically ensures your software version metadata is consistent across key project files." readme = "README.md" -requires-python = ">=3.12" +requires-python = ">=3.10" license = "Apache-2.0" -license-files = ["LICEN[CS]E*"] keywords = ["open science", "FAIR", "version", "software quality", "DevOps", "CI/CD", "GitHub Action", "pyproject.toml", "package.json", "setup.py", "codemeta.json", ".zenodo.json", "Zenodo", "CodeMeta", "CITATION.cff", "CFF", "citation", "metadata", "harmonization"] authors = [ { name = "Will Riley", email = "wanderingwill@gmail.com" }, @@ -31,7 +30,7 @@ classifiers = [ "Topic :: Software Development", "Topic :: Utilities" ] -dependencies = ["pyyaml>=6.0.2", "tomli>=2.2.1", "packaging>=25.0", "semver>=3.0.4"] +dependencies = ["pyyaml>=6.0.2", "tomli>=2.2.1", "verple>=1.0.0"] [project.urls] Homepage = "https://github.com/willynilly/same-version" @@ -45,6 +44,7 @@ testing = [ ] dev = [ "ruff>=0.11.12", + "pre-commit>=4.2.0" ] [tool.pytest.ini_options] @@ -54,6 +54,7 @@ pythonpath = [ [tool.hatch.build] include = ["src/same_version/**", "CITATION.cff"] +license-files = ["LICEN[CS]E*"] [tool.hatch.build.targets.wheel] packages = ["src/same_version"] diff --git a/src/same_version/checkers/checker.py b/src/same_version/checkers/checker.py index 2467520..cd5938e 100644 --- a/src/same_version/checkers/checker.py +++ b/src/same_version/checkers/checker.py @@ -1,10 +1,9 @@ import logging from argparse import Namespace -import packaging.version +from verple.verple import Verple from same_version.extractors.extractor import Extractor -from same_version.utils import parse_version_pep440 logger = logging.getLogger(__name__) @@ -14,18 +13,23 @@ def __init__(self, extractor: Extractor, cli_args: Namespace): self.extractor = extractor self.cli_args = cli_args self._extracted_version: str | None = self.extractor.extract_version() - - def create_pep440_version(self, version_str: str | None) -> packaging.version.Version | None: - version_pep440 : packaging.version.Version | None = parse_version_pep440(version_str) - return version_pep440 + self._verple_version: Verple | None = self.create_verple_version(self._extracted_version) + + def create_verple_version(self, version_str: str | None) -> Verple | None: + if version_str is None: + return None + try: + return Verple.parse(version_str) + except ValueError: + return None @property def target_version_str(self) -> str | None: return self._extracted_version @property - def target_version_pep440(self) -> packaging.version.Version | None: - return self.create_pep440_version(version_str=self.target_version_str) + def target_version(self) -> Verple | None: + return self._verple_version @property def target_exists(self) -> bool: @@ -40,12 +44,11 @@ def target_cli_parameter_name(self) -> str | None: return self.extractor.target_cli_parameter_name def check(self, base_version_str: str | None) -> bool: - if not base_version_str: + if base_version_str is None: return True - base_version_pep440 = self.create_pep440_version(version_str=base_version_str) + base_version: Verple | None = self.create_verple_version(version_str=base_version_str) if self.target_exists: - - if base_version_pep440 != self.target_version_pep440: + if base_version != self.target_version: self._log_version_mismatch(base_version_str=base_version_str) return False else: diff --git a/src/same_version/checkers/nuspec_checker.py b/src/same_version/checkers/nuspec_checker.py new file mode 100644 index 0000000..6e50b12 --- /dev/null +++ b/src/same_version/checkers/nuspec_checker.py @@ -0,0 +1,13 @@ +import logging +from argparse import Namespace + +from same_version.checkers.file_checker import FileChecker +from same_version.extractors.nuspec_extractor import NuspecExtractor + +logger = logging.getLogger(__name__) + + +class NuspecChecker(FileChecker): + + def __init__(self, extractor: NuspecExtractor, cli_args: Namespace): + super().__init__(extractor=extractor, cli_args=cli_args) \ No newline at end of file diff --git a/src/same_version/checkers/package_json_checker.py b/src/same_version/checkers/package_json_checker.py index 7d1c156..2366827 100644 --- a/src/same_version/checkers/package_json_checker.py +++ b/src/same_version/checkers/package_json_checker.py @@ -1,40 +1,12 @@ import logging +from argparse import Namespace from same_version.checkers.file_checker import FileChecker -from same_version.utils import parse_version_semver +from same_version.extractors.package_json_extractor import PackageJsonExtractor logger = logging.getLogger(__name__) class PackageJsonChecker(FileChecker): - - def check(self, base_version_str: str | None) -> bool: - if base_version_str is None: - return True - base_version_pep440 = self.create_pep440_version(version_str=base_version_str) - if base_version_pep440 is None: - return True - - if self.target_exists: - target_version_str = self.target_version_str - target_name = self.target_name - - try: - base_version_semver_str: str = f"{base_version_pep440.major}.{base_version_pep440.minor}.{base_version_pep440.micro}" - if base_version_pep440.is_prerelease and base_version_pep440.pre is not None: - base_version_semver_str += f"-{base_version_pep440.pre[0]}.{base_version_pep440.pre[1]}" - - base_version_semver = parse_version_semver(base_version_semver_str) - target_version_semver = parse_version_semver(target_version_str) - - if base_version_semver != target_version_semver: - logger.error(f"❌ Version mismatch: {target_name} {target_version_str} != base version {base_version_str}") - return False - else: - return True - - except Exception as e: - logger.error(f"❌ Parse error: Could not parse tag version as SemVer for {self.target_name}: {e}") - return False - else: - self._log_missing_target() - return False + + def __init__(self, extractor: PackageJsonExtractor, cli_args: Namespace): + super().__init__(extractor=extractor, cli_args=cli_args) diff --git a/src/same_version/extractors/extractor.py b/src/same_version/extractors/extractor.py index 4a22c47..20ca8a0 100644 --- a/src/same_version/extractors/extractor.py +++ b/src/same_version/extractors/extractor.py @@ -7,6 +7,9 @@ class Extractor: def __init__(self): pass + def _should_have_version_in_data(self, data: dict) -> bool: + return True + def _get_data(self) -> dict: raise NotImplementedError() @@ -15,6 +18,9 @@ def _get_version_from_data(self, data: dict) -> str | None: def _log_missing_version_error(self, data: dict): logger.error(f"❌ {self.target_name} missing version metadata") + + def _log_should_not_have_version_error(self, version: str, data: dict): + logger.error(f"❌ {self.target_name} should not have version metadata, but has version: {version}") def _log_found_version_info(self, version: str, data: dict): logger.info(f"📖 {self.target_name} version: {version}") @@ -23,12 +29,15 @@ def extract_version(self) -> str | None: data: dict = self._get_data() version = self._get_version_from_data(data=data) if version is None: - if self.target_exists: + if self.target_exists and self._should_have_version_in_data(data=data): # only print this error if the target exists self._log_missing_version_error(data=data) return None - self._log_found_version_info(version=version, data=data) - return version + elif not self._should_have_version_in_data(data=data): + self._log_should_not_have_version_error(version=version, data=data) + else: + self._log_found_version_info(version=version, data=data) + return version @property def target_exists(self) -> bool: diff --git a/src/same_version/extractors/github_event_extractor.py b/src/same_version/extractors/github_event_extractor.py index 8b95f56..c8d6ca9 100644 --- a/src/same_version/extractors/github_event_extractor.py +++ b/src/same_version/extractors/github_event_extractor.py @@ -28,6 +28,13 @@ def target_name(self) -> str | None: def target_cli_parameter_name(self) -> str | None: return '--github-event-name' + def _should_have_version_in_data(self, data: dict): + # only the push and release have version metadata + # the pull request does not and should not. + event_name: str | None = data.get('github_event_name', None) + return event_name == 'push' or event_name == 'release' + + def _get_data(self) -> dict[str, str | None]: return self._data @@ -55,7 +62,7 @@ def _get_version_from_data(self, data: dict) -> str | None: logger.info(f"📦 Detected GitHub release tag version: {version}") return version elif event_name == 'pull_request': - logger.info("📦 Detected GitHub pull request") + logger.info("📦 Detected GitHub pull request. No version expected.") return None else: logger.error(f"❌ Unsupported GitHub event: {event_name}") diff --git a/src/same_version/extractors/nuspec_extractor.py b/src/same_version/extractors/nuspec_extractor.py new file mode 100644 index 0000000..bce7c56 --- /dev/null +++ b/src/same_version/extractors/nuspec_extractor.py @@ -0,0 +1,38 @@ +import xml.etree.ElementTree as ET +from argparse import Namespace + +from same_version.extractors.xml_extractor import XmlExtractor + + +class NuspecExtractor(XmlExtractor): + + def __init__(self, cli_args: Namespace): + target_cli_parameter_name: str = '--nuspec-path' + default_target_name: str = ".nuspec" + super().__init__( + target_file_path=self._create_target_file_path_from_cli_arg(cli_args=cli_args, cli_arg_parameter=target_cli_parameter_name), + default_target_name=default_target_name, + target_cli_parameter_name=target_cli_parameter_name + ) + + def _get_version_from_data(self, data: dict) -> str | None: + version: str | None = None + tree: ET.ElementTree[ET.Element[str]] | None = data.get('tree', None) + if tree is not None: + + try: + root = tree.getroot() + + # XML structure: ... + # The {*} is used for optional namespaces that some tools prepend + metadata = root.find('metadata') or root.find('{*}metadata') + + if metadata is not None: + version_elem = metadata.find('version') or metadata.find('{*}version') + if version_elem is not None and version_elem.text: + version = version_elem.text.strip() + + except Exception: + version = None + + return version diff --git a/src/same_version/main.py b/src/same_version/main.py index f77baeb..fd49148 100644 --- a/src/same_version/main.py +++ b/src/same_version/main.py @@ -10,6 +10,7 @@ from same_version.checkers.codemeta_json_checker import CodeMetaJsonChecker from same_version.checkers.composer_json_checker import ComposerJsonChecker from same_version.checkers.github_event_checker import GitHubEventChecker +from same_version.checkers.nuspec_checker import NuspecChecker from same_version.checkers.package_json_checker import PackageJsonChecker from same_version.checkers.pom_xml_checker import PomXmlChecker from same_version.checkers.py_version_assignment_checker import ( @@ -28,6 +29,7 @@ from same_version.extractors.codemeta_json_extractor import CodeMetaJsonExtractor from same_version.extractors.composer_json_extractor import ComposerJsonExtractor from same_version.extractors.github_event_extractor import GitHubEventExtractor +from same_version.extractors.nuspec_extractor import NuspecExtractor from same_version.extractors.package_json_extractor import PackageJsonExtractor from same_version.extractors.pom_xml_extractor import PomXmlExtractor from same_version.extractors.py_version_assignment_extractor import ( @@ -72,6 +74,9 @@ def parse_args() -> argparse.Namespace: parser.add_argument('--check-pom-xml', default=True, required=False, help='Check pom.xml? (true/false)') parser.add_argument('--pom-xml-path', default='pom.xml', required=False, help='Path to pom.xml') + parser.add_argument('--check-nuspec', default=True, required=False, help='Check .nuspec? (true/false)') + parser.add_argument('--nuspec-path', default='.nuspec', required=False, help='Path to .nuspec') + parser.add_argument('--check-cargo-toml', default=True, required=False, help='Check Cargo.toml? (true/false)') parser.add_argument('--cargo-toml-path', default='Cargo.toml', required=False, help='Path to Cargo.toml') @@ -156,6 +161,12 @@ def main(): pom_xml_checker: PomXmlChecker = PomXmlChecker(extractor=pom_xml_extractor, cli_args=cli_args) checkers.append(pom_xml_checker) + # .nuspec + if str(getattr(cli_args, 'check_nuspec', '') or '').lower() == 'true': + nuspec_extractor: NuspecExtractor = NuspecExtractor(cli_args=cli_args) + nuspec_checker: NuspecChecker = NuspecChecker(extractor=nuspec_extractor, cli_args=cli_args) + checkers.append(nuspec_checker) + # R DESCRIPTION file if str(getattr(cli_args, 'check_r_description', '') or '').lower() == 'true': r_description_extractor: RDescriptionExtractor = RDescriptionExtractor(cli_args=cli_args) diff --git a/src/same_version/utils.py b/src/same_version/utils.py deleted file mode 100644 index 0e33d17..0000000 --- a/src/same_version/utils.py +++ /dev/null @@ -1,14 +0,0 @@ - -import packaging.version -import semver - - -def parse_version_pep440(v: str | None) -> packaging.version.Version | None: - if v is None: - return None - return packaging.version.Version(v) - -def parse_version_semver(v: str | None) -> semver.Version | None: - if v is None: - return None - return semver.Version.parse(v) \ No newline at end of file