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 +96 -0
- tests/test_citationFileFormat.py +39 -0
- tests/test_flowControl.py +111 -0
- tests/test_pypa.py +18 -0
- tests/test_pypi.py +15 -0
- tests/test_variables.py +42 -0
- updateCitation/__init__.py +16 -0
- updateCitation/citationFileFormat.py +88 -0
- updateCitation/flowControl.py +77 -0
- updateCitation/github.py +76 -0
- updateCitation/pypa.py +82 -0
- updateCitation/pypi.py +39 -0
- updateCitation/pyprojectDOTtoml.py +53 -0
- updateCitation/variables.py +91 -0
- updateCitation-0.0.1.dist-info/LICENSE +407 -0
- updateCitation-0.0.1.dist-info/METADATA +80 -0
- updateCitation-0.0.1.dist-info/RECORD +19 -0
- updateCitation-0.0.1.dist-info/WHEEL +5 -0
- updateCitation-0.0.1.dist-info/top_level.txt +2 -0
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)
|
tests/test_variables.py
ADDED
|
@@ -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)
|
updateCitation/github.py
ADDED
|
@@ -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
|