Skip to content

Commit 2b50cd9

Browse files
authored
Merge pull request #33 from Materials-Data-Science-and-Informatics/feature/packagejson_support
package.json import and export
2 parents 3708d0d + 309e138 commit 2b50cd9

21 files changed

Lines changed: 658 additions & 54 deletions

.somesy.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ no_sync_pyproject = false
4949
pyproject_file = "pyproject.toml"
5050
no_sync_codemeta = false
5151
codemeta_file = "codemeta.json"
52+
sync_package_json = false
5253
show_info = false
5354
verbose = false
5455
debug = false

src/somesy/cli/init.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ def config():
4242
if pyproject_file is not None or pyproject_file != "":
4343
options["pyproject_file"] = pyproject_file
4444

45+
options["sync_package_json"] = typer.confirm(
46+
"Do you want to sync to a package.json file?", default=False
47+
)
48+
package_json_file = typer.prompt("package.json file path", default="package.json")
49+
if package_json_file is not None or package_json_file != "":
50+
options["package_json_file"] = package_json_file
51+
4552
options["no_sync_codemeta"] = not typer.confirm(
4653
"Do you want to sync to a codemeta.json file?", default=True
4754
)

src/somesy/cli/sync.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,24 @@ def sync(
6969
resolve_path=True,
7070
help="Existing pyproject.toml file path (default: pyproject.toml)",
7171
),
72+
sync_package_json: bool = typer.Option(
73+
None,
74+
"--sync-package-json",
75+
"-J",
76+
help="Do not sync package.json file (default: False)",
77+
),
78+
package_json_file: Path = typer.Option(
79+
None,
80+
"--package-json-file",
81+
"-j",
82+
exists=True,
83+
file_okay=True,
84+
dir_okay=False,
85+
writable=True,
86+
readable=True,
87+
resolve_path=True,
88+
help="Existing package.json file path (default: package.json)",
89+
),
7290
no_sync_codemeta: bool = typer.Option(
7391
None,
7492
"--no-sync-codemeta",
@@ -99,6 +117,8 @@ def sync(
99117
cff_file=cff_file,
100118
no_sync_pyproject=no_sync_pyproject,
101119
pyproject_file=pyproject_file,
120+
sync_package_json=sync_package_json,
121+
package_json_file=package_json_file,
102122
no_sync_codemeta=no_sync_codemeta,
103123
codemeta_file=codemeta_file,
104124
).items()
@@ -135,6 +155,10 @@ def run_sync(somesy_input: SomesyInput):
135155
logger.info(
136156
f" - [italic]pyproject.toml[/italic]:\t[grey]{conf.pyproject_file}[/grey]"
137157
)
158+
if conf.sync_package_json:
159+
logger.info(
160+
f" - [italic]package.json[/italic]:\t[grey]{conf.package_json_file}[/grey]"
161+
)
138162
if not conf.no_sync_cff:
139163
logger.info(f" - [italic]CITATION.cff[/italic]:\t[grey]{conf.cff_file}[/grey]")
140164
if not conf.no_sync_codemeta:

src/somesy/codemeta/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ def collect_cm_sources(conf: SomesyConfig):
2424
cm_sources.append(conf.pyproject_file)
2525
# NOTE: we don't add CFF directly, because it must be handled separately
2626
# NOTE: add other suitable somesy targets / codemeta sources (except CFF and codemeta) here
27+
if (
28+
conf.sync_package_json
29+
and conf.package_json_file is not None
30+
and conf.package_json_file.is_file()
31+
):
32+
cm_sources.append(conf.package_json_file)
2733
return cm_sources
2834

2935

src/somesy/commands/sync.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from somesy.cff.writer import CFF
88
from somesy.codemeta import update_codemeta
99
from somesy.core.models import ProjectMetadata, SomesyConfig, SomesyInput
10+
from somesy.package_json.writer import PackageJSON
1011
from somesy.pyproject.writer import Pyproject
1112

1213
logger = logging.getLogger("somesy")
@@ -23,6 +24,9 @@ def sync(somesy_input: SomesyInput):
2324
if not conf.no_sync_pyproject:
2425
_sync_python(metadata, conf.pyproject_file)
2526

27+
if conf.sync_package_json:
28+
_sync_package_json(metadata, conf.package_json_file)
29+
2630
if not conf.no_sync_cff:
2731
_sync_cff(metadata, conf.cff_file)
2832

@@ -67,6 +71,24 @@ def _sync_cff(
6771
logger.verbose("Saved synced CITATION.cff file.\n")
6872

6973

74+
def _sync_package_json(
75+
metadata: ProjectMetadata,
76+
package_json_file: Path,
77+
):
78+
"""Sync package.json file using project metadata.
79+
80+
Args:
81+
metadata (ProjectMetadata): project metadata to sync pyproject.toml file.
82+
package_json_file (Path, optional): package.json file path if wanted to be synced. Defaults to None.
83+
"""
84+
logger.verbose("Loading package.json file.")
85+
package_json = PackageJSON(package_json_file)
86+
logger.verbose("Syncing package.json file.")
87+
package_json.sync(metadata)
88+
package_json.save()
89+
logger.verbose("Saved synced package.json file.\n")
90+
91+
7092
def _sync_codemeta(conf: SomesyConfig):
7193
logger.verbose("Updating codemeta.json file.")
7294
if update_codemeta(conf):

src/somesy/core/core.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Core somesy functions."""
2+
import json
23
import logging
34
from pathlib import Path
45
from typing import Any, Dict, Optional
@@ -7,7 +8,7 @@
78

89
logger = logging.getLogger("somesy")
910

10-
INPUT_FILES_ORDERED = [".somesy.toml", "somesy.toml", "pyproject.toml"]
11+
INPUT_FILES_ORDERED = [".somesy.toml", "somesy.toml", "pyproject.toml", "package.json"]
1112
"""Input files ordered by priority for discovery."""
1213

1314

@@ -80,7 +81,13 @@ def get_input_content(path: Path, *, no_unwrap: bool = False) -> Dict[str, Any]:
8081
"No tool.somesy section found in pyproject.toml file!"
8182
)
8283

83-
# NOTE: if we support somesy config in package.json, it would go here.
84+
if path.suffix == ".json" and "package" in path.name:
85+
with open(path, "r") as f:
86+
input_content = json.load(f)
87+
if "somesy" in input_content:
88+
return input_content["somesy"]
89+
else:
90+
raise RuntimeError("No somesy section found in package.json file!")
8491

8592
# no match:
8693
raise ValueError("Unsupported input file.")

src/somesy/core/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,11 @@ def at_least_one_target(cls, values):
170170
Path("pyproject.toml"), description="pyproject.toml file path."
171171
)
172172

173+
sync_package_json: bool = Field(False, description="Sync with package.json.")
174+
package_json_file: Path = Field(
175+
Path("package.json"), description="package.json file path."
176+
)
177+
173178
no_sync_codemeta: bool = Field(False, description="Do not sync with codemeta.json.")
174179
codemeta_file: Path = Field(
175180
Path("codemeta.json"), description="codemeta.json file path."

src/somesy/core/writer.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,10 @@ def sync(self, metadata: ProjectMetadata) -> None:
200200
)
201201

202202
self.license = metadata.license.value
203-
self.homepage = str(metadata.homepage)
204-
self.repository = str(metadata.repository)
203+
if metadata.homepage:
204+
self.homepage = str(metadata.homepage)
205+
if metadata.repository:
206+
self.repository = str(metadata.repository)
205207

206208
@staticmethod
207209
@abstractmethod
@@ -307,11 +309,11 @@ def homepage(self, homepage: Optional[str]) -> None:
307309
self._set_property(self._get_key("homepage"), homepage)
308310

309311
@property
310-
def repository(self) -> Optional[str]:
312+
def repository(self) -> Optional[Union[str, dict]]:
311313
"""Return the repository url of the project."""
312314
return self._get_property(self._get_key("repository"))
313315

314316
@repository.setter
315-
def repository(self, repository: Optional[str]) -> None:
317+
def repository(self, repository: Optional[Union[str, dict]]) -> None:
316318
"""Set the repository url of the project."""
317319
self._set_property(self._get_key("repository"), repository)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""PackageJSON module."""
2+
from .writer import PackageJSON
3+
4+
__all__ = ["PackageJSON"]

src/somesy/package_json/models.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""package.json validation models."""
2+
import re
3+
from typing import List, Optional, Union
4+
5+
from pydantic import AnyUrl, BaseModel, EmailStr, ValidationError, validator
6+
7+
8+
class PackageAuthor(BaseModel):
9+
"""Package author model."""
10+
11+
name: Optional[str]
12+
email: Optional[EmailStr]
13+
url: Optional[AnyUrl]
14+
15+
16+
class PackageRepository(BaseModel):
17+
"""Package repository model."""
18+
19+
type: str
20+
url: Optional[str]
21+
22+
23+
class PackageLicense(BaseModel):
24+
"""Package license model."""
25+
26+
type: str
27+
url: Optional[str]
28+
29+
30+
class PackageJsonConfig(BaseModel):
31+
"""Package.json config model."""
32+
33+
name: str
34+
version: str
35+
description: Optional[str]
36+
author: Optional[Union[str, PackageAuthor]]
37+
maintainers: Optional[List[Union[str, PackageAuthor]]]
38+
contributors: Optional[List[Union[str, PackageAuthor]]]
39+
license: Optional[Union[str, PackageLicense]]
40+
repository: Optional[PackageRepository]
41+
homepage: Optional[AnyUrl]
42+
keywords: Optional[List[str]]
43+
44+
# convert package author to dict if it is a string
45+
@classmethod
46+
def convert_author(cls, author: str) -> PackageAuthor:
47+
"""Convert author string to PackageAuthor model."""
48+
# parse author string to "name <email> (url)" format with regex
49+
author_regex = r"^(.*?)\s*(?:<([^>]+)>)?\s*(?:\(([^)]+)\))?$"
50+
author_match = re.match(author_regex, author)
51+
if not author_match:
52+
raise ValidationError(f"Invalid author format: {author}")
53+
author_name = author_match[1]
54+
author_email = author_match[2]
55+
author_url = author_match[3]
56+
57+
return PackageAuthor(name=author_name, email=author_email, url=author_url)
58+
59+
@validator("name")
60+
def validate_name(cls, v):
61+
"""Validate package name."""
62+
pattern = r"^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$"
63+
if re.match(pattern, v) is None:
64+
raise ValidationError("Invalid name")
65+
66+
return v
67+
68+
@validator("version")
69+
def validate_version(cls, v):
70+
"""Validate package version."""
71+
# pattern for npm version
72+
pattern = r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
73+
if re.match(pattern, v) is None:
74+
raise ValidationError("Invalid version")
75+
return v
76+
77+
@validator("author")
78+
def validate_author(cls, v):
79+
"""Validate package author."""
80+
return cls.convert_author(v) if isinstance(v, str) else v
81+
82+
@validator("maintainers", "contributors")
83+
def validate_people(cls, v):
84+
"""Validate package maintainers and contributors."""
85+
people = []
86+
for p in v:
87+
if isinstance(p, str):
88+
people.append(cls.convert_author(p))
89+
else:
90+
people.append(p)
91+
return people
92+
93+
class Config:
94+
"""Pydantic config."""
95+
96+
allow_population_by_field_name = True

0 commit comments

Comments
 (0)