updateCitation 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tests/conftest.py ADDED
@@ -0,0 +1,96 @@
1
+ """SSOT for Pytest."""
2
+ from typing import Generator, Any, Type, Callable
3
+ import pathlib
4
+ from updateCitation import (
5
+ add_pyprojectDOTtoml,
6
+ addCitation,
7
+ addGitHubRelease,
8
+ addPyPAMetadata,
9
+ addPyPIrelease,
10
+ CitationNexus,
11
+ flowControl,
12
+ getSettingsPackage,
13
+ SettingsPackage,
14
+ )
15
+ from updateCitation.github import getGitHubRelease
16
+ from updateCitation.pypa import getPyPAMetadata
17
+ from updateCitation.pypi import getPyPIrelease
18
+ from updateCitation.pyprojectDOTtoml import get_pyprojectDOTtoml
19
+ from updateCitation.variables import cffDASHversionDEFAULT, CitationNexusFieldsRequired, messageDEFAULT
20
+ import pytest
21
+ import shutil
22
+ import uuid
23
+
24
+ # SSOT for test data paths and filenames
25
+ pathDataSamples = pathlib.Path("tests/dataSamples")
26
+ pathFilenameCitationAlphaDOTcff = pathDataSamples / "citationAlpha.cff"
27
+ pathTmpRoot = pathDataSamples / "tmp"
28
+
29
+ @pytest.fixture(scope="session", autouse=True)
30
+ def setupTeardownTestSession() -> Generator[None, None, None]:
31
+ pathDataSamples.mkdir(exist_ok=True)
32
+ pathTmpRoot.mkdir(exist_ok=True)
33
+ yield
34
+ shutil.rmtree(pathTmpRoot, ignore_errors=True)
35
+
36
+ @pytest.fixture
37
+ def pathTmpTesting(request: pytest.FixtureRequest) -> pathlib.Path:
38
+ """'path' means directory or folder, not file."""
39
+ pathTmp = pathTmpRoot / uuid.uuid4().hex
40
+ pathTmp.mkdir(parents=True, exist_ok=False)
41
+ return pathTmp
42
+
43
+ @pytest.fixture
44
+ def pathFilenameTmpTesting(request: pytest.FixtureRequest) -> pathlib.Path:
45
+ """'filename' means file; 'pathFilename' means the full path and filename."""
46
+ try:
47
+ extension = request.param
48
+ except AttributeError:
49
+ extension = ".txt"
50
+
51
+ uuidString = uuid.uuid4().hex
52
+ pathFilenameTmp = pathlib.Path(pathTmpRoot, uuidString[0:-8], uuidString[-8:None] + extension)
53
+ pathFilenameTmp.parent.mkdir(parents=True, exist_ok=False)
54
+ return pathFilenameTmp
55
+
56
+ @pytest.fixture
57
+ def nexusCitationTesting(request: pytest.FixtureRequest) -> CitationNexus:
58
+ """Return a CitationNexus object with the specified attributes."""
59
+ try:
60
+ attributes = request.param
61
+ except AttributeError:
62
+ attributes = {}
63
+
64
+ return CitationNexus(**attributes)
65
+
66
+ @pytest.fixture
67
+ def citationAlphaDOTcff() -> pathlib.Path:
68
+ return pathFilenameCitationAlphaDOTcff
69
+
70
+ """
71
+ Section: Standardized assert statements and failure messages"""
72
+
73
+ def uniformTestFailureMessage(expected: Any, actual: Any, functionName: str, *arguments: Any, **keywordArguments: Any) -> str:
74
+ """Format assertion message for any test comparison."""
75
+ listArgumentComponents = [str(parameter) for parameter in arguments]
76
+ listKeywordComponents = [f"{key}={value}" for key, value in keywordArguments.items()]
77
+ joinedArguments = ', '.join(listArgumentComponents + listKeywordComponents)
78
+
79
+ return (f"\nTesting: `{functionName}({joinedArguments})`\n"
80
+ f"Expected: {expected}\n"
81
+ f"Got: {actual}")
82
+
83
+ def standardizedEqualTo(expected: Any, functionTarget: Callable, *arguments: Any, **keywordArguments: Any) -> None:
84
+ """Template for most tests to compare the actual outcome with the expected outcome, including expected errors."""
85
+ if type(expected) == Type[Exception]:
86
+ messageExpected = expected.__name__
87
+ else:
88
+ messageExpected = expected
89
+
90
+ try:
91
+ messageActual = actual = functionTarget(*arguments, **keywordArguments)
92
+ except Exception as actualError:
93
+ messageActual = type(actualError).__name__
94
+ actual = type(actualError)
95
+
96
+ assert actual == expected, uniformTestFailureMessage(messageExpected, messageActual, functionTarget.__name__, *arguments, **keywordArguments)
@@ -0,0 +1,39 @@
1
+ import pathlib
2
+ import pytest
3
+ from tests.conftest import (
4
+ addCitation,
5
+ CitationNexus,
6
+ standardizedEqualTo,
7
+ )
8
+
9
+ def test_addCitation(nexusCitationTesting, citationAlphaDOTcff: pathlib.Path):
10
+ """Test that addCitation() returns a CitationNexus object."""
11
+ nexusCitation = addCitation(nexusCitationTesting, citationAlphaDOTcff)
12
+ assert isinstance(nexusCitation, CitationNexus), "addCitation() should return a CitationNexus object."
13
+
14
+ @pytest.mark.parametrize("fieldName, expectedValue", [
15
+ ("cffDASHversion", "1.2.0"),
16
+ ("message", "If you use this software, you can cite it using the metadata from this file."),
17
+ ("authors", [{'given-names': 'Hunter', 'family-names': 'Hogan', 'email': 'HunterHogan@pm.me'}]),
18
+ ("commit", "0d70eb67ffbab9563208ec06294887fb7fa6768a"),
19
+ ("dateDASHreleased", "2025-02-07"),
20
+ ("identifiers", [{'type': 'url',
21
+ 'value': 'https://github.com/hunterhogan/mapFolding/releases/tag/0.3.9',
22
+ 'description': 'The URL for mapFolding 0.3.9.'}]),
23
+ ("keywords", ['A001415', 'A001416', 'A001417', 'A001418', 'A195646',
24
+ 'folding', 'map folding', 'OEIS', 'stamp folding']),
25
+ ("license", "CC-BY-NC-4.0"),
26
+ ("repository", "https://github.com/hunterhogan/mapFolding.git"),
27
+ ("repositoryDASHartifact", "https://pypi.org/project/mapfolding/0.3.9/"),
28
+ ("repositoryDASHcode", "https://github.com/hunterhogan/mapFolding/releases/tag/0.3.9"),
29
+ ("title", "mapFolding"),
30
+ ("type", "software"),
31
+ ("url", "https://github.com/hunterhogan/mapFolding"),
32
+ ("version", "0.3.9"),
33
+ ])
34
+ def test_addCitation_fields(nexusCitationTesting, citationAlphaDOTcff: pathlib.Path,
35
+ fieldName: str, expectedValue: str):
36
+ """Test that addCitation() correctly sets each field in the CitationNexus object."""
37
+ nexusCitation = addCitation(nexusCitationTesting, citationAlphaDOTcff)
38
+ # actual = getattr(nexusCitation, fieldName)
39
+ standardizedEqualTo(expectedValue, getattr, nexusCitation, fieldName)
@@ -0,0 +1,111 @@
1
+ import os
2
+ from updateCitation import flowControl
3
+ import pathlib
4
+ import pytest
5
+ from conftest import standardizedEqualTo, cffDASHversionDEFAULT, messageDEFAULT, CitationNexus, SettingsPackage, get_pyprojectDOTtoml
6
+
7
+ # Dummy implementations to record call order.
8
+ def dummy_add_pyprojectDOTtoml(nexusCitation: CitationNexus, truth: SettingsPackage) -> tuple[CitationNexus, SettingsPackage]:
9
+ callOrder.append("add_pyprojectDOTtoml")
10
+ truth = get_pyprojectDOTtoml(truth) # Populate truth.tomlPackageData from the pyproject.toml file.
11
+ # Set required fields to avoid ValueError from setInStone.
12
+ nexusCitation.title = truth.tomlPackageData.get("name")
13
+ nexusCitation.authors = truth.tomlPackageData.get("authors", [])
14
+ # Supply a dummy contact if absent.
15
+ contactData = truth.tomlPackageData.get("contact")
16
+ if not contactData:
17
+ nexusCitation.contact = [{"name": "Dummy", "email": "dummy@example.com"}]
18
+ else:
19
+ nexusCitation.contact = contactData
20
+ return nexusCitation, truth
21
+
22
+ def dummy_addCitation(nexusCitation: CitationNexus, pathFilenameCitationSSOT) -> CitationNexus:
23
+ callOrder.append("addCitation")
24
+ return nexusCitation
25
+
26
+ def dummy_addPyPAMetadata(nexusCitation: CitationNexus, tomlPackageData: dict) -> CitationNexus:
27
+ callOrder.append("addPyPAMetadata")
28
+ return nexusCitation
29
+
30
+ def dummy_addGitHubRelease(nexusCitation: CitationNexus) -> CitationNexus:
31
+ callOrder.append("addGitHubRelease")
32
+ return nexusCitation
33
+
34
+ def dummy_addPyPIrelease(nexusCitation: CitationNexus) -> CitationNexus:
35
+ callOrder.append("addPyPIrelease")
36
+ return nexusCitation
37
+
38
+ def dummy_writeCitation(nexusCitation: CitationNexus, pathFilenameCitationSSOT, pathFilenameCitationDOTcffRepository) -> None:
39
+ callOrder.append("writeCitation")
40
+ return
41
+
42
+ @pytest.fixture
43
+ def validTomlTmpFile(tmp_path: pathlib.Path) -> pathlib.Path:
44
+ pathFilename = tmp_path / "pyproject.toml"
45
+ tomlContent = """
46
+ [project]
47
+ name = "ValidTitle"
48
+ authors = [{name = "John Doe", email = "john@example.com"}]
49
+ contact = [{name = "Contact Name", email = "contact@example.com"}]
50
+ """
51
+ pathFilename.write_text(tomlContent)
52
+ return pathFilename
53
+
54
+ @pytest.fixture
55
+ def missingTitleTomlTmpFile(tmp_path: pathlib.Path) -> pathlib.Path:
56
+ pathFilename = tmp_path / "pyproject.toml"
57
+ # Missing the "name" key.
58
+ tomlContent = """
59
+ [project]
60
+ authors = [{name = "John Doe", email = "john@example.com"}]
61
+ contact = [{name = "Contact Name", email = "contact@example.com"}]
62
+ """
63
+ pathFilename.write_text(tomlContent)
64
+ return pathFilename
65
+
66
+ def restore_flowControl(monkeypatch) -> None:
67
+ # Restore original functions (if needed in further tests) by deleting our attributes.
68
+ for name in ["add_pyprojectDOTtoml", "addCitation", "addPyPAMetadata", "addGitHubRelease", "addPyPIrelease", "writeCitation"]:
69
+ if hasattr(flowControl, name):
70
+ monkeypatch.undo()
71
+
72
+ def test_here_valid(validTomlTmpFile: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
73
+ global callOrder
74
+ callOrder = [] # type: list[str]
75
+
76
+ # Override functions in flowControl, recording call order.
77
+ monkeypatch.setattr(flowControl, "add_pyprojectDOTtoml", dummy_add_pyprojectDOTtoml)
78
+ monkeypatch.setattr(flowControl, "addCitation", dummy_addCitation)
79
+ monkeypatch.setattr(flowControl, "addPyPAMetadata", dummy_addPyPAMetadata)
80
+ monkeypatch.setattr(flowControl, "addGitHubRelease", dummy_addGitHubRelease)
81
+ monkeypatch.setattr(flowControl, "addPyPIrelease", dummy_addPyPIrelease)
82
+ monkeypatch.setattr(flowControl, "writeCitation", dummy_writeCitation)
83
+
84
+ # Call here() with our valid temporary pyproject.toml.
85
+ flowControl.here(validTomlTmpFile)
86
+
87
+ # Verify call order.
88
+ expectedOrder = [
89
+ "add_pyprojectDOTtoml",
90
+ "addCitation",
91
+ "addPyPAMetadata",
92
+ "addGitHubRelease",
93
+ "addPyPIrelease",
94
+ "writeCitation"
95
+ ]
96
+ assert callOrder == expectedOrder
97
+
98
+ def test_here_missingTitle(missingTitleTomlTmpFile: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
99
+ # Override add_pyprojectDOTtoml to mimic its normal behavior so that title remains None.
100
+ def dummy_noTitle(nexusCitation: CitationNexus, truth: SettingsPackage) -> tuple[CitationNexus, SettingsPackage]:
101
+ # Do not set title.
102
+ nexusCitation.authors = truth.tomlPackageData.get("authors", [])
103
+ nexusCitation.contact = truth.tomlPackageData.get("contact", [])
104
+ # This will cause setInStone to raise ValueError.
105
+ nexusCitation.setInStone("pyprojectDOTtoml")
106
+ return nexusCitation, truth
107
+
108
+ monkeypatch.setattr(flowControl, "add_pyprojectDOTtoml", dummy_noTitle)
109
+
110
+ with pytest.raises(ValueError):
111
+ flowControl.here(missingTitleTomlTmpFile)
tests/test_pypa.py ADDED
@@ -0,0 +1,18 @@
1
+ import pytest
2
+ from conftest import standardizedEqualTo, getPyPAMetadata, addPyPAMetadata, CitationNexus
3
+
4
+ def test_getPyPAMetadata_missingName() -> None:
5
+ dictionaryPackageData = {
6
+ "version": "17.19.23",
7
+ }
8
+ with pytest.raises(Exception):
9
+ getPyPAMetadata(dictionaryPackageData)
10
+
11
+ def test_addPyPAMetadata_missingName(nexusCitationTesting: CitationNexus) -> None:
12
+ dictionaryPackageData = {
13
+ "urls": {
14
+ "homepage": "https://gemini.stars.cosmos"
15
+ }
16
+ }
17
+ with pytest.raises(Exception):
18
+ addPyPAMetadata(nexusCitationTesting, dictionaryPackageData)
tests/test_pypi.py ADDED
@@ -0,0 +1,15 @@
1
+ import pytest
2
+ from conftest import standardizedEqualTo, getPyPIrelease, addPyPIrelease
3
+
4
+ @pytest.mark.parametrize("nexusCitationTesting, expected", [
5
+ ({"title": "numpy", "version": "1.23.5"}, {"repositoryDASHartifact": "https://pypi.org/project/numpy/1.23.5/"}),
6
+ ({"title": "scikit-learn", "version": "1.2.2"}, {"repositoryDASHartifact": "https://pypi.org/project/scikit-learn/1.2.2/"}),
7
+ ({"title": "Requests", "version": "2.28.2"}, {"repositoryDASHartifact": "https://pypi.org/project/requests/2.28.2/"}),
8
+ ({"title": "Orion", "version": "5.7.11"}, {"repositoryDASHartifact": "https://pypi.org/project/orion/5.7.11/"}),
9
+ ({"title": "Taurus", "version": "11.13.17"}, {"repositoryDASHartifact": "https://pypi.org/project/taurus/11.13.17/"}),
10
+ ({"version": "1.0.0"}, ValueError),
11
+ ({"title": "pandas"}, ValueError),
12
+ ({}, ValueError), ], indirect=["nexusCitationTesting"] )
13
+ def test_getPyPIrelease(nexusCitationTesting: dict[str, str], expected: dict[str, str] | type[ValueError]) -> None:
14
+ """Test PyPI release info retrieval with various package names and versions."""
15
+ standardizedEqualTo(expected, getPyPIrelease, nexusCitationTesting)
@@ -0,0 +1,42 @@
1
+ from pathlib import Path
2
+ import pytest
3
+ from conftest import standardizedEqualTo, cffDASHversionDEFAULT, messageDEFAULT, CitationNexus, SettingsPackage
4
+
5
+ def test_CitationNexus_requiredFields(nexusCitationTesting: CitationNexus) -> None:
6
+ assert nexusCitationTesting.cffDASHversion == cffDASHversionDEFAULT
7
+ assert nexusCitationTesting.message == messageDEFAULT
8
+ assert nexusCitationTesting.authors == []
9
+ assert nexusCitationTesting.title is None
10
+
11
+ def test_CitationNexus_setInStone_failsOnMissingRequired(nexusCitationTesting: CitationNexus) -> None:
12
+ nexusCitation = CitationNexus()
13
+ with pytest.raises(ValueError, match="Field title is required but not provided"):
14
+ nexusCitation.setInStone("pyprojectDOTtoml")
15
+
16
+ def test_CitationNexus_setInStone_freezesFields(nexusCitationTesting: CitationNexus) -> None:
17
+ nexusCitationTesting.title = "SolarSystem"
18
+ nexusCitationTesting.authors = [{"given-names": "Mercury", "family-names": "Venus"}]
19
+ nexusCitationTesting.setInStone("pyprojectDOTtoml")
20
+
21
+ nexusCitationTesting.title = "GalacticCore"
22
+ assert nexusCitationTesting.title == "SolarSystem"
23
+
24
+ def test_CitationNexus_setInStone_multipleContexts(nexusCitationTesting: CitationNexus) -> None:
25
+ nexusCitationTesting.title = "Jupiter"
26
+ nexusCitationTesting.authors = [{"given-names": "Saturn", "family-names": "Uranus"}]
27
+ nexusCitationTesting.version = "3.5.7"
28
+ nexusCitationTesting.keywords = ["Neptune", "Pluto"]
29
+ nexusCitationTesting.setInStone("pyprojectDOTtoml")
30
+ nexusCitationTesting.setInStone("PyPA")
31
+
32
+ # Should not change fields frozen by either context
33
+ nexusCitationTesting.title = "NewTitle"
34
+ nexusCitationTesting.version = "3.5.8"
35
+ assert nexusCitationTesting.title == "Jupiter"
36
+ assert nexusCitationTesting.version == "3.5.7"
37
+
38
+ def test_SettingsPackage_initialization(pathFilenameTmpTesting: Path) -> None:
39
+ settings = SettingsPackage(pathFilenamePackageSSOT=pathFilenameTmpTesting)
40
+ assert settings.pathFilenamePackageSSOT == pathFilenameTmpTesting
41
+ assert settings.filenameCitationDOTcff == "CITATION.cff"
42
+ assert isinstance(settings.tomlPackageData, dict)
@@ -0,0 +1,16 @@
1
+ from .variables import (
2
+ CitationNexus,
3
+ CitationNexusFieldsFrozen,
4
+ filename_pyprojectDOTtomlDEFAULT,
5
+ mapNexusCitation2pyprojectDOTtoml,
6
+ projectURLTargets,
7
+ SettingsPackage,
8
+ )
9
+
10
+ from .pyprojectDOTtoml import add_pyprojectDOTtoml, getSettingsPackage
11
+ from .citationFileFormat import addCitation, writeCitation
12
+ from .pypa import addPyPAMetadata
13
+ from .github import addGitHubRelease
14
+ from .pypi import addPyPIrelease
15
+
16
+ from .flowControl import here
@@ -0,0 +1,88 @@
1
+ from cffconvert.cli.create_citation import create_citation
2
+ from typing import Any, Dict, List
3
+ from updateCitation import CitationNexus, CitationNexusFieldsFrozen
4
+ import attrs
5
+ import cffconvert
6
+ import pathlib
7
+ import ruamel.yaml
8
+
9
+ def addCitation(nexusCitation: CitationNexus, pathFilenameCitationSSOT: pathlib.Path) -> CitationNexus:
10
+ """Given a path to a citation file, return a CitationNexus object.
11
+ The citation file is expected to be in a format that can be parsed by
12
+ `cffconvert.cli.create_citation.create_citation()`. This function
13
+ ensures convergence with the CFF ecosystem by using `create_citation`
14
+ and its internal `_parse()` method.
15
+ Parameters:
16
+ pathFilenameCitationSSOT (pathlib.Path): Path to the citation file.
17
+ Returns:
18
+ CitationNexus: A CitationNexus object populated with data from the
19
+ citation file.
20
+ """
21
+
22
+ # `cffconvert.cli.create_citation.create_citation()` is PAINFULLY mundane, but a major problem
23
+ # in the CFF ecosystem is divergence. Therefore, I will use this function so that my code
24
+ # converges with the CFF ecosystem.
25
+ citationObject: cffconvert.Citation = create_citation(infile=pathFilenameCitationSSOT, url=None)
26
+ # `._parse()` is a yaml loader: use it for convergence
27
+ cffobj: Dict[Any, Any] = citationObject._parse()
28
+
29
+ # This step is designed to prevent deleting fields that are populated in the current CFF file,
30
+ # but for whatever reason do not get added to the CitationNexus object.
31
+ Z0Z_list: List[attrs.Attribute] = list(attrs.fields(type(nexusCitation)))
32
+ for Z0Z_field in Z0Z_list:
33
+ if Z0Z_field.name in CitationNexusFieldsFrozen:
34
+ continue
35
+ cffobjKeyName: str = Z0Z_field.name.replace("DASH", "-")
36
+ cffobjValue = cffobj.get(cffobjKeyName)
37
+ if cffobjValue: # An empty list will be False
38
+ setattr(nexusCitation, Z0Z_field.name, cffobjValue)
39
+
40
+ # nexusCitation.setInStone("Citation")
41
+ return nexusCitation
42
+
43
+ def writeCitation(nexusCitation: CitationNexus, pathFilenameCitationSSOT: pathlib.Path, pathFilenameCitationDOTcffRepo: pathlib.Path):
44
+ """Writes citation information to YAML files.
45
+ This function takes a CitationNexus object and writes its data to two YAML files:
46
+ one for single source of truth (SSOT) and another for a repository's .cff file.
47
+ It performs data conversion, validation, and file writing using the ruamel.yaml library.
48
+ Parameters:
49
+ nexusCitation (CitationNexus): An object containing citation metadata.
50
+ pathFilenameCitationSSOT (pathlib.Path): Path to the SSOT YAML file.
51
+ pathFilenameCitationDOTcffRepo (pathlib.Path): Path to the .cff YAML file in the repository.
52
+ Raises:
53
+ ValidationError: If the generated citation object fails validation.
54
+ Notes:
55
+ - The function uses a temporary validation file.
56
+ - It replaces "DASH" with "-" in dictionary keys.
57
+ - It filters out empty lists from the citation data.
58
+ """
59
+
60
+ # NOTE embarrassingly hacky process to follow
61
+ parameterIndent= 2
62
+ parameterLineWidth = 60
63
+ yamlWorkhorse = ruamel.yaml.YAML()
64
+
65
+ def srsly(Z0Z_filed, Z0Z_value):
66
+ if Z0Z_value: # empty lists
67
+ return True
68
+ else:
69
+ return False
70
+
71
+ dictionaryCitation = attrs.asdict(nexusCitation, filter=srsly)
72
+ for keyName in list(dictionaryCitation.keys()):
73
+ dictionaryCitation[keyName.replace("DASH", "-")] = dictionaryCitation.pop(keyName)
74
+
75
+ pathFilenameForValidation = pathFilenameCitationSSOT.with_stem('validation')
76
+
77
+ def writeStream(pathFilename):
78
+ with open(pathFilename, 'w') as pathlibIsAStealthContextManagerThatRuamelCannotDetectAndRefusesToWorkWith:
79
+ yamlWorkhorse.dump(dictionaryCitation, pathlibIsAStealthContextManagerThatRuamelCannotDetectAndRefusesToWorkWith)
80
+
81
+ writeStream(pathFilenameForValidation)
82
+
83
+ citationObject: cffconvert.Citation = create_citation(infile=pathFilenameForValidation, url=None)
84
+ if citationObject.validate() is None:
85
+ writeStream(pathFilenameCitationSSOT)
86
+ writeStream(pathFilenameCitationDOTcffRepo)
87
+
88
+ pathFilenameForValidation.unlink()
@@ -0,0 +1,77 @@
1
+ from typing import Any, Union
2
+ from updateCitation import (
3
+ add_pyprojectDOTtoml,
4
+ addCitation,
5
+ addGitHubRelease,
6
+ addPyPAMetadata,
7
+ addPyPIrelease,
8
+ CitationNexus,
9
+ filename_pyprojectDOTtomlDEFAULT,
10
+ getSettingsPackage,
11
+ SettingsPackage,
12
+ writeCitation,
13
+ )
14
+ import os
15
+ import pathlib
16
+
17
+ """
18
+ How to automate citation updates:
19
+ - The `pyproject.toml` file is the source of truth for settings.
20
+ - read from it
21
+ - create a `SettingsPackage` object with the toml values or the default values
22
+ - Create an installable package for updateCitation.
23
+ - Create a GitHub Action that runs updateCitation.
24
+
25
+ Projected flow during a GitHub action to update a citation:
26
+ - Commit and push all to GitHub
27
+ - GitHub action:
28
+ - pip install updateCitation # i need to make a PyPI package first, of course
29
+ - import updateCitation
30
+ - `updateCitation.here()`
31
+ ? Can/should the following logic (or some parts of it) be a function in `updateCitation`?
32
+ - updateCitation will compare the toml version to the github and the pypi versions
33
+ - if the toml version is newer
34
+ - if python tests pass,
35
+ - updateCitation will update everything, including not yet existing versions on GitHub and/or PyPI
36
+ - updateCitation will make a special git commit with the updated citation so that the updated versions are in the releases
37
+ - after that special commit, the workflows for GitHub and PyPI are allowed to run
38
+ - if python tests do NOT pass, updateCitation will update everything but NOT anticipate new versions numbers on GitHub and PyPI
39
+ - I don't have logic for updating the `commit` field yet, but when I figure out how to implement the logic, I guess I will use the hash from the commit just before the special commit.
40
+ """
41
+
42
+ def here(pathFilename_pyprojectDOTtoml: Union[str, os.PathLike[Any], None] = None) -> None:
43
+ """Updates citation files based on package metadata and release information.
44
+
45
+ This function orchestrates the update of citation files within a repository.
46
+ It gathers metadata from the pyproject.toml file, adds information from
47
+ GitHub and PyPI releases, and writes the updated citation data to both
48
+ the single-source-of-truth (SSOT) citation file and the repository-level
49
+ citation file.
50
+ Parameters:
51
+ pathRoot (Union[str, os.PathLike[Any]]): The root path of the repository.
52
+ Raises:
53
+ ValueError: If the package name cannot be found in the pyproject.toml file.
54
+ """
55
+ pathFilenameSettingsSSOT = pathlib.Path(pathFilename_pyprojectDOTtoml) if pathFilename_pyprojectDOTtoml else pathlib.Path.cwd() / filename_pyprojectDOTtomlDEFAULT
56
+ truth: SettingsPackage = getSettingsPackage(pathFilenameSettingsSSOT)
57
+
58
+ nexusCitation = CitationNexus()
59
+
60
+ nexusCitation, truth = add_pyprojectDOTtoml(nexusCitation, truth)
61
+
62
+ if not nexusCitation.title:
63
+ # TODO learn how to change the field from `str | None` to `str` after the field is populated
64
+ # especially for a required field
65
+ raise ValueError("Package name is required.")
66
+
67
+ # pathCitations = truth.pathRepository / "citations"
68
+ pathCitations = truth.pathRepository / nexusCitation.title / "citations"
69
+ pathFilenameCitationSSOT = pathCitations / truth.filenameCitationDOTcff
70
+ pathFilenameCitationDOTcffRepository = truth.pathRepository / truth.filenameCitationDOTcff
71
+
72
+ nexusCitation = addCitation(nexusCitation, pathFilenameCitationSSOT)
73
+ nexusCitation = addPyPAMetadata(nexusCitation, truth.tomlPackageData)
74
+ nexusCitation = addGitHubRelease(nexusCitation)
75
+ nexusCitation = addPyPIrelease(nexusCitation)
76
+
77
+ writeCitation(nexusCitation, pathFilenameCitationSSOT, pathFilenameCitationDOTcffRepository)
@@ -0,0 +1,76 @@
1
+ from github import Auth, Github
2
+ from typing import Any, Dict
3
+ from updateCitation import CitationNexus
4
+ import os
5
+ import warnings
6
+
7
+ def getGitHubRelease(nexusCitation: CitationNexus) -> Dict[str, Any]:
8
+ """Retrieves the latest release information from a GitHub repository.
9
+ Parameters:
10
+ nexusCitation (CitationNexus): A CitationNexus object containing
11
+ citation metadata, including the repository URL.
12
+ Returns:
13
+ Dict[str, Any]: A dictionary containing the release date,
14
+ identifiers (release URL), and repository code URL.
15
+ Returns an empty dictionary if the repository URL is missing
16
+ or if an error occurs while fetching the release information.
17
+ """
18
+ if not nexusCitation.repository:
19
+ return {}
20
+
21
+ githubClient = None
22
+ try:
23
+ # Initialize GitHub client with optional authentication
24
+ token = os.environ.get("GITHUB_TOKEN")
25
+ githubClient = Github(auth=Auth.Token(token)) if token else Github()
26
+
27
+ repoFullName = nexusCitation.repository.replace("https://github.com/", "").replace(".git", "")
28
+ repo = githubClient.get_repo(repoFullName)
29
+
30
+ latestRelease = repo.get_latest_release()
31
+
32
+ return {
33
+ "dateDASHreleased": latestRelease.published_at.strftime("%Y-%m-%d"),
34
+ "identifiers": [{
35
+ "type": "url",
36
+ "value": latestRelease.html_url,
37
+ "description": f"The URL for {nexusCitation.title} {nexusCitation.version}."
38
+ }] if latestRelease.html_url else [],
39
+ "repositoryDASHcode": latestRelease.html_url,
40
+ }
41
+
42
+ except Exception as ERRORmessage:
43
+ warnings.warn(f"Failed to get GitHub release info: {ERRORmessage}")
44
+ return {}
45
+ finally:
46
+ if githubClient:
47
+ githubClient.close()
48
+
49
+ def addGitHubRelease(nexusCitation: CitationNexus) -> CitationNexus:
50
+ """Adds GitHub release information to a CitationNexus object.
51
+ Parameters:
52
+ nexusCitation (CitationNexus): The CitationNexus object to update.
53
+ Returns:
54
+ CitationNexus: The updated CitationNexus object with GitHub release information.
55
+ """
56
+
57
+ gitHubReleaseData = getGitHubRelease(nexusCitation)
58
+
59
+ commitValue = gitHubReleaseData.get("commit")
60
+ if commitValue:
61
+ nexusCitation.commit = commitValue
62
+
63
+ dateDASHreleasedValue = gitHubReleaseData.get("dateDASHreleased")
64
+ if dateDASHreleasedValue:
65
+ nexusCitation.dateDASHreleased = dateDASHreleasedValue
66
+
67
+ identifiersValue = gitHubReleaseData.get("identifiers")
68
+ if identifiersValue:
69
+ nexusCitation.identifiers = identifiersValue
70
+
71
+ repositoryDASHcodeValue = gitHubReleaseData.get("repositoryDASHcode")
72
+ if repositoryDASHcodeValue:
73
+ nexusCitation.repositoryDASHcode = repositoryDASHcodeValue
74
+
75
+ # nexusCitation.setInStone("GitHub")
76
+ return nexusCitation
updateCitation/pypa.py ADDED
@@ -0,0 +1,82 @@
1
+ from updateCitation import CitationNexus, projectURLTargets
2
+ from packaging.metadata import Metadata as PyPAMetadata
3
+ from typing import Any, Dict
4
+ import packaging
5
+ import packaging.metadata
6
+ import packaging.utils
7
+ import packaging.version
8
+
9
+ def getPyPAMetadata(packageData: Dict[str, Any]) -> PyPAMetadata:
10
+ """
11
+ Retrieves and formats package metadata from a dictionary into a PyPAMetadata object.
12
+ Parameters:
13
+ packageData (Dict[str, Any]): A dictionary containing package information,
14
+ typically obtained from an external source like a package index.
15
+ It should include keys such as "name", "version", "keywords", "license",
16
+ and "urls". The "urls" key should map to a dictionary where keys are
17
+ URL names (e.g., "Homepage", "Documentation") and values are the
18
+ corresponding URLs. The "license" key is expected to be a dictionary
19
+ with a "text" key holding the license text.
20
+ Returns:
21
+ PyPAMetadata: A PyPAMetadata object containing the extracted and formatted
22
+ metadata. The project URLs are filtered to include only those with keys
23
+ present in the `projectURLTargets` set (converted to lowercase for
24
+ comparison). The package name is canonicalized using
25
+ `packaging.utils.canonicalize_name` and validated.
26
+ Raises:
27
+ packaging.metadata.InvalidMetadata: If the package name is missing from
28
+ the input `packageData`. This exception is raised by
29
+ `packaging.utils.canonicalize_name` if `validate=True` and the name
30
+ is None. Other potential exceptions from `packaging.utils.canonicalize_name`
31
+ are not explicitly handled.
32
+ """
33
+ dictionaryProjectURLs: Dict[str, str] = {}
34
+ for urlName, url in packageData.get("urls", {}).items():
35
+ urlName = urlName.lower()
36
+ if urlName in projectURLTargets:
37
+ dictionaryProjectURLs[urlName] = url
38
+
39
+ metadataRaw = packaging.metadata.RawMetadata(
40
+ keywords=packageData.get("keywords", []),
41
+ license_expression=packageData.get("license", {}).get("text", ""),
42
+ metadata_version="2.4",
43
+ name=packaging.utils.canonicalize_name(packageData.get("name", None), validate=True), # packaging.metadata.InvalidMetadata: 'name' is a required field
44
+ project_urls=dictionaryProjectURLs,
45
+ version=packageData.get("version", None),
46
+ )
47
+
48
+ metadata = PyPAMetadata().from_raw(metadataRaw)
49
+ return metadata
50
+
51
+ def addPyPAMetadata(nexusCitation: CitationNexus, tomlPackageData: Dict[str, Any]) -> CitationNexus:
52
+ """Adds PyPA (Python Packaging Authority) metadata to a CitationNexus object.
53
+ Parameters:
54
+ nexusCitation (CitationNexus): The CitationNexus object to update.
55
+ tomlPackageData (Dict[str, Any]): A dictionary containing the parsed TOML data
56
+ from the package's pyproject.toml file. This should contain the
57
+ `project` table as specified in PEP 621.
58
+ Returns:
59
+ CitationNexus: The updated CitationNexus object with PyPA metadata.
60
+ Raises:
61
+ ValueError: If the metadata name is missing from the TOML data.
62
+ """
63
+
64
+ metadata: PyPAMetadata = getPyPAMetadata(tomlPackageData)
65
+
66
+ if metadata.version: nexusCitation.version = str(metadata.version)
67
+ if metadata.keywords: nexusCitation.keywords = metadata.keywords
68
+ if metadata.license_expression: nexusCitation.license = metadata.license_expression
69
+
70
+ Z0Z_lookup: Dict[str, str] = {
71
+ "homepage": "url",
72
+ "license": "licenseDASHurl",
73
+ "repository": "repository",
74
+ }
75
+ if metadata.project_urls:
76
+ for urlTarget in projectURLTargets:
77
+ url = metadata.project_urls.get(urlTarget, None)
78
+ if url:
79
+ setattr(nexusCitation, Z0Z_lookup[urlTarget], url)
80
+
81
+ # nexusCitation.setInStone("PyPA")
82
+ return nexusCitation