diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6a48b5..7454cb1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ Please consult the changelog to inform yourself about breaking changes and secur * added separate `documentation` URL to Project metadata model * added support for Julia `Project.toml` file -* added support for fortran `fpm.toml` file +* added support for Fortran `fpm.toml` file +* added support for Java `pom.xml` file ## [v0.3.1](https://github.com/Materials-Data-Science-and-Informatics/somesy/tree/v0.3.1) (2024-01-23) { id="0.3.1" } diff --git a/README.md b/README.md index 66d3363f..e94a620a 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,16 @@ authoritative** source for project metadata, which is used to update all supported (and enabled) *target files*. You can find an overview of supported formats further below. -By default, `somesy` will create (if they did not exist) or update `CITATION.cff` and `codemeta.json` files in your repository. If you happen to use `pyproject.toml` (in Python projects), `package.json` (in JavaScript projects), `Project.toml` (in Julia projects), or `fpm.toml` (in Fortran projects) somesy would also update the respective information there. +By default, `somesy` will create (if they did not exist) or update `CITATION.cff` and `codemeta.json` files in your repository. +If you happen to use + +* `pyproject.toml` (in Python projects), +* `package.json` (in JavaScript projects), +* `Project.toml` (in Julia projects), +* `fpm.toml` (in Fortran projects), +* `pom.xml` (in Java projects), + +then somesy would also update the respective information there. You can see call available options with `somesy --help`, all of these can also be conveniently set in your `somesy.toml` file. @@ -171,15 +180,17 @@ Here is an overview of all the currently supported files and formats. | package.json | ✓ | | package.json _(JavaScript)_ | ✓(2.) | | Project.toml | ✓ | | Project.toml _(Julia)_ | ✓ | | fpm.toml | ✓ | | fpm.toml _(Fortran)_ | ✓(3.) | +| | ✓ | | pom.toml _(Java)_ | ✓(4.) | | | | | CITATION.cff | ✓ | -| | | | codemeta.json | ✓(4.) | +| | | | codemeta.json | ✓(5.) | **Notes:** 1. note that `somesy` does not support setuptools *dynamic fields* 2. `package.json` only supports one author, so `somesy` will pick the *first* listed author 3. `fpm.toml` only supports one author and maintainer, so `somesy` will pick the *first* listed author and maintainer -4. unlike other targets, `somesy` will *re-create* the `codemeta.json` (i.e. do not edit it by hand!) +4. `pom.xml` has no concept of `maintainers`, but it can have multiple licenses (somesy only supports one main project license) +5. unlike other targets, `somesy` will *re-create* the `codemeta.json` (i.e. do not edit it by hand!) diff --git a/codemeta.json b/codemeta.json index 64e87022..04bd7297 100644 --- a/codemeta.json +++ b/codemeta.json @@ -46,20 +46,6 @@ "codeRepository": "https://github.com/Materials-Data-Science-and-Informatics/somesy", "buildInstructions": "https://materials-data-science-and-informatics.github.io/somesy", "contributor": [ - { - "@type": "Person", - "givenName": "Mustafa", - "familyName": "Soylu", - "email": "m.soylu@fz-juelich.de", - "@id": "https://orcid.org/0000-0003-2637-0432" - }, - { - "@type": "Person", - "givenName": "Anton", - "familyName": "Pirogov", - "email": "a.pirogov@fz-juelich.de", - "@id": "https://orcid.org/0000-0002-5077-7497" - }, { "@type": "Person", "givenName": "Jens", diff --git a/docs/manual.md b/docs/manual.md index ed2f29be..eb3882bf 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -117,33 +117,33 @@ some of the currently supported formats. Bold field names are mandatory, the oth === "Person Metadata" - | Somesy Field | Poetry Config | SetupTools Config | Julia Config | Fortran Config | package.json | CITATION.cff | CodeMeta | - | ---------------- | ------------- | ----------------- | ------------ | -------------- | ------------ | --------------- | --------------- | - | | | | | | | | | - | **given-names** | name+email | name | name+email | name+email | name | given-names | givenName | - | **family-names** | name+email | name | name+email | name+email | name | family-names | familyName | - | **email** | name+email | email | name+email | name+email | email | email | email | - | orcid | - | - | - | - | url | orcid | id | - | *(many others)* | - | - | - | - | - | *(same)* | *(same)* | + | Somesy Field | Poetry Config | SetupTools Config | Java POM | Julia Config | Fortran Config | package.json | CITATION.cff | CodeMeta | + | ---------------- | ------------- | ----------------- | ------------ | ------------ | -------------- | ------------ | --------------- | --------------- | + | | | | | | | | | | + | **given-names** | name+email | name | name | name+email | name+email | name | given-names | givenName | + | **family-names** | name+email | name | name | name+email | name+email | name | family-names | familyName | + | **email** | name+email | email | email | name+email | name+email | email | email | email | + | orcid | - | - | url | - | - | url | orcid | id | + | *(many others)* | - | - | - | - | - | - | *(same)* | *(same)* | === "Project Metadata" - | Somesy Field | Poetry Config | SetupTools Config | Julia Config | Fortran Config | package.json | CITATION.cff | CodeMeta | - | ----------------- | ------------- | ------------------ | ------------ | -------------- | ------------ | --------------- | ----------------- | - | | | | | | | | - | **name** | name | name | name | name | name | title | name | - | **description** | description | description | - | description | description | abstract | description | - | **license** | license | license | - | license | license | license | license | - | **version** | version | version | version | version | version | version | version | - | | | | | | | | | - | ***author=true*** | authors | authors | authors | author | author | authors | author | - | *maintainer=true* | maintainers | maintainers | - | maintainer | maintainers | contact | maintainer | - | *people* | - | - | - | - | contributors | - | contributor | - | | | | | | | | | - | keywords | keywords | keywords | - | keywords | keywords | keywords | keywords | - | homepage | homepage | urls.homepage | - | homepage | homepage | url | url | - | repository | repository | urls.repository | - | - | repository | repository_code | codeRepository | - | documentation | documentation | urls.documentation | - | - | - | - | buildInstructions | + | Somesy Field | Poetry Config | SetupTools Config | Java POM | Julia Config | Fortran Config | package.json | CITATION.cff | CodeMeta | + | ----------------- | ------------- | ------------------ | ------------------------------- | ------------ | -------------- | ------------ | --------------- | ----------------- | + | | | | | | | | | | + | **name** | name | name | name | name | name | name | title | name | + | **description** | description | description | description | - | description | description | abstract | description | + | **license** | license | license | licenses.license | - | license | license | license | license | + | **version** | version | version | version | version | version | version | version | version | + | | | | | | | | | | + | ***author=true*** | authors | authors | developers | authors | author | author | authors | author | + | *maintainer=true* | maintainers | maintainers | - | - | maintainer | maintainers | contact | maintainer | + | *people* | - | - | - | - | - | contributors | - | contributor | + | | | | | | | | | | + | keywords | keywords | keywords | - | - | keywords | keywords | keywords | keywords | + | homepage | homepage | urls.homepage | urls | - | homepage | homepage | url | url | + | repository | repository | urls.repository | scm.url | - | - | repository | repository_code | codeRepository | + | documentation | documentation | urls.documentation | distributionManagement.site.url | - | - | - | - | buildInstructions | Note that the mapping is often not 1-to-1. For example, CITATION.cff allows rich specification of author contact information and complex names. In contrast, diff --git a/poetry.lock b/poetry.lock index ecee0f5e..a6c6d042 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "annotated-types" version = "0.6.0" description = "Reusable constraint types to use with typing.Annotated" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -19,7 +18,6 @@ typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} name = "anybadge" version = "1.14.0" description = "Simple, flexible badge generator for project badges." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -34,7 +32,6 @@ packaging = "*" name = "attrs" version = "21.4.0" description = "Classes Without Boilerplate" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -52,7 +49,6 @@ tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy" name = "babel" version = "2.13.1" description = "Internationalization utilities" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -71,7 +67,6 @@ dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] name = "black" version = "23.11.0" description = "The uncompromising code formatter." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -114,7 +109,6 @@ uvloop = ["uvloop (>=0.15.2)"] name = "certifi" version = "2023.11.17" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -126,7 +120,6 @@ files = [ name = "cffconvert" version = "2.0.0" description = "Command line program to validate and convert CITATION.cff files." -category = "main" optional = false python-versions = "*" files = [ @@ -150,7 +143,6 @@ publishing = ["twine", "wheel"] name = "cfgv" version = "3.4.0" description = "Validate configuration and produce human readable error messages." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -162,7 +154,6 @@ files = [ name = "charset-normalizer" version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -262,7 +253,6 @@ files = [ name = "click" version = "8.1.7" description = "Composable command line interface toolkit" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -277,7 +267,6 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -289,7 +278,6 @@ files = [ name = "commonmark" version = "0.9.1" description = "Python parser for the CommonMark Markdown spec" -category = "main" optional = false python-versions = "*" files = [ @@ -304,7 +292,6 @@ test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] name = "coverage" version = "7.3.2" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -368,11 +355,21 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] + [[package]] name = "distlib" version = "0.3.7" description = "Distribution utilities" -category = "dev" optional = false python-versions = "*" files = [ @@ -384,7 +381,6 @@ files = [ name = "dnspython" version = "2.4.2" description = "DNS toolkit" -category = "main" optional = false python-versions = ">=3.8,<4.0" files = [ @@ -404,7 +400,6 @@ wmi = ["wmi (>=1.5.1,<2.0.0)"] name = "docopt" version = "0.6.2" description = "Pythonic argument parser, that will make you smile" -category = "main" optional = false python-versions = "*" files = [ @@ -415,7 +410,6 @@ files = [ name = "email-validator" version = "2.1.0.post1" description = "A robust email address syntax and deliverability validation library." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -431,7 +425,6 @@ idna = ">=2.0.0" name = "exceptiongroup" version = "1.2.0" description = "Backport of PEP 654 (exception groups)" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -446,7 +439,6 @@ test = ["pytest (>=6)"] name = "fhconfparser" version = "2023" description = "Provides a config language independent way to read a config file." -category = "dev" optional = false python-versions = ">=3.8,<4.0" files = [ @@ -462,7 +454,6 @@ tomli = ">=2.0.0,<3" name = "filelock" version = "3.13.1" description = "A platform independent file lock." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -479,7 +470,6 @@ typing = ["typing-extensions (>=4.8)"] name = "ghp-import" version = "2.1.0" description = "Copy your docs directly to the gh-pages branch." -category = "dev" optional = false python-versions = "*" files = [ @@ -497,7 +487,6 @@ dev = ["flake8", "markdown", "twine", "wheel"] name = "griffe" version = "0.38.1" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -512,7 +501,6 @@ colorama = ">=0.4" name = "hypothesis" version = "6.91.0" description = "A library for property-based testing" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -545,7 +533,6 @@ zoneinfo = ["backports.zoneinfo (>=0.2.1)", "tzdata (>=2023.3)"] name = "identify" version = "2.5.33" description = "File identification library for Python" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -560,7 +547,6 @@ license = ["ukkonen"] name = "idna" version = "3.6" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -572,7 +558,6 @@ files = [ name = "importlib-metadata" version = "4.13.0" description = "Read metadata from Python packages" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -592,7 +577,6 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag name = "importlib-resources" version = "6.1.1" description = "Read resources from Python packages" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -611,7 +595,6 @@ testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -623,7 +606,6 @@ files = [ name = "interrogate" version = "1.5.0" description = "Interrogate a codebase for docstring coverage." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -649,7 +631,6 @@ tests = ["pytest", "pytest-cov", "pytest-mock"] name = "jinja2" version = "3.1.2" description = "A very fast and expressive template engine." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -667,7 +648,6 @@ i18n = ["Babel (>=2.7)"] name = "jsonschema" version = "3.2.0" description = "An implementation of JSON Schema validation for Python" -category = "main" optional = false python-versions = "*" files = [ @@ -689,7 +669,6 @@ format-nongpl = ["idna", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-va name = "licensecheck" version = "2023.1.2" description = "Output the licenses used by dependencies and check if these are compatible with the project license" -category = "dev" optional = false python-versions = ">=3.8,<4.0" files = [ @@ -708,7 +687,6 @@ tomli = ">=2.0.1,<3" name = "markdown" version = "3.5.1" description = "Python implementation of John Gruber's Markdown." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -727,7 +705,6 @@ testing = ["coverage", "pyyaml"] name = "markdown-exec" version = "1.7.0" description = "Utilities to execute code blocks in Markdown files." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -746,7 +723,6 @@ ansi = ["pygments-ansi-color"] name = "markdown-include" version = "0.8.1" description = "A Python-Markdown extension which provides an 'include' function" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -764,7 +740,6 @@ tests = ["pytest"] name = "markupsafe" version = "2.1.3" description = "Safely add untrusted strings to HTML/XML markup." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -824,7 +799,6 @@ files = [ name = "mergedeep" version = "1.3.4" description = "A deep merge function for 🐍." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -836,7 +810,6 @@ files = [ name = "mike" version = "2.0.0" description = "Manage multiple versions of your MkDocs-powered documentation" -category = "dev" optional = false python-versions = "*" files = [ @@ -861,7 +834,6 @@ test = ["coverage", "flake8 (>=3.0)", "flake8-quotes", "shtab"] name = "mkdocs" version = "1.5.3" description = "Project documentation with Markdown." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -893,7 +865,6 @@ min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-imp name = "mkdocs-autorefs" version = "0.5.0" description = "Automatically link across pages in MkDocs." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -909,7 +880,6 @@ mkdocs = ">=1.1" name = "mkdocs-coverage" version = "1.0.0" description = "MkDocs plugin to integrate your coverage HTML report into your site." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -924,7 +894,6 @@ mkdocs = ">=1.2" name = "mkdocs-exclude" version = "1.0.2" description = "A mkdocs plugin that lets you exclude files or trees." -category = "dev" optional = false python-versions = "*" files = [ @@ -938,7 +907,6 @@ mkdocs = "*" name = "mkdocs-gen-files" version = "0.5.0" description = "MkDocs plugin to programmatically generate documentation pages during the build" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -953,7 +921,6 @@ mkdocs = ">=1.0.3" name = "mkdocs-literate-nav" version = "0.6.1" description = "MkDocs plugin to specify the navigation in Markdown instead of YAML" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -968,7 +935,6 @@ mkdocs = ">=1.0.3" name = "mkdocs-macros-plugin" version = "0.7.0" description = "Unleash the power of MkDocs with macros and variables" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -990,7 +956,6 @@ test = ["mkdocs-include-markdown-plugin", "mkdocs-macros-test", "mkdocs-material name = "mkdocs-material" version = "9.5.0" description = "Documentation that simply works" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1020,7 +985,6 @@ recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2. name = "mkdocs-material-extensions" version = "1.3.1" description = "Extension pack for Python Markdown and MkDocs Material." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1032,7 +996,6 @@ files = [ name = "mkdocs-section-index" version = "0.3.8" description = "MkDocs plugin to allow clickable sections that lead to an index page" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1047,7 +1010,6 @@ mkdocs = ">=1.2" name = "mkdocstrings" version = "0.24.0" description = "Automatic documentation from sources, for MkDocs." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1077,7 +1039,6 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] name = "mkdocstrings-python" version = "1.7.5" description = "A Python handler for mkdocstrings." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1093,7 +1054,6 @@ mkdocstrings = ">=0.20" name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1105,7 +1065,6 @@ files = [ name = "nodeenv" version = "1.8.0" description = "Node.js virtual environment builder" -category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ @@ -1120,7 +1079,6 @@ setuptools = "*" name = "packaging" version = "23.2" description = "Core utilities for Python packages" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1132,7 +1090,6 @@ files = [ name = "paginate" version = "0.5.6" description = "Divides large result sets into pages for easier browsing" -category = "dev" optional = false python-versions = "*" files = [ @@ -1143,7 +1100,6 @@ files = [ name = "pastel" version = "0.2.1" description = "Bring colors to your terminal." -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -1155,7 +1111,6 @@ files = [ name = "pathspec" version = "0.11.2" description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1167,7 +1122,6 @@ files = [ name = "platformdirs" version = "4.1.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1183,7 +1137,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-co name = "pluggy" version = "1.3.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1199,7 +1152,6 @@ testing = ["pytest", "pytest-benchmark"] name = "poethepoet" version = "0.18.1" description = "A task runner that works well with poetry." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1218,7 +1170,6 @@ poetry-plugin = ["poetry (>=1.0,<2.0)"] name = "pre-commit" version = "3.5.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1237,7 +1188,6 @@ virtualenv = ">=20.10.0" name = "py" version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -1249,7 +1199,6 @@ files = [ name = "pydantic" version = "2.5.2" description = "Data validation using Python type hints" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1270,7 +1219,6 @@ email = ["email-validator (>=2.0.0)"] name = "pydantic-core" version = "2.14.5" description = "" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1388,7 +1336,6 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" name = "pygments" version = "2.17.2" description = "Pygments is a syntax highlighting package written in Python." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1404,7 +1351,6 @@ windows-terminal = ["colorama (>=0.4.6)"] name = "pygments-ansi-color" version = "0.3.0" description = "" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1419,7 +1365,6 @@ pygments = "!=2.7.3" name = "pykwalify" version = "1.8.0" description = "Python lib/cli for JSON/YAML schema validation" -category = "main" optional = false python-versions = "*" files = [ @@ -1436,7 +1381,6 @@ python-dateutil = ">=2.8.0" name = "pymdown-extensions" version = "10.5" description = "Extension pack for Python Markdown." -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1455,7 +1399,6 @@ extra = ["pygments (>=2.12)"] name = "pyparsing" version = "3.1.1" description = "pyparsing module - Classes and methods to define and execute parsing grammars" -category = "dev" optional = false python-versions = ">=3.6.8" files = [ @@ -1470,7 +1413,6 @@ diagrams = ["jinja2", "railroad-diagrams"] name = "pyrsistent" version = "0.20.0" description = "Persistent/Functional/Immutable data structures" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1512,7 +1454,6 @@ files = [ name = "pytest" version = "7.4.3" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1535,7 +1476,6 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "pytest-cov" version = "4.1.0" description = "Pytest plugin for measuring coverage." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1554,7 +1494,6 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "pytest-mock" version = "3.12.0" description = "Thin-wrapper around the mock package for easier use with pytest" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1572,7 +1511,6 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -1587,7 +1525,6 @@ six = ">=1.5" name = "pytz" version = "2023.3.post1" description = "World timezone definitions, modern and historical" -category = "dev" optional = false python-versions = "*" files = [ @@ -1599,7 +1536,6 @@ files = [ name = "pyyaml" version = "6.0.1" description = "YAML parser and emitter for Python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1649,7 +1585,6 @@ files = [ name = "pyyaml-env-tag" version = "0.1" description = "A custom YAML tag for referencing environment variables in YAML files. " -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1664,7 +1599,6 @@ pyyaml = "*" name = "regex" version = "2023.10.3" description = "Alternative regular expression module, to replace re." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1762,7 +1696,6 @@ files = [ name = "requests" version = "2.31.0" description = "Python HTTP for Humans." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1784,7 +1717,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "requirements-parser" version = "0.5.0" description = "This is a small Python module for parsing Pip requirement files." -category = "dev" optional = false python-versions = ">=3.6,<4.0" files = [ @@ -1799,7 +1731,6 @@ types-setuptools = ">=57.0.0" name = "rich" version = "12.6.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -category = "main" optional = false python-versions = ">=3.6.3,<4.0.0" files = [ @@ -1819,7 +1750,6 @@ jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] name = "ruamel-yaml" version = "0.17.40" description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" -category = "main" optional = false python-versions = ">=3" files = [ @@ -1838,57 +1768,36 @@ jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] name = "ruamel-yaml-clib" version = "0.2.8" description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" -category = "main" optional = false python-versions = ">=3.6" files = [ {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win32.whl", hash = "sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win_amd64.whl", hash = "sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win32.whl", hash = "sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win_amd64.whl", hash = "sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_24_aarch64.whl", hash = "sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win32.whl", hash = "sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win_amd64.whl", hash = "sha256:1758ce7d8e1a29d23de54a16ae867abd370f01b5a69e1a3ba75223eaa3ca1a1b"}, {file = "ruamel.yaml.clib-0.2.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_12_0_arm64.whl", hash = "sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win32.whl", hash = "sha256:75e1ed13e1f9de23c5607fe6bd1aeaae21e523b32d83bb33918245361e9cc51b"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win_amd64.whl", hash = "sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win32.whl", hash = "sha256:955eae71ac26c1ab35924203fda6220f84dce57d6d7884f189743e2abe3a9fbe"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win_amd64.whl", hash = "sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win32.whl", hash = "sha256:84b554931e932c46f94ab306913ad7e11bba988104c5cff26d90d03f68258cd5"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win_amd64.whl", hash = "sha256:25ac8c08322002b06fa1d49d1646181f0b2c72f5cbc15a85e80b4c30a544bb15"}, {file = "ruamel.yaml.clib-0.2.8.tar.gz", hash = "sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512"}, @@ -1898,7 +1807,6 @@ files = [ name = "setuptools" version = "69.0.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1915,7 +1823,6 @@ testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jar name = "shellingham" version = "1.5.4" description = "Tool to Detect Surrounding Shell" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1927,7 +1834,6 @@ files = [ name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -1939,7 +1845,6 @@ files = [ name = "sortedcontainers" version = "2.4.0" description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" -category = "dev" optional = false python-versions = "*" files = [ @@ -1951,7 +1856,6 @@ files = [ name = "tabulate" version = "0.9.0" description = "Pretty-print tabular data" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1966,7 +1870,6 @@ widechars = ["wcwidth"] name = "termcolor" version = "2.4.0" description = "ANSI color formatting for output in terminal" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1981,7 +1884,6 @@ tests = ["pytest", "pytest-cov"] name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -1993,7 +1895,6 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2005,7 +1906,6 @@ files = [ name = "tomlkit" version = "0.11.8" description = "Style preserving TOML library" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2017,7 +1917,6 @@ files = [ name = "typer" version = "0.7.0" description = "Typer, build great CLIs. Easy to code. Based on Python type hints." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2041,7 +1940,6 @@ test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6. name = "types-setuptools" version = "69.0.0.0" description = "Typing stubs for setuptools" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2053,7 +1951,6 @@ files = [ name = "typing-extensions" version = "4.8.0" description = "Backported and Experimental Type Hints for Python 3.8+" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2065,7 +1962,6 @@ files = [ name = "urllib3" version = "2.1.0" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2082,7 +1978,6 @@ zstd = ["zstandard (>=0.18.0)"] name = "verspec" version = "0.1.0" description = "Flexible version handling" -category = "dev" optional = false python-versions = "*" files = [ @@ -2097,7 +1992,6 @@ test = ["coverage", "flake8 (>=3.7)", "mypy", "pretend", "pytest"] name = "virtualenv" version = "20.25.0" description = "Virtual Python Environment builder" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2118,7 +2012,6 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess name = "watchdog" version = "3.0.0" description = "Filesystem events monitoring" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -2158,7 +2051,6 @@ watchmedo = ["PyYAML (>=3.10)"] name = "wrapt" version = "1.16.0" description = "Module for decorators, wrappers and monkey patching." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -2238,7 +2130,6 @@ files = [ name = "zipp" version = "3.17.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "main" optional = false python-versions = ">=3.8" files = [ @@ -2253,4 +2144,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "7ba6685496db93e8a6ad458a07133eaf736168e4a313c3d80a1dd07d5a0b3b87" +content-hash = "6bc518881b74a7e46d4a5fbe0b3a893f2736ba9842f7f5ccb958a49f07f792bf" diff --git a/pyproject.toml b/pyproject.toml index 1df864cf..4d9ff32b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ cffconvert = "^2.0.0" wrapt = "^1.15.0" packaging = "^23.1" jinja2 = "^3.1.2" +defusedxml = "^0.7.1" [tool.poetry.group.dev.dependencies] poethepoet = "^0.18.1" diff --git a/src/somesy/cli/fill.py b/src/somesy/cli/fill.py index 857ac7eb..a0f87059 100644 --- a/src/somesy/cli/fill.py +++ b/src/somesy/cli/fill.py @@ -6,7 +6,12 @@ import typer from jinja2 import Environment, FunctionLoader, select_autoescape -from .util import resolved_somesy_input, wrap_exceptions +from .util import ( + existing_file_arg_config, + file_arg_config, + resolved_somesy_input, + wrap_exceptions, +) logger = logging.getLogger("somesy") app = typer.Typer() @@ -19,37 +24,22 @@ def fill( None, "--template", "-t", - exists=True, - file_okay=True, - dir_okay=False, - writable=False, - readable=True, - resolve_path=False, help="Path to a Jinja2 template for somesy to fill (default: stdin).", + **existing_file_arg_config, ), input_file: Path = typer.Option( None, "--input-file", "-i", - exists=True, - file_okay=True, - dir_okay=False, - writable=True, - readable=True, - resolve_path=True, help="Path of somesy input file (default: try to infer).", + **existing_file_arg_config, ), output_file: Path = typer.Option( None, "--output-file", "-o", - exists=False, - file_okay=True, - dir_okay=False, - writable=True, - readable=False, - resolve_path=True, help="Path for target file (default: stdout).", + **file_arg_config, ), ): """Fill a Jinja2 template with somesy project metadata (e.g. list authors in project docs).""" diff --git a/src/somesy/cli/init.py b/src/somesy/cli/init.py index 9020b912..6e5936e4 100644 --- a/src/somesy/cli/init.py +++ b/src/somesy/cli/init.py @@ -23,44 +23,46 @@ def config(): # prompt for inputs input_file = typer.prompt("Input file path", default=input_file_default) - input_file = Path(input_file) - options = { - "input_file": input_file, - "no_sync_cff": not typer.confirm( - "Do you want to sync to a CFF file?", default=True - ), - } - cff_file = typer.prompt("CFF file path", default="CITATION.cff") - if cff_file is not None or cff_file != "": + options = dict(input_file=Path(input_file)) + + # ---- + + options["no_sync_cff"] = not typer.confirm( + "Do you want to sync to a CFF file?", default=True + ) + if cff_file := typer.prompt("CFF file path", default="CITATION.cff"): options["cff_file"] = cff_file + options["no_sync_codemeta"] = not typer.confirm( + "Do you want to sync to a codemeta.json file?", default=True + ) + if codemeta_file := typer.prompt( + "codemeta.json file path", default="codemeta.json" + ): + options["codemeta_file"] = codemeta_file + options["no_sync_pyproject"] = not typer.confirm( "Do you want to sync to a pyproject.toml file?", default=True ) - - pyproject_file = typer.prompt("pyproject.toml file path", default="pyproject.toml") - if pyproject_file is not None or pyproject_file != "": + if pyproject_file := typer.prompt( + "pyproject.toml file path", default="pyproject.toml" + ): options["pyproject_file"] = pyproject_file options["sync_package_json"] = typer.confirm( "Do you want to sync to a package.json file?", default=False ) - package_json_file = typer.prompt("package.json file path", default="package.json") - if package_json_file is not None or package_json_file != "": + if package_json_file := typer.prompt( + "package.json file path", default="package.json" + ): options["package_json_file"] = package_json_file - options["no_sync_codemeta"] = not typer.confirm( - "Do you want to sync to a codemeta.json file?", default=True - ) - codemeta_file = typer.prompt("codemeta.json file path", default="codemeta.json") - if codemeta_file is not None or codemeta_file != "": - options["codemeta_file"] = codemeta_file - options["no_sync_julia"] = not typer.confirm( "Do you want to sync to a Project.toml(Julia) file?", default=True ) - julia_file = typer.prompt("Project.toml(Julia) file path", default="Project.toml") - if julia_file is not None or julia_file != "": + if julia_file := typer.prompt( + "Project.toml (Julia) file path", default="Project.toml" + ): options["julia_file"] = julia_file options["no_sync_fortran"] = not typer.confirm( @@ -70,6 +72,14 @@ def config(): if fortran_file is not None or fortran_file != "": options["fortran_file"] = fortran_file + options["no_sync_pom_xml"] = not typer.confirm( + "Do you want to sync to a pom.xml file?", default=True + ) + if pom_xml_file := typer.prompt("pom.xml file path", default="pom.xml"): + options["pom_xml_file"] = pom_xml_file + + # ---- + options["show_info"] = typer.confirm( "Do you want to show info about the sync process?" ) diff --git a/src/somesy/cli/sync.py b/src/somesy/cli/sync.py index 67b127ad..c2eb3b48 100644 --- a/src/somesy/cli/sync.py +++ b/src/somesy/cli/sync.py @@ -7,7 +7,12 @@ from somesy.commands import sync as sync_command from somesy.core.models import SomesyInput -from .util import resolved_somesy_input, wrap_exceptions +from .util import ( + existing_file_arg_config, + file_arg_config, + resolved_somesy_input, + wrap_exceptions, +) logger = logging.getLogger("somesy") @@ -21,13 +26,8 @@ def sync( None, "--input-file", "-i", - exists=False, - file_okay=True, - dir_okay=False, - writable=True, - readable=True, - resolve_path=True, help="Somesy input file path (default: .somesy.toml)", + **file_arg_config, ), no_sync_pyproject: bool = typer.Option( None, @@ -39,13 +39,8 @@ def sync( None, "--pyproject-file", "-p", - exists=True, - file_okay=True, - dir_okay=False, - writable=True, - readable=True, - resolve_path=True, help="Existing pyproject.toml file path (default: pyproject.toml)", + **existing_file_arg_config, ), no_sync_package_json: bool = typer.Option( None, @@ -57,13 +52,47 @@ def sync( None, "--package-json-file", "-j", - exists=True, - file_okay=True, - dir_okay=False, - writable=True, - readable=True, - resolve_path=True, help="Existing package.json file path (default: package.json)", + **existing_file_arg_config, + ), + no_sync_julia: bool = typer.Option( + None, + "--no-sync-julia", + "-L", + help="Do not sync Project.toml (Julia) file (default: False)", + ), + julia_file: Path = typer.Option( + None, + "--julia-file", + "-l", + help="Custom Project.toml (Julia) file path (default: Project.toml)", + **existing_file_arg_config, + ), + no_sync_fortran: bool = typer.Option( + None, + "--no-sync-fortran", + "-F", + help="Do not sync fpm.toml (Fortran) file (default: False)", + **existing_file_arg_config, + ), + fortran_file: Path = typer.Option( + None, + "--fortran-file", + "-f", + help="Custom fpm.toml (Fortran) file path (default: fpm.toml)", + ), + no_sync_pom_xml: bool = typer.Option( + None, + "--no-sync-pomxml", + "-X", + help="Do not sync pom.xml (Java Maven) file (default: False)", + ), + pom_xml_file: Path = typer.Option( + None, + "--pomxml-file", + "-x", + help="Custom pom.xml (Java Maven) file path (default: pom.xml)", + **existing_file_arg_config, ), no_sync_cff: bool = typer.Option( None, @@ -75,67 +104,21 @@ def sync( None, "--cff-file", "-c", - exists=False, - file_okay=True, - dir_okay=False, - writable=True, - readable=True, - resolve_path=True, help="CITATION.cff file path (default: CITATION.cff)", + **file_arg_config, ), no_sync_codemeta: bool = typer.Option( None, "--no-sync-codemeta", "-M", - help="Do not sync codemeta.json file", + help="Do not sync codemeta.json file (default: False)", ), codemeta_file: Path = typer.Option( None, "--codemeta-file", "-m", - exists=False, - file_okay=True, - dir_okay=False, - writable=True, - readable=True, - resolve_path=True, - help="Custom codemeta.json file path", - ), - no_sync_julia: bool = typer.Option( - None, - "--no-sync-julia", - "-L", - help="Do not sync Project.toml(Julia) file (default: False)", - ), - julia_file: Path = typer.Option( - None, - "--julia-file", - "-l", - exists=True, - file_okay=True, - dir_okay=False, - writable=True, - readable=True, - resolve_path=True, - help="Custom Project.toml(Julia) file path", - ), - no_sync_fortran: bool = typer.Option( - None, - "--no-sync-fortran", - "-F", - help="Do not sync fpm.toml(fortran) file (default: False)", - ), - fortran_file: Path = typer.Option( - None, - "--fortran-file", - "-f", - exists=True, - file_okay=True, - dir_okay=False, - writable=True, - readable=True, - resolve_path=True, - help="Custom fpm.toml(fortran) file path", + help="Custom codemeta.json file path (default: codemeta.json)", + **file_arg_config, ), ): """Sync project metadata input with metadata files.""" @@ -153,6 +136,8 @@ def sync( julia_file=julia_file, no_sync_fortran=no_sync_fortran, fortran_file=fortran_file, + no_sync_pom_xml=no_sync_pom_xml, + pom_xml_file=pom_xml_file, ) run_sync(somesy_input) @@ -170,20 +155,25 @@ def run_sync(somesy_input: SomesyInput): logger.info( f" - [italic]package.json[/italic]:\t[grey]{conf.package_json_file}[/grey]" ) - if not conf.no_sync_cff: - logger.info(f" - [italic]CITATION.cff[/italic]:\t[grey]{conf.cff_file}[/grey]") - if not conf.no_sync_codemeta: - logger.info( - f" - [italic]codemeta.json[/italic]:\t[grey]{conf.codemeta_file}[/grey]\n" - ) if not conf.no_sync_julia: logger.info( - f" - [italic]Project.toml(Julia)[/italic]:\t[grey]{conf.julia_file}[/grey]" + f" - [italic]Project.toml[/italic]:\t[grey]{conf.julia_file}[/grey]\n" ) if not conf.no_sync_fortran: logger.info( f" - [italic]fpm.toml(fortran)[/italic]:\t[grey]{conf.fortran_file}[/grey]" ) + if not conf.no_sync_pom_xml: + logger.info( + f" - [italic]pom.xml[/italic]:\t[grey]{conf.pom_xml_file}[/grey]\n" + ) + + if not conf.no_sync_cff: + logger.info(f" - [italic]CITATION.cff[/italic]:\t[grey]{conf.cff_file}[/grey]") + if not conf.no_sync_codemeta: + logger.info( + f" - [italic]codemeta.json[/italic]:\t[grey]{conf.codemeta_file}[/grey]\n" + ) # ---- sync_command(somesy_input) # ---- diff --git a/src/somesy/cli/util.py b/src/somesy/cli/util.py index 9e50cb66..954067a1 100644 --- a/src/somesy/cli/util.py +++ b/src/somesy/cli/util.py @@ -14,6 +14,18 @@ logger = logging.getLogger("somesy") +# configuration dicts for CLI file arguments +file_arg_config = dict( + file_okay=True, + dir_okay=False, + writable=True, + readable=True, + resolve_path=True, +) +existing_file_arg_config = dict(file_arg_config) +existing_file_arg_config.update(dict(exists=True)) + + @wrapt.decorator def wrap_exceptions(wrapped, instance, args, kwargs): """Format and log exceptions for cli commands.""" diff --git a/src/somesy/codemeta/__init__.py b/src/somesy/codemeta/__init__.py index 0c70c4c5..eda81065 100644 --- a/src/somesy/codemeta/__init__.py +++ b/src/somesy/codemeta/__init__.py @@ -1,4 +1,4 @@ """Integration with codemeta.json (to re-generate codemeta as part of somesy sync).""" -from .writer import Codemeta +from .writer import CodeMeta -__all__ = ["Codemeta"] +__all__ = ["CodeMeta"] diff --git a/src/somesy/codemeta/writer.py b/src/somesy/codemeta/writer.py index 654f36a3..02e21559 100644 --- a/src/somesy/codemeta/writer.py +++ b/src/somesy/codemeta/writer.py @@ -13,7 +13,7 @@ logger = logging.getLogger("somesy") -class Codemeta(ProjectMetadataWriter): +class CodeMeta(ProjectMetadataWriter): """Codemeta.json parser and saver.""" def __init__( @@ -71,7 +71,7 @@ def _validate(self) -> None: config = dict(self._get_property([])) logger.debug( - f"No validation for codemeta.json files {Codemeta.__name__}: {pretty_repr(config)}" + f"No validation for codemeta.json files {CodeMeta.__name__}: {pretty_repr(config)}" ) def _init_new_file(self) -> None: @@ -167,4 +167,6 @@ def sync(self, metadata: ProjectMetadata) -> None: Use existing sync function from ProjectMetadataWriter but update repository and contributors. """ super().sync(metadata) - self.contributors = self._sync_person_list(self.contributors, metadata.people) + self.contributors = self._sync_person_list( + self.contributors, metadata.contributors() + ) diff --git a/src/somesy/commands/sync.py b/src/somesy/commands/sync.py index bdc4804a..d262a4ef 100644 --- a/src/somesy/commands/sync.py +++ b/src/somesy/commands/sync.py @@ -1,153 +1,63 @@ """Sync selected metadata files with given input file.""" import logging from pathlib import Path +from typing import Type from rich.pretty import pretty_repr from somesy.cff.writer import CFF -from somesy.codemeta import Codemeta +from somesy.codemeta import CodeMeta from somesy.core.models import ProjectMetadata, SomesyInput +from somesy.core.writer import ProjectMetadataWriter from somesy.fortran.writer import Fortran from somesy.julia.writer import Julia from somesy.package_json.writer import PackageJSON +from somesy.pom_xml.writer import POM from somesy.pyproject.writer import Pyproject logger = logging.getLogger("somesy") +def _sync_file( + metadata: ProjectMetadata, file: Path, writer_cls: Type[ProjectMetadataWriter] +): + """Sync metadata to a file using the provided writer.""" + logger.verbose(f"Loading '{file.name}' ...") + writer = writer_cls(file) + logger.verbose(f"Syncing '{file.name}' ...") + writer.sync(metadata) + writer.save(file) + logger.verbose(f"Saved synced '{file.name}'.\n") + + def sync(somesy_input: SomesyInput): """Sync selected metadata files with given input file.""" conf, metadata = somesy_input.config, somesy_input.project - logger.debug( - f"Project metadata: {pretty_repr(metadata.model_dump(exclude_defaults=True))}" - ) + pp_metadata = pretty_repr(metadata.model_dump(exclude_defaults=True)) + logger.debug(f"Project metadata: {pp_metadata}") # update these only if they exist: if conf.pyproject_file.is_file() and not conf.no_sync_pyproject: - _sync_python(metadata, conf.pyproject_file) + _sync_file(metadata, conf.pyproject_file, Pyproject) if conf.package_json_file.is_file() and not conf.no_sync_package_json: - _sync_package_json(metadata, conf.package_json_file) + _sync_file(metadata, conf.package_json_file, PackageJSON) - # create these by default if they are missing: - if not conf.no_sync_cff: - _sync_cff(metadata, conf.cff_file) + if conf.julia_file.is_file() and not conf.no_sync_julia: + _sync_file(metadata, conf.julia_file, Julia) - if not conf.no_sync_codemeta: - _sync_codemeta(metadata, conf.codemeta_file) + if conf.fortran_file.is_file() and not conf.no_sync_fortran: + _sync_file(metadata, conf.fortran_file, Fortran) - if not conf.no_sync_julia: - _sync_julia(metadata, conf.julia_file) + if conf.pom_xml_file.is_file() and not conf.no_sync_pom_xml: + _sync_file(metadata, conf.pom_xml_file, POM) - if not conf.no_sync_fortran: - _sync_fortran(metadata, conf.fortran_file) + # create these by default if they are missing: + if not conf.no_sync_cff: + _sync_file(metadata, conf.cff_file, CFF) -def _sync_python( - metadata: ProjectMetadata, - pyproject_file: Path, -): - """Sync pyproject.toml file using project metadata. - - Args: - metadata (ProjectMetadata): project metadata to sync pyproject.toml file. - pyproject_file (Path, optional): pyproject file to read project metadata from. - """ - logger.verbose("Loading pyproject.toml file.") - pyproject = Pyproject(pyproject_file) - logger.verbose("Syncing pyproject.toml file.") - pyproject.sync(metadata) - pyproject.save() - logger.verbose("Saved synced pyproject.toml file.\n") - - -def _sync_cff( - metadata: ProjectMetadata, - cff_file: Path, -): - """Sync CITATION.cff file using project metadata. - - Args: - metadata (ProjectMetadata): project metadata to sync pyproject.toml file. - cff_file (Path, optional): CFF file path if wanted to be synced. Defaults to None. - """ - logger.verbose("Loading CITATION.cff file.") - cff = CFF(cff_file) - logger.verbose("Syncing CITATION.cff file.") - cff.sync(metadata) - cff.save() - logger.verbose("Saved synced CITATION.cff file.\n") - - -def _sync_package_json( - metadata: ProjectMetadata, - package_json_file: Path, -): - """Sync package.json file using project metadata. - - Args: - metadata (ProjectMetadata): project metadata to sync pyproject.toml file. - package_json_file (Path, optional): package.json file path if wanted to be synced. Defaults to None. - """ - logger.verbose("Loading package.json file.") - package_json = PackageJSON(package_json_file) - logger.verbose("Syncing package.json file.") - package_json.sync(metadata) - package_json.save() - logger.verbose("Saved synced package.json file.\n") - - -def _sync_codemeta( - metadata: ProjectMetadata, - codemeta_file: Path, -): - """Sync codemeta.json file using project metadata. - - Args: - metadata (ProjectMetadata): project metadata to sync pyproject.toml file. - codemeta_file (Path, optional): codemeta.json file path if wanted to be synced. Defaults to None. - """ - logger.verbose("Creating codemeta.json file.") - cm = Codemeta(codemeta_file) - logger.verbose("Syncing codemeta.json file.") - cm.sync(metadata) - cm.save() - logger.verbose(f"New codemeta graph written to {codemeta_file}.") - - -def _sync_julia( - metadata: ProjectMetadata, - julia_file: Path, -): - """Sync Project.toml file using project metadata. - - Args: - metadata (ProjectMetadata): project metadata to sync Project.toml file. - julia_file (Path, optional): Project.toml file path if wanted to be synced. Defaults to None. - """ - logger.verbose("Loading Project.toml file.") - cm = Julia(julia_file) - logger.verbose("Syncing Project.toml file.") - cm.sync(metadata) - cm.save() - logger.verbose(f"Saved synced Project.toml file to {julia_file}.") - - -def _sync_fortran( - metadata: ProjectMetadata, - fortran_file: Path, -): - """Sync fpm.toml file using project metadata. - - Args: - metadata (ProjectMetadata): project metadata to sync fpm.toml file. - fortran_file (Path, optional): fpm.toml file path if wanted to be synced. Defaults to None. - """ - logger.verbose("Loading fpm.toml file.") - cm = Fortran(fortran_file) - logger.verbose("Syncing fpm.toml file.") - cm.sync(metadata) - cm.save() - logger.verbose(f"Saved synced fpm.toml file to {fortran_file}.") + if not conf.no_sync_codemeta: + _sync_file(metadata, conf.codemeta_file, CodeMeta) diff --git a/src/somesy/core/models.py b/src/somesy/core/models.py index 3032f45d..e8d13dfb 100644 --- a/src/somesy/core/models.py +++ b/src/somesy/core/models.py @@ -36,6 +36,7 @@ class SomesyBaseModel(BaseModel): model_config = dict( extra="forbid", + validate_assignment=True, populate_by_name=True, str_strip_whitespace=True, str_min_length=1, @@ -119,7 +120,15 @@ def model_dump_json(self, *args, **kwargs): return json.dumps(ret, ensure_ascii=False) -_SOMESY_TARGETS = ["cff", "pyproject", "package_json", "codemeta", "julia", "fortran"] +_SOMESY_TARGETS = [ + "cff", + "pyproject", + "package_json", + "codemeta", + "julia", + "fortran", + "pom_xml", +] class SomesyConfig(SomesyBaseModel): @@ -171,18 +180,6 @@ def at_least_one_target(cls, values): Path, Field(description="package.json file path.") ] = Path("package.json") - no_sync_cff: Annotated[bool, Field(description="Do not sync with CFF.")] = False - cff_file: Annotated[Path, Field(description="CFF file path.")] = Path( - "CITATION.cff" - ) - - no_sync_codemeta: Annotated[ - bool, Field(description="Do not sync with codemeta.json.") - ] = False - codemeta_file: Annotated[ - Path, Field(description="codemeta.json file path.") - ] = Path("codemeta.json") - no_sync_julia: Annotated[ bool, Field(description="Do not sync with Project.toml.") ] = False @@ -197,6 +194,25 @@ def at_least_one_target(cls, values): "fpm.toml" ) + no_sync_pom_xml: Annotated[ + bool, Field(description="Do not sync with pom.xml.") + ] = False + pom_xml_file: Annotated[Path, Field(description="pom.xml file path.")] = Path( + "pom.xml" + ) + + no_sync_cff: Annotated[bool, Field(description="Do not sync with CFF.")] = False + cff_file: Annotated[Path, Field(description="CFF file path.")] = Path( + "CITATION.cff" + ) + + no_sync_codemeta: Annotated[ + bool, Field(description="Do not sync with codemeta.json.") + ] = False + codemeta_file: Annotated[ + Path, Field(description="codemeta.json file path.") + ] = Path("codemeta.json") + def log_level(self) -> SomesyLogLevel: """Return log level derived from this configuration.""" return SomesyLogLevel.from_flags( diff --git a/src/somesy/core/writer.py b/src/somesy/core/writer.py index 8b870cfa..6c25d749 100644 --- a/src/somesy/core/writer.py +++ b/src/somesy/core/writer.py @@ -16,6 +16,12 @@ class IgnoreKey: FieldKeyMapping = Dict[str, Union[List[str], IgnoreKey]] """Type to be used for the dict passed as `direct_mappings`.""" +DictLike = Any +"""Dict-like that supports getitem, setitem, delitem, etc. + +NOTE: This should be probably turned into a proper protocol. +""" + class ProjectMetadataWriter(ABC): """Base class for Project Metadata Output Wrapper. @@ -42,8 +48,8 @@ def __init__( create_if_not_exists: Create an empty CFF file if not exists. Defaults to True. direct_mappings: Dict with direct mappings of keys between somesy and target """ - self._data: Dict = {} - self.path = path + self._data: DictLike = {} + self.path = path if isinstance(path, Path) else Path(path) self.create_if_not_exists = create_if_not_exists self.direct_mappings = direct_mappings or {} @@ -94,7 +100,11 @@ def save(self, path: Optional[Path]) -> None: """ def _get_property( - self, key: Union[str, List[str]], *, remove: bool = False + self, + key: Union[str, List[str]], + *, + only_first: bool = False, + remove: bool = False, ) -> Optional[Any]: """Get a property from the data. @@ -103,6 +113,7 @@ def _get_property( Args: key: Name of the key or sequence of multiple keys to retrieve the value. + only_first: If True, returns only first entry if the value is a list. remove: If True, will remove the retrieved value and clean up the dict. """ key_path = [key] if isinstance(key, str) else key @@ -111,25 +122,28 @@ def _get_property( seq = [curr] for k in key_path: curr = curr.get(k) + curr = curr[0] if isinstance(curr, list) and only_first else curr seq.append(curr) if curr is None: return None if remove: seq.pop() - logger.debug("remove in") - logger.debug(seq[-1]) del seq[-1][key_path[-1]] # remove leaf value # clean up the tree for key, dct in reversed(list(zip(key_path[:-1], seq[:-1]))): if not dct.get(key): del dct[key] + if isinstance(curr, list) and only_first: + return curr[0] return curr def _set_property(self, key: Union[str, List[str], IgnoreKey], value: Any) -> None: """Set a property in the data. + Note if there are lists along the path, they are cleared out. + Override this to e.g. rewrite the retrieved key (e.g. if everything relevant is in some subobject). """ @@ -147,6 +161,7 @@ def _set_property(self, key: Union[str, List[str], IgnoreKey], value: Any) -> No if key not in curr: curr[key] = {} curr = curr[key] + curr[key_path[-1]] = value # ---- @@ -271,8 +286,6 @@ def _to_person(person_obj: Any) -> Person: @classmethod def _parse_people(cls, people: Optional[List[Any]]) -> List[Person]: """Return a list of Persons parsed from list of format-specific people representations.""" - if not people: - return [] return list(map(cls._to_person, people or [])) # ---- @@ -333,6 +346,17 @@ def maintainers(self, maintainers: List[Person]) -> None: maintainers = [self._from_person(c) for c in maintainers] self._set_property(self._get_key("maintainers"), maintainers) + @property + def contributors(self): + """Return the contributors of the project.""" + return self._get_property(self._get_key("contributors")) + + @contributors.setter + def contributors(self, contributors: List[Person]) -> None: + """Set the contributors of the project.""" + contributors = [self._from_person(c) for c in contributors] + self._set_property(self._get_key("contributors"), contributors) + @property def keywords(self) -> Optional[List[str]]: """Return the keywords of the project.""" diff --git a/src/somesy/package_json/writer.py b/src/somesy/package_json/writer.py index 8dbf5fee..4382725e 100644 --- a/src/somesy/package_json/writer.py +++ b/src/somesy/package_json/writer.py @@ -2,7 +2,7 @@ import logging from collections import OrderedDict from pathlib import Path -from typing import List, Optional +from typing import Dict, List, Optional, Union from rich.pretty import pretty_repr @@ -112,5 +112,19 @@ def sync(self, metadata: ProjectMetadata) -> None: """ super().sync(metadata) self.contributors = self._sync_person_list(self.contributors, metadata.people) - if metadata.repository: - self.repository = {"type": "git", "url": str(metadata.repository)} + + @property + def repository(self) -> Optional[Union[str, Dict]]: + """Return the repository url of the project.""" + if repo := super().repository: + if isinstance(repo, str): + return repo + else: + return repo.get("url") + else: + return None + + @repository.setter + def repository(self, value: Optional[Union[str, Dict]]) -> None: + """Set the repository url of the project.""" + self._set_property(self._get_key("repository"), dict(type="git", url=value)) diff --git a/src/somesy/pom_xml/__init__.py b/src/somesy/pom_xml/__init__.py index a6f8fc52..b6d40446 100644 --- a/src/somesy/pom_xml/__init__.py +++ b/src/somesy/pom_xml/__init__.py @@ -1 +1,10 @@ """Somesy implementation for Java Maven pom.xml.""" + +# some POM-related constants and reusable objects +POM_URL = "http://maven.apache.org/POM/4.0.0" +POM_PREF = "{" + POM_URL + "}" +POM_ROOT_ATRS = { + "xmlns": "http://maven.apache.org/POM/4.0.0", + "xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", + "xsi:schemaLocation": "http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd", +} diff --git a/src/somesy/pom_xml/writer.py b/src/somesy/pom_xml/writer.py new file mode 100644 index 00000000..271b67e5 --- /dev/null +++ b/src/somesy/pom_xml/writer.py @@ -0,0 +1,216 @@ +"""Writer adapter for pom.xml files.""" +import logging +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +from somesy.core.models import Person +from somesy.core.writer import FieldKeyMapping, ProjectMetadataWriter + +from . import POM_ROOT_ATRS, POM_URL +from .xmlproxy import XMLProxy + +logger = logging.getLogger("somesy") + + +class POM(ProjectMetadataWriter): + """Java Maven pom.xml parser and saver.""" + + # TODO: write a wrapper for ElementTree that behaves like a dict + # TODO: set up correct field name mappings + + def __init__( + self, + path: Path, + create_if_not_exists: bool = True, + ): + """Java Maven pom.xml parser. + + See [somesy.core.writer.ProjectMetadataWriter.__init__][]. + """ + mappings: FieldKeyMapping = { + # "year": ["inceptionYear"], # not supported by somesy + does not really change + # "project_slug": ["artifactId"], # not supported by somesy for sync + "license": ["licenses", "license"], + "homepage": ["url"], + "repository": ["scm"], + "documentation": ["distributionManagement", "site"], + "authors": ["developers", "developer"], + "contributors": ["contributors", "contributor"], + } + super().__init__( + path, create_if_not_exists=create_if_not_exists, direct_mappings=mappings + ) + + def _init_new_file(self): + """Initialize new pom.xml file.""" + pom = XMLProxy(ET.Element("project", POM_ROOT_ATRS)) + pom["properties"] = {"info.versionScheme": "semver-spec"} + pom.write(self.path) + + def _load(self): + """Load the POM file.""" + ET.register_namespace("", POM_URL) # register POM as default xml namespace + self._data = XMLProxy.parse(self.path, default_namespace=POM_URL) + + def _validate(self): + """Validate the POM file.""" + logger.info("Cannot validate POM file, skipping validation.") + + def save(self, path: Optional[Path] = None) -> None: + """Save the POM DOM to a file.""" + self._data.write(path or self.path, default_namespace=None) + + def _get_property( + self, + key: Union[str, List[str]], + *, + only_first: bool = False, + remove: bool = False, + ) -> Optional[Any]: + elem = super()._get_property(key, only_first=only_first, remove=remove) + if elem is not None: + if isinstance(elem, list): + return [e.to_jsonlike() for e in elem] + else: + return elem.to_jsonlike() + return None + + @staticmethod + def _from_person(person: Person): + """Convert person object to dict for POM XML person format.""" + ret: Dict[str, Any] = {} + person_id = str(person.orcid) or person.to_name_email_string() + ret["id"] = person_id + ret["name"] = person.full_name + ret["email"] = person.email + if person.orcid: + ret["url"] = str(person.orcid) + if person.contribution_types: + ret["roles"] = dict(role=[c.value for c in person.contribution_types]) + return ret + + @staticmethod + def _to_person(person_obj) -> Person: + """Parse POM XML person to a somesy Person.""" + print(person_obj) + names = person_obj["name"].split() + gnames = " ".join(names[:-1]) + fname = names[-1] + email = person_obj["email"] + url = person_obj.get("url") + maybe_orcid = url if url.find("orcid.org") >= 0 else None + if roles := person_obj.get("roles"): + contr = roles["role"] + else: + contr = None + + return Person( + given_names=gnames, + family_names=fname, + email=email, + orcid=maybe_orcid, + contribution_types=contr, + ) + + # no search keywords supported in POM + @property + def keywords(self) -> Optional[List[str]]: + """Return the keywords of the project.""" + pass + + @keywords.setter + def keywords(self, keywords: List[str]) -> None: + """Set the keywords of the project.""" + pass + + # authors must be a list + @property + def authors(self): + """Return the authors of the project.""" + authors = self._get_property(self._get_key("authors")) + return authors if isinstance(authors, list) else [authors] + + @authors.setter + def authors(self, authors: List[Person]) -> None: + """Set the authors of the project.""" + authors = [self._from_person(c) for c in authors] + self._set_property(self._get_key("authors"), authors) + + # contributors must be a list + @property + def contributors(self): + """Return the contributors of the project.""" + contr = self._get_property(self._get_key("contributors")) + if contr is None: + return [] + return contr if isinstance(contr, list) else [contr] + + @contributors.setter + def contributors(self, contributors: List[Person]) -> None: + """Set the contributors of the project.""" + contr = [self._from_person(c) for c in contributors] + self._set_property(self._get_key("contributors"), contr) + + # no maintainers supported im POM, only developers and contributors + @property + def maintainers(self): + """Return the maintainers of the project.""" + return [] + + @maintainers.setter + def maintainers(self, maintainers: List[Person]) -> None: + """Set the maintainers of the project.""" + pass + + # only one project license supported in somesy (POM can have many) + @property + def license(self) -> Optional[str]: + """Return the license of the project.""" + lic = self._get_property(self._get_key("license"), only_first=True) + return lic.get("name") if lic is not None else None + + @license.setter + def license(self, license: Optional[str]) -> None: + """Set the license of the project.""" + self._set_property( + self._get_key("license"), dict(name=license, distribution="repo") + ) + + @property + def repository(self) -> Optional[Union[str, dict]]: + """Return the repository url of the project.""" + repo = super().repository + if isinstance(repo, str): + return repo + return repo.get("url") if repo is not None else None + + @repository.setter + def repository(self, value: Optional[Union[str, dict]]) -> None: + """Set the repository url of the project.""" + self._set_property( + self._get_key("repository"), dict(name="git repository", url=value) + ) + + @property + def documentation(self) -> Optional[Union[str, dict]]: + """Return the documentation url of the project.""" + docs = super().documentation + if isinstance(docs, str): + return docs + return docs.get("url") if docs is not None else None + + @documentation.setter + def documentation(self, value: Optional[Union[str, dict]]) -> None: + """Set the documentation url of the project.""" + self._set_property( + self._get_key("documentation"), dict(name="documentation site", url=value) + ) + + def sync(self, metadata) -> None: + """Sync codemeta.json with project metadata. + + Use existing sync function from ProjectMetadataWriter but update repository and contributors. + """ + super().sync(metadata) + self.contributors = self._sync_person_list(self.contributors, metadata.people) diff --git a/src/somesy/pom_xml/xmlproxy.py b/src/somesy/pom_xml/xmlproxy.py new file mode 100644 index 00000000..7b2586a4 --- /dev/null +++ b/src/somesy/pom_xml/xmlproxy.py @@ -0,0 +1,407 @@ +"""Wrapper to provide dict-like access to XML via ElementTree.""" +from __future__ import annotations + +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, List, Optional, Union, cast + +import defusedxml.ElementTree as DET + +# shallow type hint mostly for documentation purpose +JSONLike = Any + + +def load_xml(path: Path) -> ET.ElementTree: + """Parse an XML file into an ElementTree, preserving comments.""" + path = path if isinstance(path, Path) else Path(path) + parser = DET.XMLParser(target=ET.TreeBuilder(insert_comments=True)) + return DET.parse(path, parser=parser) + + +def indent(elem, level=0): + """Indent the elements of this XML node (i.e. pretty print).""" + i = "\n" + level * " " + if len(elem): + if not elem.text or not elem.text.strip(): + elem.text = i + " " + if not elem.tail or not elem.tail.strip(): + elem.tail = i + for el in elem: + indent(el, level + 1) + if not el.tail or not el.tail.strip(): + el.tail = i + else: + if level and (not elem.tail or not elem.tail.strip()): + elem.tail = i + + +class XMLProxy: + """Class providing dict-like access to edit XML via ElementTree. + + Note that this wrapper facade is limited to a restricted (but useful) subset of XML: + * XML attributes are not supported + * DTDs are ignored (arbitrary keys can be queried and added) + * each tag is assumed to EITHER contain text OR more nested tags + * lists are treated atomically (no way to add/remove element from a collection) + + The semantics is implemented as follows: + + * If there are multiple tags with the same name, a list of XMLProxy nodes is returned + * If a unique tag does have no nested tags, its `text` string value is returned + * Otherwise, the node is returned + """ + + def _wrap(self, el: ET.Element) -> XMLProxy: + """Wrap a different element, inheriting the same namespace.""" + return XMLProxy(el, default_namespace=self._def_ns) + + def _dump(self): + """Dump XML to stdout (for debugging).""" + ET.dump(self._node) + + def _qualified_key(self, key: str): + """If passed key is not qualified, prepends the default namespace (if set).""" + if key[0] == "{" or not self._def_ns: + return key + return "{" + self._def_ns + "}" + key + + def _shortened_key(self, key: str): + """Inverse of `_qualified_key` (strips default namespace from element name).""" + if key[0] != "{" or not self._def_ns or key.find(self._def_ns) < 0: + return key + return key[key.find("}") + 1 :] + + # ---- + + def __init__(self, el: ET.Element, *, default_namespace: Optional[str] = None): + """Wrap an existing XML ElementTree Element.""" + self._node: ET.Element = el + self._def_ns = default_namespace + + @classmethod + def parse(cls, path: Union[str, Path], **kwargs) -> XMLProxy: + """Parse an XML file into a wrapped ElementTree, preserving comments.""" + path = path if isinstance(path, Path) else Path(path) + return cls(load_xml(path).getroot(), **kwargs) + + def write(self, path: Union[str, Path], *, header: bool = True, **kwargs): + """Write the XML DOM to an UTF-8 encoded file.""" + path = path if isinstance(path, Path) else Path(path) + et = ET.ElementTree(self._node) + if self._def_ns and "default_namespace" not in kwargs: + kwargs["default_namespace"] = self._def_ns + indent(et.getroot()) + et.write(path, encoding="UTF-8", xml_declaration=header, **kwargs) + + def __repr__(self): + """See `object.__repr__`.""" + return str(self._node) + + def __len__(self): + """Return number of inner tags inside current XML element. + + Note that bool(node) thus checks whether an XML node is a leaf in the element tree. + """ + return len(self._node) + + def __iter__(self): + """Iterate the nested elements in-order.""" + return map(self._wrap, iter(self._node)) + + @property + def namespace(self) -> Optional[str]: + """Default namespace of this node.""" + return self._def_ns + + @property + def is_comment(self): + """Return whether the current element node is an XML comment.""" + return not isinstance(self._node.tag, str) + + @property + def tag(self) -> Optional[str]: + """Return tag name of this element (unless it is a comment).""" + if self.is_comment: + return None + return self._shortened_key(self._node.tag) + + @tag.setter + def tag(self, val: str): + """Set the tag of this element.""" + if self.is_comment: + raise ValueError("Cannot set tag name for comment element!") + self._node.tag = self._qualified_key(val) + + # ---- helpers ---- + + def to_jsonlike( + self, + *, + strip_default_ns: bool = True, + keep_root: bool = False, + ) -> JSONLike: + """Convert XML node to a JSON-like primitive, array or dict (ignoring attributes). + + Note that all leaf values are strings (i.e. not parsed to bool/int/float etc.). + + Args: + strip_default_ns: Do not qualify keys from the default namespace + keep_root: If true, the root tag name will be preserved (`{"root_tag": {...}}`) + """ + if not len(self): # leaf -> assume it's a primitive value + return self._node.text or "" + + dct = {} + ccnt = 0 + for elem in iter(self): + raw = elem._node + if not isinstance(raw.tag, str): + ccnt += 1 + key = f"__comment_{ccnt}__" + else: + key = raw.tag if not strip_default_ns else self._shortened_key(raw.tag) + + curr_val = elem.to_jsonlike(strip_default_ns=strip_default_ns) + if key not in dct: + dct[key] = curr_val + continue + val = dct[key] + if not isinstance(val, list): + dct[key] = [dct[key]] + dct[key].append(curr_val) + + return dct if not keep_root else {self._shortened_key(self._node.tag): dct} + + @classmethod + def _from_jsonlike_primitive( + cls, val, *, elem_name: Optional[str] = None, **kwargs + ) -> Union[str, XMLProxy]: + """Convert a leaf node into a string value (i.e. return inner text). + + Returns a string (or an XML element, if elem_name is passed). + """ + if val is None: + ret = "" # turn None into empty string + elif isinstance(val, str): + ret = val + elif isinstance(val, bool): + ret = str(val).lower() # True -> true / False -> false + elif isinstance(val, (int, float)): + ret = str(val) + else: + raise TypeError( + f"Value of type {type(val)} is not JSON-like primitive: {val}" + ) + + if not elem_name: + return ret + else: # return the value wrapped as an element (needed in from_jsonlike) + elem = ET.Element(elem_name) + elem.text = ret + return cls(elem, **kwargs) + + @classmethod + def from_jsonlike( + cls, val: JSONLike, *, root_name: Optional[str] = None, **kwargs: Any + ): + """Convert a JSON-like primitive, array or dict into an XML element. + + Note that booleans are serialized as `true`/`false` and None as `null`. + + Args: + val: Value to convert into an XML element. + root_name: If `val` is a dict, defines the tag name for the root element. + kwargs: Additional arguments for XML element instantiation. + """ + if isinstance(val, list): + return list( + map(lambda x: cls.from_jsonlike(x, root_name=root_name, **kwargs), val) + ) + if not isinstance(val, dict): # primitive val + return cls._from_jsonlike_primitive(val, elem_name=root_name, **kwargs) + + # now the dict case remains + elem = ET.Element(root_name or "root") + for k, v in val.items(): + if k.startswith( + "__comment_" + ): # special key names are mapped to XML comments + elem.append(ET.Comment(v if isinstance(v, str) else str(v))) + + elif isinstance(v, list): + for vv in XMLProxy.from_jsonlike(v, root_name=k, **kwargs): + elem.append(vv._node) + elif not isinstance(v, dict): # primitive val + # FIXME: use better case-splitting for type of function to avoid cast + tmp = cast( + XMLProxy, + XMLProxy._from_jsonlike_primitive(v, elem_name=k, **kwargs), + ) + elem.append(tmp._node) + else: # dict + elem.append(XMLProxy.from_jsonlike(v, root_name=k)._node) + + return cls(elem, **kwargs) + + # ---- dict-like access ---- + + def get(self, key: str, *, as_nodes: bool = False, deep: bool = False): + """Get sub-structure(s) of value(s) matching desired XML tag name. + + * If there are multiple matching elements, will return them all as a list. + * If there is a single matching element, will return that element without a list. + + Args: + key: tag name to retrieve + as_nodes: If true, will *always* return a list of (zero or more) XML nodes + deep: Expand nested XML elements instead of returning them as XML nodes + """ + # NOTE: could allow to retrieve comments when using empty string/none as key? + + if as_nodes and deep: + raise ValueError("as_nodes and deep are mutually exclusive!") + if not key: + raise ValueError("Key must not be an empty string!") + key = self._qualified_key(key) + + # if not fully qualified + default NS is given, use it for query + lst = self._node.findall(key) + ns: List[XMLProxy] = list(map(self._wrap, lst)) + if as_nodes: # return it as a list of xml nodes + return ns + if not ns: # no element + return None + + ret = ns if not deep else [x.to_jsonlike() for x in ns] + if len(ret) == 1: + return ret[0] # single element + else: + return ret + + def __getitem__(self, key: str): + """Acts like `dict.__getitem__`, implemented with `get`.""" + val = self.get(key) + if val is not None: + return val + else: + raise KeyError(key) + + def __contains__(self, key: str) -> bool: + """Acts like `dict.__contains__`, implemented with `get`.""" + return self.get(key) is not None + + def __delitem__(self, key: Union[str, XMLProxy]): + """Delete a nested XML element with matching key name. + + Note that **all** XML elements with the given tag name are removed! + + To prevent this behavior, instead of a string tag name you can provide the + exact element to be removed, i.e. if a node `node_a` represents the following XML: + + ``` + + 1 + 2 + 3 + + ``` + + Then we have that: + + * `del node_a["b"]` removes **both** tags, leaving just the `c` tag. + * `del node_a[node_a["a"][1]]` removes just the second tag with the `3`. + """ + if isinstance(key, str): + nodes = self.get(key, as_nodes=True) + else: + nodes = [key] if key._node in self._node else [] + + if not nodes: + raise KeyError(key) + + if self._node.text is not None: + self._node.text = "" + for child in nodes: + self._node.remove(child._node) + + def _clear(self): + """Remove contents of this XML element (e.g. for overwriting in-place).""" + self._node.text = "" + children = list(iter(self._node)) # need to store, removal invalidates iterator + for child in children: + self._node.remove(child) + + def __setitem__(self, key: Union[str, XMLProxy], val: Union[JSONLike, XMLProxy]): + """Add or overwrite an inner XML tag. + + If there is exactly one matching tag, the value is substituted in-place. + If the passed value is a list, all list entries are added in their own element. + + If there are multiple existing matches or target values, then + **all** existing elements are removed and the new value(s) are added in + new element(s) (i.e. coming after other unrelated existing elements)! + + To prevent this behavior, instead of a string tag name you can provide the + exact element to be overwritten, i.e. if a node `node_a` represents the following XML: + + ``` + + 1 + 2 + 3 + + ``` + + Then we have that: + + * `node_a["b"] = 5` removes both existing tags and creates a new tag with the passed value(s). + * `node_a[node_a["b"][1]] = 5` replaces the `3` in the second tag with the `5`. + + Note that the passed value must be either an XML element already, or be a pure JSON-like object. + """ + if isinstance(key, str): + nodes = self.get(key, as_nodes=True) + # delete all existing elements if multiple exist or are passed + if len(nodes) > 1 or (len(nodes) and isinstance(val, list)): + del self[key] + nodes = [] + # now we can assume there's zero or one suitable target elements + if nodes: # if it is one, clear it out + nodes[0]._clear() + else: # an XMLProxy object was passed as key -> try to use that + if isinstance(val, list): + raise ValueError( + "Cannot overwrite a single element with a list of values!" + ) + # ensure the target node is cleared out and use it as target + key._clear() + nodes = [key] + key = key.tag + + # ensure key string is qualified with a namespace + key_name: str = self._qualified_key(key) + + # normalize passed value(s) to be list (general case) + vals = val if isinstance(val, list) else [val] + + # ensure there is the required number of target element nodes + for _ in range(len(vals) - len(nodes)): + nodes.append(self._wrap(ET.SubElement(self._node, key_name))) + + # normalize values no XML element nodes + nvals = [] + for val in vals: + # ensure value is represented as an XML node + if isinstance(val, XMLProxy): + obj = self._wrap(ET.Element("dummy")) + obj._node.append(val._node) + else: + obj = self.from_jsonlike(val, root_name=key_name) + + nvals.append(obj) + + for node, val in zip(nodes, nvals): + # transplant node contents into existing element (so it is inserted in-place) + node._node.text = val._node.text + for child in iter(val): + node._node.append(child._node) diff --git a/src/somesy/pyproject/writer.py b/src/somesy/pyproject/writer.py index abfe148e..742e46f3 100644 --- a/src/somesy/pyproject/writer.py +++ b/src/somesy/pyproject/writer.py @@ -56,12 +56,12 @@ def save(self, path: Optional[Path] = None) -> None: tomlkit.dump(self._data, f) def _get_property( - self, key: Union[str, List[str]], *, remove: bool = False + self, key: Union[str, List[str]], *, remove: bool = False, **kwargs ) -> Optional[Any]: """Get a property from the pyproject.toml file.""" key_path = [key] if isinstance(key, str) else key full_path = self._section + key_path - return super()._get_property(full_path, remove=remove) + return super()._get_property(full_path, remove=remove, **kwargs) def _set_property(self, key: Union[str, List[str], IgnoreKey], value: Any) -> None: """Set a property in the pyproject.toml file.""" diff --git a/tests/conftest.py b/tests/conftest.py index 65cb237d..364ce112 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,12 @@ from somesy.pyproject import Pyproject from somesy.julia import Julia from somesy.fortran import Fortran +from somesy.pom_xml.writer import POM + +TEST_DIR = Path(__file__).resolve().parent + +TEST_DATA_DIR = TEST_DIR / "data" +"""Location of the test input data.""" class FileTypes(Enum): @@ -21,6 +27,7 @@ class FileTypes(Enum): PACKAGE_JSON = "package_json" JULIA = "julia" FORTRAN = "fortran" + POM_XML = "pom_xml" @pytest.fixture(scope="session", autouse=True) @@ -76,6 +83,8 @@ def _create_files(files: Set[Tuple[FileTypes, str]]): read_file_name = read_file_path / Path("Project.toml") elif file_type == FileTypes.FORTRAN: read_file_name = read_file_path / Path("fpm.toml") + elif file_type == FileTypes.POM_XML: + read_file_name = read_file_path / Path("pom.xml") with open(read_file_name, "r") as f: content = f.read() @@ -105,7 +114,7 @@ def _load_files(files: Set[FileTypes]): if not isinstance(file_type, FileTypes): raise ValueError(f"Invalid file type: {file_type}") - read_file_name = Path("tests/data") + read_file_name = TEST_DATA_DIR if file_type == FileTypes.CITATION: read_file_name = read_file_name / Path("CITATION.cff") file_instances[file_type] = CFF(read_file_name) @@ -127,6 +136,9 @@ def _load_files(files: Set[FileTypes]): elif file_type == FileTypes.FORTRAN: read_file_name = read_file_name / Path("fpm.toml") file_instances[file_type] = Fortran(read_file_name) + elif file_type == FileTypes.POM_XML: + read_file_name = read_file_name / Path("pom.xml") + file_instances[file_type] = POM(read_file_name) return file_instances @@ -135,6 +147,7 @@ def _load_files(files: Set[FileTypes]): @pytest.fixture def person() -> Person: + """Return example person.""" p = { "given-names": "Jane", "email": "j.doe@example.com", @@ -144,3 +157,13 @@ def person() -> Person: ret = Person.model_validate(p) ret.set_key_order(list(p.keys())) # custom order! return ret + + +@pytest.fixture +def xml_examples(): + """Return path for an xml example file in test directory, based on file name.""" + + def _xml_loader(filename: str) -> Path: + return TEST_DATA_DIR / filename + + yield _xml_loader diff --git a/tests/data/blank_pom.xml b/tests/data/blank_pom.xml new file mode 100644 index 00000000..c8ac1aa9 --- /dev/null +++ b/tests/data/blank_pom.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tests/data/example_1.xml b/tests/data/example_1.xml new file mode 100644 index 00000000..20f7dce0 --- /dev/null +++ b/tests/data/example_1.xml @@ -0,0 +1,17 @@ + + + + foo + + true + + 42 + + + x + y + + z + + 3.14 + diff --git a/tests/data/example_pom.xml b/tests/data/example_pom.xml new file mode 100644 index 00000000..17af66a3 --- /dev/null +++ b/tests/data/example_pom.xml @@ -0,0 +1,37 @@ + + + ACME Tools + acme-tools + The ACME tools. + https://github.com/lampepfl/dotty + 0.1.0 + + semver-spec + + + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0 + repo + + + + https://github.com/lampepfl/dotty + scm:git:git@github.com:lampepfl/dotty.git + + + + odersky + Martin Odersky + https://github.com/odersky + martin.odersky@epfl.ch + + + DarkDimius + Dmitry Petrashko + https://d-d.me + me@d-d.me + + + diff --git a/tests/data/pom.xml b/tests/data/pom.xml new file mode 100644 index 00000000..de4a9731 --- /dev/null +++ b/tests/data/pom.xml @@ -0,0 +1,40 @@ + + + test-package + test-package + This is a test package for demonstration purposes. + https://github.com/lampepfl/dotty + 0.1.0 + + semver-spec + + + + + MIT + repo + + + Apache-2.0 + repo + + + + https://github.com/lampepfl/dotty + scm:git:git@github.com:lampepfl/dotty.git + + + + jane + Jane Doe + https://github.com/janedoe + jane.doe@acme.com + + maintenance + code + test + review + + + + diff --git a/tests/output/test_codemeta.py b/tests/output/test_codemeta.py index 882d3964..2f95d933 100644 --- a/tests/output/test_codemeta.py +++ b/tests/output/test_codemeta.py @@ -1,11 +1,11 @@ -from somesy.codemeta import Codemeta +from somesy.codemeta import CodeMeta from somesy.json_wrapper import json def test_update_codemeta(somesy_input, tmp_path): codemeta_file = tmp_path / "codemeta.json" - cm = Codemeta(codemeta_file) + cm = CodeMeta(codemeta_file) # firstly, create a codemeta file cm.sync(somesy_input.project) diff --git a/tests/output/test_pom_writer.py b/tests/output/test_pom_writer.py new file mode 100644 index 00000000..2b0ed98d --- /dev/null +++ b/tests/output/test_pom_writer.py @@ -0,0 +1,127 @@ +import pytest + +from somesy.pom_xml.writer import POM +from somesy.core.models import LicenseEnum, Person, ProjectMetadata + + +@pytest.fixture +def pom(load_files, file_types): + files = load_files([file_types.POM_XML]) + return files[file_types.POM_XML] + + +@pytest.fixture +def pom_file(create_files, file_types): + return create_files([(file_types.POM_XML, "pom.xml")]) / "pom.xml" + + +def test_content_match(pom: POM): + assert pom.name == "test-package" + assert pom.description == "This is a test package for demonstration purposes." + assert pom.license == "MIT" + assert len(pom.authors) == 1 + + +def test_sync(pom: POM, somesy_input: dict): + pom.sync(somesy_input.project) + assert pom.name == "testproject" + assert pom.version == "1.0.0" + + +def test_save(tmp_path): + # test save with default path + file_path = tmp_path / "pom.xml" + pom = POM(file_path, create_if_not_exists=True) + pom.save() + assert file_path.is_file() + + # test save with custom path + custom_path = tmp_path / "custom.xml" + pom.save(custom_path) + assert custom_path.is_file() + + +def test_from_to_person(person: Person): + p1 = POM._from_person(person) + assert p1["name"] == person.full_name + assert p1["email"] == person.email + assert p1["url"] == str(person.orcid) + + p = POM._to_person(POM._from_person(person)) + assert p.full_name == person.full_name + assert p.email == person.email + assert str(p.orcid) == str(person.orcid) + + +def test_person_merge(pom_file, person: Person): + pj = POM(pom_file) + + pm = ProjectMetadata( + name="My awesome project", + description="Project description", + license=LicenseEnum.MIT, + version="0.1.0", + people=[person.model_copy(update=dict(author=True, publication_author=True))], + ) + pj.sync(pm) + pj.save() + + # jane becomes john -> modified person + person1b = person.model_copy( + update={"given_names": "John", "author": True, "publication_author": True} + ) + + # different Jane Doe with different orcid -> new person + person2 = person.model_copy( + update={ + "orcid": "https://orcid.org/4321-0987-3231", + "email": "i.am.jane@doe.com", + "author": True, + "publication_author": True, + } + ) + # use different order, just for some difference + person2.set_key_order(["given_names", "orcid", "family_names", "email"]) + + # listed in "arbitrary" order in somesy metadata (new person comes first) + pm.people = [person2, person1b] # need to assign like that to keep _key_order + pj.sync(pm) + pj.save() + + # existing author order preserved + assert pj.authors[0]["name"] == person1b.full_name + assert pj.authors[0]["email"] == person1b.email + + assert pj.contributors[1]["name"] == person2.full_name + assert pj.contributors[1]["email"] == person2.email + + # new person + person3 = Person( + **{ + "given_names": "Janice", + "family_names": "Doethan", + "email": "jane93@gmail.com", + "author": True, + "publication_author": True, + } + ) + # john has a new email address + person1c = person1b.model_copy(update={"email": "john.of.us@qualityland.com"}) + # jane 2 is removed from authors, but added to maintainers + person2.author = False + person2.publication_author = False + person2.maintainer = True + # reflect in project metadata + pm.people = [person3, person2, person1c] + # sync to CFF file + pj.sync(pm) + pj.save() + + assert len(pj.contributors) == 3 + assert len(pj.maintainers) == 0 + assert pj.authors[0]["name"] == person1c.full_name + assert pj.authors[0]["email"] == person1c.email + print(pj.contributors) + + assert pj.contributors[2]["name"] == person3.full_name + assert pj.contributors[2]["email"] == person3.email diff --git a/tests/processing/test_xmlwrapper.py b/tests/processing/test_xmlwrapper.py new file mode 100644 index 00000000..d41c5c56 --- /dev/null +++ b/tests/processing/test_xmlwrapper.py @@ -0,0 +1,168 @@ +from somesy.pom_xml import POM_URL +from somesy.pom_xml.xmlproxy import XMLProxy + +import pytest + + +def test_parse_write(tmp_path, xml_examples): + """Make sure that header + namespace of XML are set correctly and comments are preserved.""" + file_src = xml_examples("blank_pom.xml") + file_trg = tmp_path / "written.xml" + + prx = XMLProxy.parse(file_src, default_namespace=POM_URL) + + # the root + assert repr(prx).startswith( + " returns list + lst = prx["listEntry"] + assert len(lst) == 2 + assert lst[0].to_jsonlike() == "foo" + assert lst[1].to_jsonlike() == {"someDict": {"a": "x", "b": "y"}, "someValue": "z"} + + expected = dict( + nothing="", + emptyString="", + listEntry=["foo", {"someDict": {"a": "x", "b": "y"}, "someValue": "z"}], + __comment_1__=" comment 1 ", + aBool="true", + __comment_2__=" comment 2 ", + anInt="42", + aFloat="3.14", + ) + dct = prx.to_jsonlike() + assert dct == expected + assert list(dct.keys()) == list(expected.keys()) # key order correct + + +def test_del_set_items(xml_examples): + prx = XMLProxy.parse(xml_examples("example_1.xml"), default_namespace=POM_URL) + + # remove existing elements + del prx["emptyString"] + del prx["listEntry"][1]["someDict"]["b"] + + with pytest.raises(KeyError): + del prx["emptyString"] # already removed + with pytest.raises(KeyError): + del prx["invalid"] # not existing + + # add new elements + prx["newValue"] = 5 + assert prx["newValue"].to_jsonlike() == "5" + dct = dict(some="hello", keys="world") + prx["newObject"] = dct + assert prx["newObject"].to_jsonlike() == dct + + # overwrite existing elements + prx["anInt"] = 123 # primitive to primitive + assert prx["anInt"].to_jsonlike() == "123" + prx["newValue"] = dict(x=1, y=2) # primitive to complex + assert prx["newValue"].to_jsonlike() == dict(x="1", y="2") + prx["newValue"] = None # complex to primitive + assert prx["newValue"].to_jsonlike() == "" + prx["listEntry"][1]["someDict"]["a"] = "qux" # overwrite nested + assert prx["listEntry"][1]["someDict"]["a"].to_jsonlike() == "qux" + + # overwrite specific if there are many hits + prx[prx["listEntry"][0]] = "xyz" + assert prx["listEntry"][0].to_jsonlike() == "xyz" + prx[prx["listEntry"][0]] = dict(x="1", y="2") + assert prx["listEntry"][0].to_jsonlike() == dict(x="1", y="2") + # overwrite all elements if there are many hits + prx["listEntry"] = 321 + assert prx["listEntry"].to_jsonlike() == "321" + # overwrite all elements if there are many target values + prx["listEntry"] = [123, 456] + assert prx.get("listEntry", deep=True) == ["123", "456"] + + # elements still in right order + exp_keys = ["nothing", None, "aBool", None, "anInt", "aFloat"] + exp_keys += ["newValue", "newObject", "listEntry", "listEntry"] + keys = [x.tag for x in iter(prx)] + assert keys == exp_keys + + +def test_update_write(xml_examples, tmp_path): + prx = XMLProxy.parse(xml_examples("example_pom.xml"), default_namespace=POM_URL) + + prx["version"] = "0.2.0" + prx["licenses"]["license"] = "MIT" + prx["developers"]["developer"][1]["name"] = "John Doe" + prx["newKey"] = 42 + externalKey = "{https://something.com}externalKey" + prx[externalKey] = "value" + + tmp_file = tmp_path / "written.xml" + prx.write(tmp_file) + + # check that the comment is retained + content = tmp_file.read_text() + assert content.find("some comment") > 0 + # check that the changes persisted + prs = XMLProxy.parse(tmp_file, default_namespace=POM_URL) + assert prs["developers"]["developer"][1]["name"].to_jsonlike() == "John Doe" + assert prs["version"].to_jsonlike() == "0.2.0" + assert "newKey" in prs + # check that the namespace of the external key is preserved + assert "externalKey" not in prs + assert externalKey in prs