Skip to content

Commit b4a0b59

Browse files
committed
add initial rust module
1 parent 6cf00b4 commit b4a0b59

4 files changed

Lines changed: 275 additions & 0 deletions

File tree

src/somesy/rust/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Rust module."""
2+
from .writer import Rust
3+
4+
__all__ = ["Rust"]

src/somesy/rust/models.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Pyproject models."""
2+
import re
3+
from pathlib import Path
4+
from typing import Dict, List, Optional, Set, Union
5+
6+
from packaging.version import parse as parse_version
7+
from pydantic import BaseModel, Field, field_validator, model_validator
8+
from typing_extensions import Annotated
9+
10+
from somesy.core.types import HttpUrlStr
11+
12+
13+
class RustConfig(BaseModel):
14+
"""Rust configuration model."""
15+
16+
model_config = dict(use_enum_values=True)
17+
18+
name: Annotated[
19+
str,
20+
Field(
21+
pattern=r"^[A-Za-z0-9]+([_-][A-Za-z0-9]+)*$",
22+
max_length=64,
23+
description="Package name",
24+
),
25+
]
26+
version: Annotated[
27+
str,
28+
Field(
29+
pattern=r"^\d+(\.\d+)*((a|b|rc)\d+)?(post\d+)?(dev\d+)?$",
30+
description="Package version",
31+
),
32+
]
33+
description: Annotated[
34+
Optional[str], Field(description="Package description")
35+
] = None
36+
license: Annotated[
37+
Optional[str],
38+
Field(
39+
description="A combination SPDX license identifiers with AND, OR and so on."
40+
),
41+
] = None
42+
authors: Annotated[Set[str], Field(description="Package authors")]
43+
maintainers: Annotated[
44+
Optional[Set[str]], Field(description="Package maintainers")
45+
] = None
46+
readme: Annotated[
47+
Optional[Union[Path, List[Path]]], Field(description="Package readme file(s)")
48+
] = None
49+
license_file: Annotated[
50+
Optional[Path], Field(description="Package license file")
51+
] = None
52+
homepage: Annotated[
53+
Optional[HttpUrlStr], Field(description="Package homepage")
54+
] = None
55+
repository: Annotated[
56+
Optional[HttpUrlStr], Field(description="Package repository")
57+
] = None
58+
documentation: Annotated[
59+
Optional[HttpUrlStr], Field(description="Package documentation page")
60+
] = None
61+
keywords: Annotated[
62+
Optional[Set[str]], Field(description="Keywords that describe the package")
63+
] = None
64+
classifiers: Annotated[
65+
Optional[List[str]], Field(description="pypi classifiers")
66+
] = None
67+
urls: Annotated[
68+
Optional[Dict[str, HttpUrlStr]], Field(description="Package URLs")
69+
] = None
70+
71+
@model_validator(mode="before")
72+
@classmethod
73+
def license_or_file(cls, values):
74+
"""License and license file are mutually exclusive."""
75+
if values["license"] and values["license_file"]:
76+
raise ValueError("license and license_file are mutually exclusive")
77+
return values
78+
79+
@field_validator("version")
80+
@classmethod
81+
def validate_version(cls, v):
82+
"""Validate version using PEP 440."""
83+
try:
84+
_ = parse_version(v)
85+
except ValueError as err:
86+
raise ValueError("Invalid version") from err
87+
return v
88+
89+
@field_validator("readme", "license_file")
90+
@classmethod
91+
def validate_readme(cls, v):
92+
"""Validate readme file(s) by checking whether files exist."""
93+
if isinstance(v, list):
94+
if any(not e.is_file() for e in v):
95+
raise ValueError("Some file(s) do not exist")
96+
else:
97+
if not v.is_file():
98+
raise ValueError("File does not exist")
99+
100+
@field_validator("keywords")
101+
@classmethod
102+
def check_keywords_field(cls, v):
103+
"""Check the keywords field."""
104+
if v is None:
105+
return v
106+
107+
# Check if number of keywords is at most 5
108+
if v is not None and len(v) > 5:
109+
raise ValueError("A maximum of 5 keywords is allowed")
110+
111+
for keyword in v:
112+
check_keyword(keyword)
113+
114+
return v
115+
116+
117+
def check_keyword(keyword: str):
118+
"""Check if keyword is valid."""
119+
# Check if keyword is ASCII and has at most 20 characters
120+
if not keyword.isascii() or len(keyword) > 20:
121+
raise ValueError(
122+
"Each keyword must be ASCII text and have at most 20 characters"
123+
)
124+
125+
# Check if keyword starts with an alphanumeric character
126+
if not re.match(r"^[a-zA-Z0-9]", keyword):
127+
raise ValueError("Each keyword must start with an alphanumeric character")
128+
129+
# Check if keyword contains only allowed characters
130+
if not re.match(r"^[a-zA-Z0-9_\-+]+$", keyword):
131+
raise ValueError("Keywords can only contain letters, numbers, _, -, or +")

src/somesy/rust/writer.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Pyproject writers for setuptools and rust."""
2+
import logging
3+
from pathlib import Path
4+
from typing import Any, List, Optional, Union
5+
6+
from rich.pretty import pretty_repr
7+
from tomlkit import dump, load, table
8+
9+
from somesy.core.models import Person, ProjectMetadata
10+
from somesy.core.writer import IgnoreKey, ProjectMetadataWriter
11+
12+
from .models import RustConfig, check_keyword
13+
14+
logger = logging.getLogger("somesy")
15+
16+
17+
class Rust(ProjectMetadataWriter):
18+
"""Rust config file handler parsed from Cargo.toml."""
19+
20+
def __init__(self, path: Path, *, direct_mappings=None):
21+
"""Rust config file handler parsed from Cargo.toml.
22+
23+
See [somesy.core.writer.ProjectMetadataWriter.__init__][].
24+
"""
25+
self._section = ["package"]
26+
super().__init__(
27+
path, create_if_not_exists=False, direct_mappings=direct_mappings or {}
28+
)
29+
30+
def _load(self) -> None:
31+
"""Load Cargo.toml file."""
32+
with open(self.path) as f:
33+
self._data = load(f)
34+
35+
def _validate(self) -> None:
36+
"""Validate rust config using pydantic class.
37+
38+
In order to preserve toml comments and structure, tomlkit library is used.
39+
Pydantic class only used for validation.
40+
"""
41+
config = dict(self._get_property([]))
42+
logger.debug(
43+
f"Validating config using {RustConfig.__name__}: {pretty_repr(config)}"
44+
)
45+
RustConfig(**config)
46+
47+
def save(self, path: Optional[Path] = None) -> None:
48+
"""Save the Cargo.toml file."""
49+
path = path or self.path
50+
with open(path, "w") as f:
51+
dump(self._data, f)
52+
53+
def _get_property(
54+
self, key: Union[str, List[str]], *, remove: bool = False, **kwargs
55+
) -> Optional[Any]:
56+
"""Get a property from the Cargo.toml file."""
57+
key_path = [key] if isinstance(key, str) else key
58+
full_path = self._section + key_path
59+
return super()._get_property(full_path, remove=remove, **kwargs)
60+
61+
def _set_property(self, key: Union[str, List[str], IgnoreKey], value: Any) -> None:
62+
"""Set a property in the Cargo.toml file."""
63+
if isinstance(key, IgnoreKey):
64+
return
65+
key_path = [key] if isinstance(key, str) else key
66+
67+
if not value: # remove value and clean up the sub-dict
68+
self._get_property(key_path, remove=True)
69+
return
70+
71+
# get the tomlkit object of the section
72+
dat = self._get_property([])
73+
74+
# dig down, create missing nested objects on the fly
75+
curr = dat
76+
for key in key_path[:-1]:
77+
if key not in curr:
78+
curr.add(key, table())
79+
curr = curr[key]
80+
curr[key_path[-1]] = value
81+
82+
@staticmethod
83+
def _from_person(person: Person):
84+
"""Convert project metadata person object to rust string for person format "full name <email>."""
85+
return person.to_name_email_string()
86+
87+
@staticmethod
88+
def _to_person(person_obj: str) -> Person:
89+
"""Parse rust person string to a Person. It has format "full name <email>." but email is optional."""
90+
try:
91+
return Person.from_name_email_string(person_obj)
92+
except ValueError:
93+
return None
94+
95+
@classmethod
96+
def _parse_people(cls, people: Optional[List[Any]]) -> List[Person]:
97+
"""Return a list of Persons parsed from list of format-specific people representations. to_person can return None, so filter out None values."""
98+
return list(filter(None, map(cls._to_person, people or [])))
99+
100+
@property
101+
def keywords(self) -> Optional[List[str]]:
102+
"""Return the keywords of the project."""
103+
return self._get_property(self._get_key("keywords"))
104+
105+
@keywords.setter
106+
def keywords(self, keywords: List[str]) -> None:
107+
"""Set the keywords of the project."""
108+
validated_keywords = []
109+
for keyword in keywords:
110+
try:
111+
check_keyword(keyword)
112+
validated_keywords.append(keyword)
113+
except ValueError as e:
114+
logger.debug(f"Invalid keyword {keyword}: {e}")
115+
116+
# keyword count should max 5, so delete the rest
117+
if len(validated_keywords) > 5:
118+
validated_keywords = validated_keywords[:5]
119+
self._set_property(self._get_key("keywords"), validated_keywords)
120+
121+
def sync(self, metadata: ProjectMetadata) -> None:
122+
"""Sync the rust config with the project metadata."""
123+
super().sync(metadata)
124+
125+
# if there is a license file, remove the license field
126+
if self._get_key("license_file"):
127+
self.license = None

tests/data/Cargo.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "test-package"
3+
version = "0.1.0"
4+
description = "This is a test package for demonstration purposes."
5+
authors = ["John Doe <john.doe@example.com>"]
6+
license = "MIT"
7+
repository = "https://github.com/example/test-package"
8+
homepage = "https://example.com/test-package"
9+
documentation = "https://example.com/test-package"
10+
keywords = ["test", "demo", "example"]
11+
edition = "2021"
12+
13+
[dependencies]

0 commit comments

Comments
 (0)