Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/check-same-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
cache: 'pip' # optional and only works for Python projects

- name: Run same-version
uses: willynilly/same-version@v5.1.0
uses: willynilly/same-version@v6.0.0
with:
fail_for_missing_file: false
check_github_event: true
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ keywords:
- metadata
- harmonization
license: Apache-2.0
version: "5.1.0"
version: "6.0.0"
date-released: "2025-06-10"
references:
- title: Citation File Format
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ This workflow runs after the tag or release exists and can report problems, but
- GitHub tag/release
- `CITATION.cff`
- `pyproject.toml` (Python)
- `setup.py` (Python)
- `setup.py` (Python, via static analysis only — no execution)
- `setup.cfg` (Python)
- Python files with `__version__` assignment
- Python files with `__version__` assignment (static parsing only — must be assigned directly to a string literal)
- `codemeta.json` (General)
- `.zenodo.json` (General)
- `package.json` (JS/TypeScript)
Expand Down Expand Up @@ -259,7 +259,7 @@ jobs:
python-version: ">=3.10"

- name: Run same-version
uses: willynilly/same-version@v5.1.0
uses: willynilly/same-version@v6.0.0
with:
fail_for_missing_file: false
check_github_event: true
Expand Down Expand Up @@ -309,7 +309,7 @@ jobs:
python-version: ">=3.10"

- name: Run same-version
uses: willynilly/same-version@v5.1.0
uses: willynilly/same-version@v6.0.0
with:
fail_for_missing_file: false
check_github_event: true
Expand Down Expand Up @@ -347,7 +347,7 @@ Add to your `.pre-commit-config.yaml`:
```yaml
repos:
- repo: https://github.com/willynilly/same-version
rev: v5.1.0 # Use latest tag
rev: v6.0.0 # Use latest tag
hooks:
- id: same-version
stages: [pre-commit, pre-push]
Expand Down
2 changes: 1 addition & 1 deletion example.pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/willynilly/same-version
rev: v5.1.0 # Use latest tag
rev: v6.0.0 # Use latest tag
hooks:
- id: same-version
stages: [pre-commit, pre-push]
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "same-version"
version = "5.1.0"
version = "6.0.0"
description = "Automatically ensures your software version metadata is consistent across key project files."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
3 changes: 2 additions & 1 deletion src/same_version/extractors/py_ast_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ class PyAstExtractor(FileExtractor):

def _get_data(self) -> dict:
data = {}
data['tree'] = None
if self.target_file_path and self.target_exists:
with open(self.target_file_path, 'r', encoding='utf-8') as f:
data['tree'] = ast.parse(f.read(), filename=self.target_file_path.resolve())
data['tree'] = ast.parse(f.read(), filename=str(self.target_file_path.resolve()))
return data
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class PyVersionAssignmentExtractor(PyAstExtractor):

def __init__(self, cli_args: Namespace):
target_cli_parameter_name: str = '--py-version-assignment-path'
default_target_name: str = "Python file with __version__ assignment"
default_target_name: str = "Python file with __version__ assignment to a literal string"
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def _get_version_from_data(self, data: dict) -> str | None:
version = None
graph: list | None = data.get('@graph', None)
if isinstance(graph, list) and graph is not None:
id = self.cli_args.get('ro_crate_metadata_json_id', None)
id = vars(self.cli_args).get('ro_crate_metadata_json_id', None)
if id is not None:
for resource in graph:
resource_id = resource.get('@id', None)
Expand Down
14 changes: 10 additions & 4 deletions src/same_version/extractors/setup_cfg_extractor.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import configparser
from argparse import Namespace

from same_version.extractors.ini_extractor import IniExtractor
Expand All @@ -15,13 +16,18 @@ def __init__(self, cli_args: Namespace):
)

def _get_version_from_data(self, data: dict) -> str | None:
config = data.get('config', None)
config: configparser.ConfigParser | None = data.get('config', None)
if config is None:
return None
metadata = config.get('metadata', None)
if metadata is None:

if not config.has_section('metadata'):
return None
version = config.get('version', None)

if not config.has_option('metadata', 'version'):
return None

version = config.get('metadata', 'version')

if isinstance(version, str):
version = version.strip()

Expand Down
81 changes: 61 additions & 20 deletions src/same_version/extractors/setup_py_extractor.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import ast
import logging
import subprocess
from argparse import Namespace

from same_version.extractors.file_extractor import FileExtractor
from same_version.extractors.py_ast_extractor import PyAstExtractor

logger = logging.getLogger(__name__)

class SetupPyExtractor(FileExtractor):
class SetupPyExtractor(PyAstExtractor):

def __init__(self, cli_args: Namespace):
target_cli_parameter_name: str = '--setup-py-path'
Expand All @@ -17,21 +17,62 @@ def __init__(self, cli_args: Namespace):
target_cli_parameter_name=target_cli_parameter_name
)

def _get_data(self) -> dict:
data = {}

if not self.target_exists:
data['version'] = None
else:
try:
output = subprocess.check_output(['python', str(self.target_file_path), '--version'], stderr=subprocess.STDOUT)
data['version'] = output.decode('utf-8').strip()
except subprocess.CalledProcessError as e:
logger.error(f"❌ Error running {self.target_name} --version:\n{e.output.decode('utf-8')}")
data['version'] = None

return data

def _get_version_from_data(self, data: dict) -> str | None:
version = data.get('version', None)
return version
tree = data.get('tree', None)
if tree is None:
return None

visitor = SetupPyVisitor()
visitor.visit(tree)

version: str | None = visitor.version
if not isinstance(version, str) and version is not None:
version = None

return version


class SetupPyVisitor(ast.NodeVisitor):
def __init__(self):
self.version = None
self.assignments = {}

def visit_Assign(self, node):
# Capture simple assignments: version = "1.2.3"
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
var_name = node.targets[0].id
value = self._get_constant_value(node.value)
if value is not None:
self.assignments[var_name] = value

def visit_Call(self, node):
# Look for any call to 'setup' function
if self._is_setup_call(node):
for kw in node.keywords:
if kw.arg == "version":
value = self._get_constant_value(kw.value)
if value is None and isinstance(kw.value, ast.Name):
# Handle: version=version_var
value = self.assignments.get(kw.value.id)
if value is not None:
self.version = value

def _get_constant_value(self, node):
if isinstance(node, ast.Constant): # Python 3.8+
if isinstance(node.value, str):
return node.value
elif isinstance(node, ast.Str): # Python <3.8
return node.s
return None

def _is_setup_call(self, node):
# Accept setup() or setuptools.setup()
if isinstance(node.func, ast.Name):
return node.func.id == "setup"
if isinstance(node.func, ast.Attribute):
return (
node.func.attr == "setup" and
isinstance(node.func.value, ast.Name) and
node.func.value.id == "setuptools"
)
return False
2 changes: 1 addition & 1 deletion src/same_version/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
logger = logging.getLogger(__name__)

def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Check metadata files for consistent software versions using canonical PEP 440 and SemVer")
parser = argparse.ArgumentParser(description="Check whether your software version metadata is consistent across key project files.")
parser.add_argument('--base-version', required=False, help='A base version from which to check')
parser.add_argument('--fail-for-missing-file', default=False, required=False, help='Fail for any checked file that is missing')
parser.add_argument('--check-citation-cff', default=True, required=False, help='Check CITATION.cff? (true/false)')
Expand Down
70 changes: 69 additions & 1 deletion tests/extractors/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,72 @@ def cli_args_with_base_version():
@pytest.fixture
def cli_args_with_valid_package_json_path():
package_json_path: Path = Path(__file__).resolve().parent.parent / 'test_data' / 'package.json'
return Namespace(package_json_path=package_json_path)
return Namespace(package_json_path=package_json_path)

@pytest.fixture
def cli_args_with_valid_pom_xml_path():
pom_xml_path: Path = Path(__file__).resolve().parent.parent / 'test_data' / 'pom.xml'
return Namespace(pom_xml_path=pom_xml_path)

@pytest.fixture
def cli_args_with_valid_pyproject_toml_path():
pyproject_toml_path: Path = Path(__file__).resolve().parent.parent / 'test_data' / 'pyproject.toml'
return Namespace(pyproject_toml_path=pyproject_toml_path)

@pytest.fixture
def cli_args_with_valid_cargo_toml_path():
cargo_toml_path: Path = Path(__file__).resolve().parent.parent / 'test_data' / 'Cargo.toml'
return Namespace(cargo_toml_path=cargo_toml_path)

@pytest.fixture
def cli_args_with_valid_composer_json_path():
composer_json_path: Path = Path(__file__).resolve().parent.parent / 'test_data' / 'composer.json'
return Namespace(composer_json_path=composer_json_path)

@pytest.fixture
def cli_args_with_valid_codemeta_json_path():
codemeta_json_path: Path = Path(__file__).resolve().parent.parent / 'test_data' / 'codemeta.json'
return Namespace(codemeta_json_path=codemeta_json_path)

@pytest.fixture
def cli_args_with_valid_ro_crate_metadata_json_path_and_id():
ro_crate_metadata_json_path: Path = Path(__file__).resolve().parent.parent / 'test_data' / 'ro-crate-metadata.json'
ro_crate_metadata_json_id: str = 'software/'
return Namespace(ro_crate_metadata_json_path=ro_crate_metadata_json_path, ro_crate_metadata_json_id=ro_crate_metadata_json_id)

@pytest.fixture
def cli_args_with_valid_zenodo_json_path():
zenodo_json_path: Path = Path(__file__).resolve().parent.parent / 'test_data' / '.zenodo.json'
return Namespace(zenodo_json_path=zenodo_json_path)

@pytest.fixture
def cli_args_with_valid_nuspec_path():
nuspec_path: Path = Path(__file__).resolve().parent.parent / 'test_data' / '.nuspec'
return Namespace(nuspec_path=nuspec_path)

@pytest.fixture
def cli_args_with_valid_r_description_path():
r_description_path: Path = Path(__file__).resolve().parent.parent / 'test_data' / 'DESCRIPTION'
return Namespace(r_description_path=r_description_path)

@pytest.fixture
def cli_args_with_valid_setup_cfg_path():
setup_cfg_path: Path = Path(__file__).resolve().parent.parent / 'test_data' / 'setup.cfg'
return Namespace(setup_cfg_path=setup_cfg_path)

@pytest.fixture
def cli_args_with_valid_setup_py_path():
setup_py_path: Path = Path(__file__).resolve().parent.parent / 'test_data' / 'setup.py'
return Namespace(setup_py_path=setup_py_path)

@pytest.fixture
def cli_args_with_valid_citation_cff_path():
citation_cff_path: Path = Path(__file__).resolve().parent.parent / 'test_data' / 'CITATION.cff'
return Namespace(citation_cff_path=citation_cff_path)

@pytest.fixture
def cli_args_with_valid_py_version_assignment_path():
py_version_assignment_path: Path = Path(__file__).resolve().parent.parent / 'test_data' / 'main_with_literal_version_assignment.py'
return Namespace(py_version_assignment_path=py_version_assignment_path)


37 changes: 37 additions & 0 deletions tests/extractors/test_cargo_toml_extractor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from argparse import Namespace
from pathlib import Path

from same_version.extractors.cargo_toml_extractor import CargoTomlExtractor


def test_cargo_toml_extractor_extract_version_with_empty_cli_args(empty_cli_args: Namespace):
extractor:CargoTomlExtractor = CargoTomlExtractor(cli_args=empty_cli_args)
assert extractor.extract_version() is None

def test_cargo_toml_extractor_target_parameter_with_empty_cli_args(empty_cli_args: Namespace):
extractor:CargoTomlExtractor = CargoTomlExtractor(cli_args=empty_cli_args)
assert extractor.target_name == 'Cargo.toml'

def test_cargo_toml_extractor_target_exists_with_empty_cli_args(empty_cli_args: Namespace):
extractor:CargoTomlExtractor = CargoTomlExtractor(cli_args=empty_cli_args)
assert extractor.target_exists is False

def test_cargo_toml_extractor_target_cli_parameter_name_with_empty_cli_args(empty_cli_args: Namespace):
extractor:CargoTomlExtractor = CargoTomlExtractor(cli_args=empty_cli_args)
assert extractor.target_cli_parameter_name == '--cargo-toml-path'

def test_cargo_toml_extractor_extract_version_with_cli_args_with_valid_cargo_toml_path(cli_args_with_valid_cargo_toml_path: Namespace):
extractor:CargoTomlExtractor = CargoTomlExtractor(cli_args=cli_args_with_valid_cargo_toml_path)
assert extractor.extract_version() == '10.0.8'

def test_cargo_toml_extractor_target_parameter_with_cli_args_with_valid_cargo_toml_path(cli_args_with_valid_cargo_toml_path: Namespace):
extractor:CargoTomlExtractor = CargoTomlExtractor(cli_args=cli_args_with_valid_cargo_toml_path)
assert extractor.target_name is not None and Path(extractor.target_name).name == 'Cargo.toml'

def test_cargo_toml_extractor_target_exists_with_cli_args_with_valid_cargo_toml_path(cli_args_with_valid_cargo_toml_path: Namespace):
extractor:CargoTomlExtractor = CargoTomlExtractor(cli_args=cli_args_with_valid_cargo_toml_path)
assert extractor.target_exists is True

def test_cargo_toml_extractor_target_cli_parameter_name_with_cli_args_with_valid_cargo_toml_path(cli_args_with_valid_cargo_toml_path: Namespace):
extractor:CargoTomlExtractor = CargoTomlExtractor(cli_args=cli_args_with_valid_cargo_toml_path)
assert extractor.target_cli_parameter_name == '--cargo-toml-path'
37 changes: 37 additions & 0 deletions tests/extractors/test_citation_cff_extractor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from argparse import Namespace
from pathlib import Path

from same_version.extractors.citation_cff_extractor import CitationCffExtractor


def test_citation_cff_extractor_extract_version_with_empty_cli_args(empty_cli_args: Namespace):
extractor:CitationCffExtractor = CitationCffExtractor(cli_args=empty_cli_args)
assert extractor.extract_version() is None

def test_citation_cff_extractor_target_parameter_with_empty_cli_args(empty_cli_args: Namespace):
extractor:CitationCffExtractor = CitationCffExtractor(cli_args=empty_cli_args)
assert extractor.target_name == 'CITATION.cff'

def test_citation_cff_extractor_target_exists_with_empty_cli_args(empty_cli_args: Namespace):
extractor:CitationCffExtractor = CitationCffExtractor(cli_args=empty_cli_args)
assert extractor.target_exists is False

def test_citation_cff_extractor_target_cli_parameter_name_with_empty_cli_args(empty_cli_args: Namespace):
extractor:CitationCffExtractor = CitationCffExtractor(cli_args=empty_cli_args)
assert extractor.target_cli_parameter_name == '--citation-cff-path'

def test_citation_cff_extractor_extract_version_with_cli_args_with_valid_citation_cff_path(cli_args_with_valid_citation_cff_path: Namespace):
extractor:CitationCffExtractor = CitationCffExtractor(cli_args=cli_args_with_valid_citation_cff_path)
assert extractor.extract_version() == '0.0.8000'

def test_citation_cff_extractor_target_parameter_with_cli_args_with_valid_citation_cff_path(cli_args_with_valid_citation_cff_path: Namespace):
extractor:CitationCffExtractor = CitationCffExtractor(cli_args=cli_args_with_valid_citation_cff_path)
assert extractor.target_name is not None and Path(extractor.target_name).name == 'CITATION.cff'

def test_citation_cff_extractor_target_exists_with_cli_args_with_valid_citation_cff_path(cli_args_with_valid_citation_cff_path: Namespace):
extractor:CitationCffExtractor = CitationCffExtractor(cli_args=cli_args_with_valid_citation_cff_path)
assert extractor.target_exists is True

def test_citation_cff_extractor_target_cli_parameter_name_with_cli_args_with_valid_citation_cff_path(cli_args_with_valid_citation_cff_path: Namespace):
extractor:CitationCffExtractor = CitationCffExtractor(cli_args=cli_args_with_valid_citation_cff_path)
assert extractor.target_cli_parameter_name == '--citation-cff-path'
Loading
Loading