Coverage for src/somesy/package_json/models.py: 90%
70 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 11:35 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 11:35 +0000
1"""package.json validation models."""
3import re
4from logging import getLogger
5from typing import Annotated
7from pydantic import BaseModel, EmailStr, Field, field_validator
9from somesy.core.types import HttpUrlStr
11logger = getLogger("somesy")
14class PackageAuthor(BaseModel):
15 """Package author model."""
17 name: Annotated[str | None, Field(description="Author name")]
18 email: Annotated[EmailStr | None, Field(description="Author email")] = None
19 url: Annotated[
20 HttpUrlStr | None, Field(description="Author website or orcid page")
21 ] = None
24class PackageRepository(BaseModel):
25 """Package repository model."""
27 type: Annotated[str | None, Field(description="Repository type")] = None
28 url: Annotated[str, Field(description="Repository url")]
31class PackageLicense(BaseModel):
32 """Package license model."""
34 type: Annotated[str | None, Field(description="License type")] = None
35 url: Annotated[str, Field(description="License url")]
38NPM_PKG_AUTHOR = r"^(.*?)\s*(?:<([^>]+)>)?\s*(?:\(([^)]+)\))?$"
39NPM_PKG_NAME = r"^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$"
40NPM_PKG_VERSION = 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-]+)*))?$"
43class PackageJsonConfig(BaseModel):
44 """Package.json config model."""
46 model_config = {"populate_by_name": True}
48 name: Annotated[str, Field(description="Package name")]
49 version: Annotated[str, Field(description="Package version")]
50 description: Annotated[str | None, Field(description="Package description")] = None
51 author: Annotated[
52 str | PackageAuthor | None, Field(description="Package author")
53 ] = None
54 maintainers: Annotated[
55 list[str | PackageAuthor] | None,
56 Field(description="Package maintainers"),
57 ] = None
58 contributors: Annotated[
59 list[str | PackageAuthor] | None,
60 Field(description="Package contributors"),
61 ] = None
62 license: Annotated[
63 str | PackageLicense | None, Field(description="Package license")
64 ] = None
65 repository: Annotated[
66 PackageRepository | str | None, Field(description="Package repository")
67 ] = None
68 homepage: Annotated[HttpUrlStr | None, Field(description="Package homepage")] = None
69 keywords: Annotated[
70 list[str] | None, Field(description="Keywords that describe the package")
71 ] = None
73 # convert package author to dict if it is a string
74 @classmethod
75 def convert_author(cls, author: str) -> PackageAuthor:
76 """Convert author string to PackageAuthor model."""
77 # parse author string to "name <email> (url)" format with regex
78 author_match = re.match(NPM_PKG_AUTHOR, author)
79 if not author_match:
80 raise ValueError(f"Invalid author format: {author}")
81 author_name = author_match[1]
82 author_email = author_match[2]
83 author_url = author_match[3]
85 return PackageAuthor.model_validate(
86 {"name": author_name, "email": author_email, "url": author_url}
87 )
89 @field_validator("name")
90 @classmethod
91 def validate_name(cls, v):
92 """Validate package name."""
93 if re.match(NPM_PKG_NAME, v) is None:
94 raise ValueError("Invalid name")
96 return v
98 @field_validator("version")
99 @classmethod
100 def validate_version(cls, v):
101 """Validate package version."""
102 if re.match(NPM_PKG_VERSION, v) is None:
103 raise ValueError("Invalid version")
104 return v
106 @field_validator("author")
107 @classmethod
108 def validate_author(cls, v):
109 """Validate package author."""
110 return cls.convert_author(v) if isinstance(v, str) else v
112 @field_validator("maintainers", "contributors")
113 @classmethod
114 def validate_people(cls, v):
115 """Validate package maintainers and contributors."""
116 people = []
117 for p in v:
118 if isinstance(p, str):
119 author = cls.convert_author(p)
120 if author is not None:
121 people.append(cls.convert_author(p))
122 else:
123 logger.warning(
124 f"Invalid email format for maintainer/contributor {p}, omitting."
125 )
126 elif p.email is not None:
127 people.append(p)
128 else:
129 logger.warning(
130 f"Invalid email format for maintainer/contributor {p}, omitting."
131 )
132 return people