Skip to content

Commit 06dbd29

Browse files
authored
Merge pull request #17 from willynilly/develop
Added tests. Fixed extractors for ro-crate-metadata.json, setup.cfg, …
2 parents 2c8d06b + 08d6394 commit 06dbd29

38 files changed

Lines changed: 889 additions & 38 deletions

.github/workflows/check-same-version.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
cache: 'pip' # optional and only works for Python projects
2727

2828
- name: Run same-version
29-
uses: willynilly/same-version@v5.1.0
29+
uses: willynilly/same-version@v6.0.0
3030
with:
3131
fail_for_missing_file: false
3232
check_github_event: true

CITATION.cff

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ keywords:
3434
- metadata
3535
- harmonization
3636
license: Apache-2.0
37-
version: "5.1.0"
37+
version: "6.0.0"
3838
date-released: "2025-06-10"
3939
references:
4040
- title: Citation File Format

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ This workflow runs after the tag or release exists and can report problems, but
5656
- GitHub tag/release
5757
- `CITATION.cff`
5858
- `pyproject.toml` (Python)
59-
- `setup.py` (Python)
59+
- `setup.py` (Python, via static analysis only — no execution)
6060
- `setup.cfg` (Python)
61-
- Python files with `__version__` assignment
61+
- Python files with `__version__` assignment (static parsing only — must be assigned directly to a string literal)
6262
- `codemeta.json` (General)
6363
- `.zenodo.json` (General)
6464
- `package.json` (JS/TypeScript)
@@ -259,7 +259,7 @@ jobs:
259259
python-version: ">=3.10"
260260

261261
- name: Run same-version
262-
uses: willynilly/same-version@v5.1.0
262+
uses: willynilly/same-version@v6.0.0
263263
with:
264264
fail_for_missing_file: false
265265
check_github_event: true
@@ -309,7 +309,7 @@ jobs:
309309
python-version: ">=3.10"
310310

311311
- name: Run same-version
312-
uses: willynilly/same-version@v5.1.0
312+
uses: willynilly/same-version@v6.0.0
313313
with:
314314
fail_for_missing_file: false
315315
check_github_event: true
@@ -347,7 +347,7 @@ Add to your `.pre-commit-config.yaml`:
347347
```yaml
348348
repos:
349349
- repo: https://github.com/willynilly/same-version
350-
rev: v5.1.0 # Use latest tag
350+
rev: v6.0.0 # Use latest tag
351351
hooks:
352352
- id: same-version
353353
stages: [pre-commit, pre-push]

example.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/willynilly/same-version
3-
rev: v5.1.0 # Use latest tag
3+
rev: v6.0.0 # Use latest tag
44
hooks:
55
- id: same-version
66
stages: [pre-commit, pre-push]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "same-version"
7-
version = "5.1.0"
7+
version = "6.0.0"
88
description = "Automatically ensures your software version metadata is consistent across key project files."
99
readme = "README.md"
1010
requires-python = ">=3.10"

src/same_version/extractors/py_ast_extractor.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ class PyAstExtractor(FileExtractor):
77

88
def _get_data(self) -> dict:
99
data = {}
10+
data['tree'] = None
1011
if self.target_file_path and self.target_exists:
1112
with open(self.target_file_path, 'r', encoding='utf-8') as f:
12-
data['tree'] = ast.parse(f.read(), filename=self.target_file_path.resolve())
13+
data['tree'] = ast.parse(f.read(), filename=str(self.target_file_path.resolve()))
1314
return data

src/same_version/extractors/py_version_assignment_extractor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ class PyVersionAssignmentExtractor(PyAstExtractor):
1010

1111
def __init__(self, cli_args: Namespace):
1212
target_cli_parameter_name: str = '--py-version-assignment-path'
13-
default_target_name: str = "Python file with __version__ assignment"
13+
default_target_name: str = "Python file with __version__ assignment to a literal string"
1414
super().__init__(
1515
target_file_path=self._create_target_file_path_from_cli_arg(cli_args=cli_args, cli_arg_parameter=target_cli_parameter_name),
1616
default_target_name=default_target_name,

src/same_version/extractors/ro_crate_metadata_json_extractor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def _get_version_from_data(self, data: dict) -> str | None:
1919
version = None
2020
graph: list | None = data.get('@graph', None)
2121
if isinstance(graph, list) and graph is not None:
22-
id = self.cli_args.get('ro_crate_metadata_json_id', None)
22+
id = vars(self.cli_args).get('ro_crate_metadata_json_id', None)
2323
if id is not None:
2424
for resource in graph:
2525
resource_id = resource.get('@id', None)

src/same_version/extractors/setup_cfg_extractor.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import configparser
12
from argparse import Namespace
23

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

1718
def _get_version_from_data(self, data: dict) -> str | None:
18-
config = data.get('config', None)
19+
config: configparser.ConfigParser | None = data.get('config', None)
1920
if config is None:
2021
return None
21-
metadata = config.get('metadata', None)
22-
if metadata is None:
22+
23+
if not config.has_section('metadata'):
2324
return None
24-
version = config.get('version', None)
25+
26+
if not config.has_option('metadata', 'version'):
27+
return None
28+
29+
version = config.get('metadata', 'version')
30+
2531
if isinstance(version, str):
2632
version = version.strip()
2733

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1+
import ast
12
import logging
2-
import subprocess
33
from argparse import Namespace
44

5-
from same_version.extractors.file_extractor import FileExtractor
5+
from same_version.extractors.py_ast_extractor import PyAstExtractor
66

77
logger = logging.getLogger(__name__)
88

9-
class SetupPyExtractor(FileExtractor):
9+
class SetupPyExtractor(PyAstExtractor):
1010

1111
def __init__(self, cli_args: Namespace):
1212
target_cli_parameter_name: str = '--setup-py-path'
@@ -17,21 +17,62 @@ def __init__(self, cli_args: Namespace):
1717
target_cli_parameter_name=target_cli_parameter_name
1818
)
1919

20-
def _get_data(self) -> dict:
21-
data = {}
22-
23-
if not self.target_exists:
24-
data['version'] = None
25-
else:
26-
try:
27-
output = subprocess.check_output(['python', str(self.target_file_path), '--version'], stderr=subprocess.STDOUT)
28-
data['version'] = output.decode('utf-8').strip()
29-
except subprocess.CalledProcessError as e:
30-
logger.error(f"❌ Error running {self.target_name} --version:\n{e.output.decode('utf-8')}")
31-
data['version'] = None
32-
33-
return data
34-
3520
def _get_version_from_data(self, data: dict) -> str | None:
36-
version = data.get('version', None)
37-
return version
21+
tree = data.get('tree', None)
22+
if tree is None:
23+
return None
24+
25+
visitor = SetupPyVisitor()
26+
visitor.visit(tree)
27+
28+
version: str | None = visitor.version
29+
if not isinstance(version, str) and version is not None:
30+
version = None
31+
32+
return version
33+
34+
35+
class SetupPyVisitor(ast.NodeVisitor):
36+
def __init__(self):
37+
self.version = None
38+
self.assignments = {}
39+
40+
def visit_Assign(self, node):
41+
# Capture simple assignments: version = "1.2.3"
42+
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
43+
var_name = node.targets[0].id
44+
value = self._get_constant_value(node.value)
45+
if value is not None:
46+
self.assignments[var_name] = value
47+
48+
def visit_Call(self, node):
49+
# Look for any call to 'setup' function
50+
if self._is_setup_call(node):
51+
for kw in node.keywords:
52+
if kw.arg == "version":
53+
value = self._get_constant_value(kw.value)
54+
if value is None and isinstance(kw.value, ast.Name):
55+
# Handle: version=version_var
56+
value = self.assignments.get(kw.value.id)
57+
if value is not None:
58+
self.version = value
59+
60+
def _get_constant_value(self, node):
61+
if isinstance(node, ast.Constant): # Python 3.8+
62+
if isinstance(node.value, str):
63+
return node.value
64+
elif isinstance(node, ast.Str): # Python <3.8
65+
return node.s
66+
return None
67+
68+
def _is_setup_call(self, node):
69+
# Accept setup() or setuptools.setup()
70+
if isinstance(node.func, ast.Name):
71+
return node.func.id == "setup"
72+
if isinstance(node.func, ast.Attribute):
73+
return (
74+
node.func.attr == "setup" and
75+
isinstance(node.func.value, ast.Name) and
76+
node.func.value.id == "setuptools"
77+
)
78+
return False

0 commit comments

Comments
 (0)