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