1010import shutil
1111import sys
1212from collections import defaultdict
13+ from dataclasses import KW_ONLY , dataclass , field
1314from importlib .resources import files
1415from pathlib import Path
1516from textwrap import dedent
@@ -54,14 +55,19 @@ def append(self, obj: Exception | None) -> None:
5455)
5556
5657
57- class LinkChecker :
58- """Track known links and validate URLs."""
58+ @dataclass
59+ class HTTPValidator [E = str ]:
60+ """Validate HTTP URLs."""
61+
62+ client : httpx .Client
63+ _ : KW_ONLY
64+ validated : set [E ] = field (default_factory = set )
5965
60- def __init__ (self , client : httpx .Client ) -> None :
61- self .known_links : set [str ] = set ()
62- self .client = client
6366
64- def check_and_register (self , url : str , context : str ) -> None | ValidationError :
67+ class LinkChecker (HTTPValidator ):
68+ """Track known links and validate URLs."""
69+
70+ def __call__ (self , url : str , context : str ) -> None | ValidationError :
6571 """Check if URL is duplicate, validate it exists, and register it.
6672
6773 Parameters
@@ -77,7 +83,7 @@ def check_and_register(self, url: str, context: str) -> None | ValidationError:
7783 f"Please use the default version in ReadTheDocs URLs instead of { m ['version' ]!r} :\n { url } \n ->\n { new_url } "
7884 )
7985 return ValidationError (msg )
80- if url in self .known_links :
86+ if url in self .validated :
8187 msg = f"{ context } : Duplicate link: { url } "
8288 return ValidationError (msg )
8389
@@ -91,19 +97,17 @@ def check_and_register(self, url: str, context: str) -> None | ValidationError:
9197 msg = f"URL { url } is not reachable (error { response .status_code } ). "
9298 return ValidationError (msg )
9399
94- self .known_links .add (url )
100+ self .validated .add (url )
95101 return None
96102
97103
98- class GitHubUserValidator :
104+ @dataclass
105+ class GitHubUserValidator (HTTPValidator ):
99106 """Validate GitHub usernames using the GitHub API."""
100107
101- def __init__ (self , client : httpx .Client , github_token : str | None = None ) -> None :
102- self .client = client
103- self .github_token = github_token
104- self .validated_users : set [str ] = set ()
108+ github_token : str | None = None
105109
106- def validate_usernames (self , usernames : Sequence [str ], context : str ) -> None | ValidationError :
110+ def __call__ (self , usernames : Sequence [str ], context : str ) -> None | ValidationError :
107111 """Validate that a GitHub username exists.
108112
109113 Parameters
@@ -114,7 +118,7 @@ def validate_usernames(self, usernames: Sequence[str], context: str) -> None | V
114118 Context information for error messages (e.g., file being validated)
115119 """
116120
117- if not (unvalidated := list (set (usernames ) - self .validated_users )):
121+ if not (unvalidated := list (set (usernames ) - self .validated )):
118122 return None
119123
120124 headers = {}
@@ -141,19 +145,16 @@ def validate_usernames(self, usernames: Sequence[str], context: str) -> None | V
141145 msg = f"{ context } : Failed to validate GitHub users { unvalidated !r} :\n { error_msgs } "
142146 return ValidationError (msg )
143147
144- self .validated_users |= set (unvalidated )
148+ self .validated |= set (unvalidated )
145149 log .info (f"Validated GitHub users: { unvalidated !r} " )
146150 return None
147151
148152
149- class PyPIValidator :
153+ @dataclass
154+ class PyPIValidator (HTTPValidator ):
150155 """Validate PyPI package names against the PyPI API."""
151156
152- def __init__ (self , client : httpx .Client ) -> None :
153- self .client = client
154- self .validated_packages : set [str ] = set ()
155-
156- def validate_package (self , package_name : str , context : str ) -> None | ValidationError :
157+ def __call__ (self , package_name : str , context : str ) -> None | ValidationError :
157158 """Validate that a PyPI package exists.
158159
159160 Parameters
@@ -163,7 +164,7 @@ def validate_package(self, package_name: str, context: str) -> None | Validation
163164 context
164165 Context information for error messages (e.g., file being validated)
165166 """
166- if package_name in self .validated_packages :
167+ if package_name in self .validated :
167168 return None
168169
169170 try :
@@ -179,19 +180,16 @@ def validate_package(self, package_name: str, context: str) -> None | Validation
179180 msg = f"{ context } : Failed to validate PyPI package { package_name !r} (error { response .status_code } )"
180181 return ValidationError (msg )
181182
182- self .validated_packages .add (package_name )
183+ self .validated .add (package_name )
183184 log .info (f"Validated PyPI package: { package_name } " )
184185 return None
185186
186187
187- class CondaValidator :
188+ @dataclass
189+ class CondaValidator (HTTPValidator ):
188190 """Validate Conda package identifiers using the Anaconda API."""
189191
190- def __init__ (self , client : httpx .Client ) -> None :
191- self .client = client
192- self .validated_packages : set [str ] = set ()
193-
194- def validate_package (self , package_spec : str , context : str ) -> None | ValidationError :
192+ def __call__ (self , package_spec : str , context : str ) -> None | ValidationError :
195193 """Validate that a Conda package exists.
196194
197195 Parameters
@@ -201,7 +199,7 @@ def validate_package(self, package_spec: str, context: str) -> None | Validation
201199 context
202200 Context information for error messages (e.g., file being validated)
203201 """
204- if package_spec in self .validated_packages :
202+ if package_spec in self .validated :
205203 return None
206204
207205 # Parse channel and package name
@@ -225,19 +223,16 @@ def validate_package(self, package_spec: str, context: str) -> None | Validation
225223 msg = f"{ context } : Failed to validate Conda package '{ package_spec } ' (error { response .status_code } )"
226224 return ValidationError (msg )
227225
228- self .validated_packages .add (package_spec )
226+ self .validated .add (package_spec )
229227 log .info (f"Validated Conda package: { package_spec } " )
230228 return None
231229
232230
233- class CRANValidator :
231+ @dataclass
232+ class CRANValidator (HTTPValidator ):
234233 """Validate CRAN package names using the CRAN API."""
235234
236- def __init__ (self , client : httpx .Client ) -> None :
237- self .client = client
238- self .validated_packages : set [str ] = set ()
239-
240- def validate_package (self , package_name : str , context : str ) -> None | ValidationError :
235+ def __call__ (self , package_name : str , context : str ) -> None | ValidationError :
241236 """Validate that a CRAN package exists.
242237
243238 Parameters
@@ -247,7 +242,7 @@ def validate_package(self, package_name: str, context: str) -> None | Validation
247242 context
248243 Context information for error messages (e.g., file being validated)
249244 """
250- if package_name in self .validated_packages :
245+ if package_name in self .validated :
251246 return None
252247
253248 # CRAN packages can be checked via the packages database
@@ -264,19 +259,16 @@ def validate_package(self, package_name: str, context: str) -> None | Validation
264259 msg = f"{ context } : Failed to validate CRAN package '{ package_name } ' (error { response .status_code } )"
265260 return ValidationError (msg )
266261
267- self .validated_packages .add (package_name )
262+ self .validated .add (package_name )
268263 log .info (f"Validated CRAN package: { package_name } " )
269264 return None
270265
271266
272- class BioconductorValidator :
267+ @dataclass
268+ class BioconductorValidator (HTTPValidator ):
273269 """Validate Bioconductor package names using the Bioconductor API."""
274270
275- def __init__ (self , client : httpx .Client ) -> None :
276- self .client = client
277- self .validated_packages : set [str ] = set ()
278-
279- def validate_package (self , package_name : str , context : str ) -> None | ValidationError :
271+ def __call__ (self , package_name : str , context : str ) -> None | ValidationError :
280272 """Validate that a Bioconductor package exists.
281273
282274 Parameters
@@ -286,7 +278,7 @@ def validate_package(self, package_name: str, context: str) -> None | Validation
286278 context
287279 Context information for error messages (e.g., file being validated)
288280 """
289- if package_name in self .validated_packages :
281+ if package_name in self .validated :
290282 return None
291283
292284 # Bioconductor packages can be checked via their web API
@@ -303,7 +295,7 @@ def validate_package(self, package_name: str, context: str) -> None | Validation
303295 msg = f"{ context } : Failed to validate Bioconductor package '{ package_name } ' (error { response .status_code } )"
304296 return ValidationError (msg )
305297
306- self .validated_packages .add (package_name )
298+ self .validated .add (package_name )
307299 log .info (f"Validated Bioconductor package: { package_name } " )
308300 return None
309301
@@ -341,15 +333,15 @@ def validate_packages( # noqa: C901
341333
342334 # using different link checkers,
343335 # because each of them may point to the same URL and this wouldn't qualify as duplicate
344- link_checker_home = LinkChecker (retry_client )
345- link_checker_docs = LinkChecker (retry_client )
346- link_checker_tutorials = LinkChecker (retry_client )
336+ check_home = LinkChecker (retry_client )
337+ check_docs = LinkChecker (retry_client )
338+ check_tutorial = LinkChecker (retry_client )
347339
348- github_validator = GitHubUserValidator (retry_client , github_token )
349- pypi_validator = PyPIValidator (retry_client )
350- conda_validator = CondaValidator (retry_client )
351- cran_validator = CRANValidator (retry_client )
352- bioconductor_validator = BioconductorValidator (retry_client )
340+ check_gh_users = GitHubUserValidator (retry_client , github_token )
341+ check_pypi = PyPIValidator (retry_client )
342+ check_conda = CondaValidator (retry_client )
343+ check_cran = CRANValidator (retry_client )
344+ check_bioc = BioconductorValidator (retry_client )
353345
354346 errors : defaultdict [str , ErrorList ] = defaultdict (ErrorList )
355347 package_metadata : list [ScverseEcosystemPackages ] = []
@@ -367,25 +359,25 @@ def validate_packages( # noqa: C901
367359 pkg_errors .append (e )
368360
369361 # Check and register all links
370- pkg_errors .append (link_checker_home . check_and_register (tmp_meta ["project_home" ], pkg_id ))
371- pkg_errors .append (link_checker_docs . check_and_register (tmp_meta ["documentation_home" ], pkg_id ))
362+ pkg_errors .append (check_home (tmp_meta ["project_home" ], pkg_id ))
363+ pkg_errors .append (check_docs (tmp_meta ["documentation_home" ], pkg_id ))
372364 if url := tmp_meta .get ("tutorials_home" ):
373- pkg_errors .append (link_checker_tutorials . check_and_register (url , pkg_id ))
365+ pkg_errors .append (check_tutorial (url , pkg_id ))
374366
375367 # Validate GitHub usernames in contact field
376368 if usernames := tmp_meta .get ("contact" ):
377- pkg_errors .append (github_validator . validate_usernames (usernames , pkg_id ))
369+ pkg_errors .append (check_gh_users (usernames , pkg_id ))
378370
379371 # Validate install packages
380372 if install_info := tmp_meta .get ("install" ):
381373 if pypi_name := install_info .get ("pypi" ):
382- pkg_errors .append (pypi_validator . validate_package (pypi_name , pkg_id ))
374+ pkg_errors .append (check_pypi (pypi_name , pkg_id ))
383375 if conda_name := install_info .get ("conda" ):
384- pkg_errors .append (conda_validator . validate_package (conda_name , pkg_id ))
376+ pkg_errors .append (check_conda (conda_name , pkg_id ))
385377 if cran_name := install_info .get ("cran" ):
386- pkg_errors .append (cran_validator . validate_package (cran_name , pkg_id ))
378+ pkg_errors .append (check_cran (cran_name , pkg_id ))
387379 if bioconductor_name := install_info .get ("bioconductor" ):
388- pkg_errors .append (bioconductor_validator . validate_package (bioconductor_name , pkg_id ))
380+ pkg_errors .append (check_bioc (bioconductor_name , pkg_id ))
389381
390382 # Check logo (if available) and make path relative to root of registry
391383 if "logo" in tmp_meta :
0 commit comments