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

1"""package.json validation models.""" 

2 

3import re 

4from logging import getLogger 

5from typing import Annotated 

6 

7from pydantic import BaseModel, EmailStr, Field, field_validator 

8 

9from somesy.core.types import HttpUrlStr 

10 

11logger = getLogger("somesy") 

12 

13 

14class PackageAuthor(BaseModel): 

15 """Package author model.""" 

16 

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 

22 

23 

24class PackageRepository(BaseModel): 

25 """Package repository model.""" 

26 

27 type: Annotated[str | None, Field(description="Repository type")] = None 

28 url: Annotated[str, Field(description="Repository url")] 

29 

30 

31class PackageLicense(BaseModel): 

32 """Package license model.""" 

33 

34 type: Annotated[str | None, Field(description="License type")] = None 

35 url: Annotated[str, Field(description="License url")] 

36 

37 

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-]+)*))?$" 

41 

42 

43class PackageJsonConfig(BaseModel): 

44 """Package.json config model.""" 

45 

46 model_config = {"populate_by_name": True} 

47 

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 

72 

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] 

84 

85 return PackageAuthor.model_validate( 

86 {"name": author_name, "email": author_email, "url": author_url} 

87 ) 

88 

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") 

95 

96 return v 

97 

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 

105 

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 

111 

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