3131 download_file_with_size_limit ,
3232 html_is_js_challenge ,
3333 send_get_http_raw ,
34+ send_head_http_raw ,
3435 stream_file_with_size_limit ,
3536)
3637
@@ -474,6 +475,33 @@ def extract_attestation(attestation_data: dict) -> dict | None:
474475 return attestations [0 ]
475476
476477
478+ # as per https://github.com/pypi/inspector/blob/main/inspector/main.py line 125
479+ INSPECTOR_TEMPLATE = (
480+ "{inspector_url_scheme}://{inspector_url_netloc}/project/"
481+ "{name}/{version}/packages/{first}/{second}/{rest}/{filename}"
482+ )
483+
484+
485+ @dataclass
486+ class PyPIInspectorAsset :
487+ """The package PyPI inspector information."""
488+
489+ #: the pypi inspector link to the tarball
490+ package_sdist_link : str
491+
492+ #: the pypi inspector link(s) to the wheel(s)
493+ package_whl_links : list [str ]
494+
495+ #: a mapping of inspector links to whether they are reachable
496+ package_link_reachability : dict [str , bool ]
497+
498+ def __bool__ (self ) -> bool :
499+ """Determine if this inspector object is empty."""
500+ if (self .package_sdist_link or self .package_whl_links ) and self .package_link_reachability :
501+ return True
502+ return False
503+
504+
477505@dataclass
478506class PyPIPackageJsonAsset :
479507 """The package JSON hosted on the PyPI registry."""
@@ -496,6 +524,9 @@ class PyPIPackageJsonAsset:
496524 #: the source code temporary location name
497525 package_sourcecode_path : str
498526
527+ #: the pypi inspector information about this package
528+ inspector_asset : PyPIInspectorAsset
529+
499530 #: The size of the asset (in bytes). This attribute is added to match the AssetLocator
500531 #: protocol and is not used because pypi API registry does not provide it.
501532 @property
@@ -762,6 +793,91 @@ def get_sha256(self) -> str | None:
762793 logger .debug ("Found sha256 hash: %s" , artifact_hash )
763794 return artifact_hash
764795
796+ def get_inspector_links (self ) -> bool :
797+ """Generate PyPI inspector links for this package version's distributions and fill in the inspector asset.
798+
799+ Returns
800+ -------
801+ bool
802+ True if the link generation was successful, False otherwise.
803+ """
804+ if self .inspector_asset :
805+ return True
806+
807+ if not self .package_json and not self .download ("" ):
808+ logger .warning ("No package metadata available, cannot get links" )
809+ return False
810+
811+ releases = self .get_releases ()
812+ if releases is None :
813+ logger .warning ("Package has no releases, cannot create inspector links." )
814+ return False
815+
816+ version = self .component_version
817+ if self .component_version is None :
818+ version = self .get_latest_version ()
819+
820+ if version is None :
821+ logger .warning ("No version set, and no latest version exists. cannot create inspector links." )
822+ return False
823+
824+ distributions = json_extract (releases , [version ], list )
825+
826+ if not distributions :
827+ logger .warning (
828+ "Package has no distributions for release version %s. Cannot create inspector links." , version
829+ )
830+ return False
831+
832+ for distribution in distributions :
833+ package_type = json_extract (distribution , ["packagetype" ], str )
834+ if package_type is None :
835+ logger .warning ("The version %s has no 'package type' field in a distribution" , version )
836+ continue
837+
838+ name = json_extract (self .package_json , ["info" , "name" ], str )
839+ if name is None :
840+ logger .warning ("The version %s has no 'name' field in a distribution" , version )
841+ continue
842+
843+ blake2b_256 = json_extract (distribution , ["digests" , "blake2b_256" ], str )
844+ if blake2b_256 is None :
845+ logger .warning ("The version %s has no 'blake2b_256' field in a distribution" , version )
846+ continue
847+
848+ filename = json_extract (distribution , ["filename" ], str )
849+ if filename is None :
850+ logger .warning ("The version %s has no 'filename' field in a distribution" , version )
851+ continue
852+
853+ link = INSPECTOR_TEMPLATE .format (
854+ inspector_url_scheme = self .pypi_registry .inspector_url_scheme ,
855+ inspector_url_netloc = self .pypi_registry .inspector_url_netloc ,
856+ name = name ,
857+ version = version ,
858+ first = blake2b_256 [0 :2 ],
859+ second = blake2b_256 [2 :4 ],
860+ rest = blake2b_256 [4 :],
861+ filename = filename ,
862+ )
863+
864+ # use a head request because we don't care about the response contents
865+ reachable = False
866+ if send_head_http_raw (link ):
867+ reachable = True # link was reachable
868+
869+ if package_type == "sdist" :
870+ self .inspector_asset .package_sdist_link = link
871+ self .inspector_asset .package_link_reachability [link ] = reachable
872+ elif package_type == "bdist_wheel" :
873+ self .inspector_asset .package_whl_links .append (link )
874+ self .inspector_asset .package_link_reachability [link ] = reachable
875+ else : # no other package types exist, so else statement should never occur
876+ logger .debug ("Unknown package distribution type: %s" , package_type )
877+
878+ # if all distributions were invalid and went along a 'continue' path
879+ return bool (self .inspector_asset )
880+
765881
766882def find_or_create_pypi_asset (
767883 asset_name : str , asset_version : str | None , pypi_registry_info : PackageRegistryInfo
@@ -799,6 +915,8 @@ def find_or_create_pypi_asset(
799915 logger .debug ("Failed to create PyPIPackageJson asset." )
800916 return None
801917
802- asset = PyPIPackageJsonAsset (asset_name , asset_version , False , package_registry , {}, "" )
918+ asset = PyPIPackageJsonAsset (
919+ asset_name , asset_version , False , package_registry , {}, "" , PyPIInspectorAsset ("" , [], {})
920+ )
803921 pypi_registry_info .metadata .append (asset )
804922 return asset
0 commit comments