Skip to content

Commit 4722dc3

Browse files
authored
Merge pull request #113 from Materials-Data-Science-and-Informatics/feature/packages_support
Feature/packages support
2 parents e7e0790 + 9ab65a8 commit 4722dc3

9 files changed

Lines changed: 432 additions & 23 deletions

File tree

.github/workflows/ci.yml

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,18 @@ jobs:
2121
runs-on: ubuntu-latest
2222

2323
steps:
24-
- uses: actions/checkout@v3
24+
- uses: actions/checkout@v4
2525
- name: Install poetry
2626
run: pipx install poetry
27-
- uses: actions/setup-python@v4
27+
- uses: actions/setup-python@v5
2828
with:
2929
python-version: "3.10"
3030

3131
- name: Install poe, pre-commit and safety
3232
run: pip install poethepoet pre-commit safety
3333

3434
# NOTE: using custom cache, to include pre-commit linters + deps
35-
- uses: actions/cache@v3
35+
- uses: actions/cache@v4
3636
with:
3737
path: |
3838
~/.cache/pre-commit
@@ -54,10 +54,10 @@ jobs:
5454
runs-on: ubuntu-latest
5555

5656
steps:
57-
- uses: actions/checkout@v3
57+
- uses: actions/checkout@v4
5858
- name: Install poetry
5959
run: pipx install poetry
60-
- uses: actions/setup-python@v4
60+
- uses: actions/setup-python@v5
6161
with:
6262
python-version: "3.10"
6363
cache: "poetry"
@@ -78,10 +78,10 @@ jobs:
7878
runs-on: ${{ matrix.os }}
7979

8080
steps:
81-
- uses: actions/checkout@v3
81+
- uses: actions/checkout@v4
8282
- name: Install poetry
8383
run: pipx install poetry
84-
- uses: actions/setup-python@v4
84+
- uses: actions/setup-python@v5
8585
with:
8686
python-version: ${{ matrix.python-version }}
8787
cache: "poetry"
@@ -100,10 +100,10 @@ jobs:
100100
contents: write
101101

102102
steps:
103-
- uses: actions/checkout@v3
103+
- uses: actions/checkout@v4
104104
- name: Install poetry
105105
run: pipx install poetry
106-
- uses: actions/setup-python@v4
106+
- uses: actions/setup-python@v5
107107
with:
108108
python-version: "3.10"
109109
cache: "poetry"

.github/workflows/release.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
permissions:
2828
contents: write # needed for creating a GH Release
2929
steps:
30-
- uses: actions/checkout@v3
30+
- uses: actions/checkout@v4
3131
- uses: softprops/action-gh-release@v1
3232

3333
pypi:
@@ -37,12 +37,12 @@ jobs:
3737
permissions:
3838
id-token: write # needed for "trusted publishing" protocol
3939
steps:
40-
- uses: actions/checkout@v3
40+
- uses: actions/checkout@v4
4141

4242
- name: Install poetry
4343
run: pipx install poetry
4444

45-
- uses: actions/setup-python@v4
45+
- uses: actions/setup-python@v5
4646
with:
4747
python-version: "3.10"
4848
cache: "poetry"

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Please consult the changelog to inform yourself about breaking changes and secur
99
- make validation of output files, such as pyproject.toml, optional
1010
- make somesy project metadata input `version` optional
1111
- multiple output file support
12+
- enable having packages support
13+
- fix: package.json url set error on None value
1214

1315
## [v0.6.0](https://github.com/Materials-Data-Science-and-Informatics/somesy/tree/v0.6.0) <small>(2025-02-14)</small> { id="0.6.0" }
1416

docs/manual.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,17 @@ The example above shows a project with a `package.json` file in the root folder
293293
!!! note
294294

295295
Let's remember `somesy` will create a `CITATION.CFF` and `codemeta.json` file in the root folder if it is set otherwise either by cli or by config.
296+
297+
#### Packages (sub-modules) with their own somesy file
298+
299+
A project with multiple packages inside can have completely different metadata such version and authors. Therefore, each package should have a separate metadata, in other words, separate `somesy` config and project metadata.
300+
301+
```toml
302+
[project]
303+
...
304+
305+
[config]
306+
packages = ['package1', 'package2', 'package3']
307+
```
308+
309+
Each of these packages (sub-folders) are assumed to have separate somesy metadata in the folder. These packages could be a `somesy` supported language/framework or it could be any other language. `somesy` will create at least `CITATION.CFF` and `codemeta.json` regardless if not instructed otherwise.

src/somesy/cli/sync.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,13 @@ def sync(
159159
"-P",
160160
help="Pass validation of metadata files (default: False)",
161161
),
162+
packages: Optional[List[Path]] = typer.Option(
163+
None,
164+
"--packages",
165+
"-k",
166+
help="Packages (subfolders) for monorepos with their own somesy config.",
167+
**existing_file_arg_config,
168+
),
162169
):
163170
"""Sync project metadata input with metadata files."""
164171
somesy_input = resolved_somesy_input(
@@ -183,7 +190,9 @@ def sync(
183190
rust_file=rust_file,
184191
merge_codemeta=merge_codemeta,
185192
pass_validation=pass_validation,
193+
packages=packages,
186194
)
195+
187196
run_sync(somesy_input)
188197

189198

src/somesy/commands/sync.py

Lines changed: 100 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
from somesy.cff.writer import CFF
1010
from somesy.codemeta import CodeMeta
11-
from somesy.core.models import ProjectMetadata, SomesyInput
11+
from somesy.core.core import INPUT_FILES_ORDERED
12+
from somesy.core.models import ProjectMetadata, SomesyConfig, SomesyInput
1213
from somesy.core.writer import ProjectMetadataWriter
1314
from somesy.fortran.writer import Fortran
1415
from somesy.julia.writer import Julia
@@ -40,23 +41,114 @@ def _sync_file(
4041
logger.verbose(f"Saved synced '{file.name}'.\n")
4142

4243

43-
def _sync_files(metadata, files, writer_class, **kwargs):
44+
def _sync_files(
45+
metadata, files, writer_class, create_if_missing: bool = False, **kwargs
46+
):
47+
"""Sync metadata to files using the provided writer.
48+
49+
Args:
50+
metadata: Project metadata to sync
51+
files: Path or list of paths to sync
52+
writer_class: Writer class to use
53+
create_if_missing: Whether to create the file if it doesn't exist
54+
**kwargs: Additional arguments passed to the writer
55+
56+
"""
4457
if isinstance(files, Path):
4558
files = [files]
4659
for file in files:
47-
if file.is_file():
60+
if file.is_file() or create_if_missing:
4861
_sync_file(metadata, file, writer_class, **kwargs)
4962

5063

51-
def sync(somesy_input: SomesyInput):
52-
"""Sync selected metadata files with given input file."""
64+
def sync(somesy_input: SomesyInput, is_package: bool = False):
65+
"""Sync selected metadata files with given input file.
66+
67+
Args:
68+
somesy_input: The input configuration and metadata to sync
69+
is_package: Whether this is a package (subfolder) being synced
70+
71+
"""
5372
conf, metadata = somesy_input.config, somesy_input.project
5473

74+
# Get the base directory from the input file's location
75+
try:
76+
base_dir = somesy_input._origin.parent
77+
except AttributeError:
78+
logger.warning(
79+
"No origin found for somesy input, using current working directory."
80+
)
81+
base_dir = Path.cwd()
82+
83+
# Resolve all paths in the config relative to the base directory
84+
conf.resolve_paths(base_dir)
85+
86+
if is_package:
87+
logger.info("\n[bold green]Synchronizing package metadata...[/bold green]")
88+
else:
89+
logger.info("\n[bold green]Synchronizing root project metadata...[/bold green]")
90+
5591
pp_metadata = pretty_repr(metadata.model_dump(exclude_defaults=True))
5692
logger.debug(f"Project metadata: {pp_metadata}")
5793

94+
# First sync the current project
95+
_sync_root_project(conf, metadata)
96+
97+
# Then sync each package if defined
98+
if conf.packages:
99+
packages = [conf.packages] if isinstance(conf.packages, Path) else conf.packages
100+
for package in packages:
101+
logger.info(f"\n[bold blue]Processing package {package}...[/bold blue]")
102+
103+
# Try all possible input files in order of priority
104+
config_files = [package / file for file in INPUT_FILES_ORDERED]
105+
package_input = None
106+
107+
for config_file in config_files:
108+
try:
109+
package_input = SomesyInput.from_input_file(config_file)
110+
logger.debug(f"Found config file: {config_file}")
111+
break
112+
except (FileNotFoundError, RuntimeError):
113+
continue
114+
115+
if package_input is None:
116+
logger.warning(
117+
f"No valid somesy config found in package {package} "
118+
f"(tried: {', '.join(str(f) for f in config_files)})"
119+
)
120+
continue
121+
122+
# Create new config with CLI options and package's input file
123+
cli_options = {
124+
"no_sync_pyproject": conf.no_sync_pyproject,
125+
"no_sync_package_json": conf.no_sync_package_json,
126+
"no_sync_julia": conf.no_sync_julia,
127+
"no_sync_fortran": conf.no_sync_fortran,
128+
"no_sync_pom_xml": conf.no_sync_pom_xml,
129+
"no_sync_mkdocs": conf.no_sync_mkdocs,
130+
"no_sync_rust": conf.no_sync_rust,
131+
"no_sync_cff": conf.no_sync_cff,
132+
"no_sync_codemeta": conf.no_sync_codemeta,
133+
"merge_codemeta": conf.merge_codemeta,
134+
"pass_validation": conf.pass_validation,
135+
"packages": None, # Don't pass packages to avoid recursive package handling
136+
}
137+
package_input.config = SomesyConfig(input_file=config_file, **cli_options)
138+
139+
# Set default CFF and CodeMeta paths in package directory if not specified
140+
if not package_input.config.no_sync_cff:
141+
package_input.config.cff_file = Path("CITATION.cff")
142+
if not package_input.config.no_sync_codemeta:
143+
package_input.config.codemeta_file = Path("codemeta.json")
144+
145+
# Recursively call sync on the package
146+
sync(package_input, is_package=True)
147+
148+
149+
def _sync_root_project(conf: SomesyConfig, metadata: ProjectMetadata):
150+
"""Sync metadata files for the root project."""
58151
# update these only if they exist:
59-
60152
if conf.pyproject_file and not conf.no_sync_pyproject:
61153
_sync_files(
62154
metadata,
@@ -119,6 +211,7 @@ def sync(somesy_input: SomesyInput):
119211
metadata,
120212
conf.cff_file,
121213
CFF,
214+
create_if_missing=True,
122215
pass_validation=conf.pass_validation,
123216
)
124217

@@ -127,6 +220,7 @@ def sync(somesy_input: SomesyInput):
127220
metadata,
128221
conf.codemeta_file,
129222
CodeMeta,
223+
create_if_missing=True,
130224
merge_codemeta=conf.merge_codemeta,
131225
pass_validation=conf.pass_validation,
132226
)

src/somesy/core/models.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ def at_least_one_target(cls, values):
173173
] = False
174174

175175
input_file: Annotated[
176-
Path, Field(description="Project metadata input file path.")
176+
Optional[Path], Field(description="Project metadata input file path.")
177177
] = Path("somesy.toml")
178178

179179
no_sync_pyproject: Annotated[
@@ -233,9 +233,9 @@ def at_least_one_target(cls, values):
233233
no_sync_codemeta: Annotated[
234234
bool, Field(description="Do not sync with codemeta.json.")
235235
] = False
236-
codemeta_file: Annotated[Path, Field(description="codemeta.json file path.")] = (
237-
Path("codemeta.json")
238-
)
236+
codemeta_file: Annotated[
237+
Union[Path, List[Path]], Field(description="codemeta.json file path.")
238+
] = Path("codemeta.json")
239239
merge_codemeta: Annotated[
240240
bool,
241241
Field(
@@ -249,6 +249,14 @@ def at_least_one_target(cls, values):
249249
Field(description="Pass validation for all output files."),
250250
] = False
251251

252+
# packages (sub-folders) for monorepos with their own somesy config
253+
packages: Annotated[
254+
Optional[Union[Path, List[Path]]],
255+
Field(
256+
description="Packages (sub-folders) for monorepos with their own somesy config."
257+
),
258+
] = None
259+
252260
def log_level(self) -> SomesyLogLevel:
253261
"""Return log level derived from this configuration."""
254262
return SomesyLogLevel.from_flags(
@@ -272,6 +280,37 @@ def get_input(self) -> SomesyInput:
272280
somesy_input.config = SomesyConfig(**dct)
273281
return somesy_input
274282

283+
def resolve_paths(self, base_dir: Path) -> None:
284+
"""Resolve all paths in the config relative to the given base directory.
285+
286+
Args:
287+
base_dir: The base directory to resolve paths against.
288+
289+
"""
290+
291+
def resolve_path(
292+
paths: Optional[Union[Path, List[Path]]],
293+
) -> Optional[Union[Path, List[Path]]]:
294+
if paths is None:
295+
return None
296+
if isinstance(paths, list):
297+
return [base_dir / p for p in paths]
298+
return base_dir / paths
299+
300+
# Resolve all file paths
301+
resolved_input = resolve_path(self.input_file)
302+
self.input_file = resolved_input if isinstance(resolved_input, Path) else None
303+
self.pyproject_file = resolve_path(self.pyproject_file)
304+
self.package_json_file = resolve_path(self.package_json_file)
305+
self.julia_file = resolve_path(self.julia_file)
306+
self.fortran_file = resolve_path(self.fortran_file)
307+
self.pom_xml_file = resolve_path(self.pom_xml_file)
308+
self.mkdocs_file = resolve_path(self.mkdocs_file)
309+
self.rust_file = resolve_path(self.rust_file)
310+
self.cff_file = resolve_path(self.cff_file)
311+
self.codemeta_file = resolve_path(self.codemeta_file)
312+
self.packages = resolve_path(self.packages)
313+
275314

276315
# --------
277316
# Project metadata model (modified from CITATION.cff)
@@ -735,6 +774,13 @@ class SomesyInput(SomesyBaseModel):
735774
),
736775
]
737776

777+
# if config.input_file is set, use it as origin
778+
@model_validator(mode="after")
779+
def set_origin(self):
780+
"""Set the origin of the input file."""
781+
if self.config and self.config.input_file:
782+
self._origin = self.config.input_file
783+
738784
def is_somesy_file(self) -> bool:
739785
"""Return whether this somesy input is from a somesy config file.
740786

src/somesy/package_json/writer.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,4 +202,7 @@ def repository(self) -> Optional[Union[str, Dict]]:
202202
@repository.setter
203203
def repository(self, value: Optional[Union[str, Dict]]) -> None:
204204
"""Set the repository url of the project."""
205-
self._set_property(self._get_key("repository"), dict(type="git", url=value))
205+
if value is None:
206+
self._set_property(self._get_key("repository"), None)
207+
else:
208+
self._set_property(self._get_key("repository"), dict(type="git", url=value))

0 commit comments

Comments
 (0)