1
+ from __future__ import annotations
2
+
1
3
import os
2
4
import re
3
5
import abc
26
28
from importlib import import_module
27
29
from importlib .abc import MetaPathFinder
28
30
from itertools import starmap
29
- from typing import Iterable , List , Mapping , Optional , Set , Union , cast
31
+ from typing import Any , Iterable , List , Mapping , Match , Optional , Set , cast
30
32
31
33
__all__ = [
32
34
'Distribution' ,
@@ -163,17 +165,17 @@ class EntryPoint:
163
165
value : str
164
166
group : str
165
167
166
- dist : Optional [' Distribution' ] = None
168
+ dist : Optional [Distribution ] = None
167
169
168
170
def __init__ (self , name : str , value : str , group : str ) -> None :
169
171
vars (self ).update (name = name , value = value , group = group )
170
172
171
- def load (self ):
173
+ def load (self ) -> Any :
172
174
"""Load the entry point from its definition. If only a module
173
175
is indicated by the value, return that module. Otherwise,
174
176
return the named object.
175
177
"""
176
- match = self .pattern .match (self .value )
178
+ match = cast ( Match , self .pattern .match (self .value ) )
177
179
module = import_module (match .group ('module' ))
178
180
attrs = filter (None , (match .group ('attr' ) or '' ).split ('.' ))
179
181
return functools .reduce (getattr , attrs , module )
@@ -268,7 +270,7 @@ def __repr__(self):
268
270
"""
269
271
return '%s(%r)' % (self .__class__ .__name__ , tuple (self ))
270
272
271
- def select (self , ** params ):
273
+ def select (self , ** params ) -> EntryPoints :
272
274
"""
273
275
Select entry points from self that match the
274
276
given parameters (typically group and/or name).
@@ -304,19 +306,17 @@ def _from_text(text):
304
306
class PackagePath (pathlib .PurePosixPath ):
305
307
"""A reference to a path in a package"""
306
308
307
- hash : Optional [" FileHash" ]
309
+ hash : Optional [FileHash ]
308
310
size : int
309
- dist : " Distribution"
311
+ dist : Distribution
310
312
311
313
def read_text (self , encoding : str = 'utf-8' ) -> str : # type: ignore[override]
312
- with self .locate ().open (encoding = encoding ) as stream :
313
- return stream .read ()
314
+ return self .locate ().read_text (encoding = encoding )
314
315
315
316
def read_binary (self ) -> bytes :
316
- with self .locate ().open ('rb' ) as stream :
317
- return stream .read ()
317
+ return self .locate ().read_bytes ()
318
318
319
- def locate (self ) -> pathlib . Path :
319
+ def locate (self ) -> SimplePath :
320
320
"""Return a path-like object for this path"""
321
321
return self .dist .locate_file (self )
322
322
@@ -330,6 +330,7 @@ def __repr__(self) -> str:
330
330
331
331
332
332
class DeprecatedNonAbstract :
333
+ # Required until Python 3.14
333
334
def __new__ (cls , * args , ** kwargs ):
334
335
all_names = {
335
336
name for subclass in inspect .getmro (cls ) for name in vars (subclass )
@@ -349,25 +350,48 @@ def __new__(cls, *args, **kwargs):
349
350
350
351
351
352
class Distribution (DeprecatedNonAbstract ):
352
- """A Python distribution package."""
353
+ """
354
+ An abstract Python distribution package.
355
+
356
+ Custom providers may derive from this class and define
357
+ the abstract methods to provide a concrete implementation
358
+ for their environment. Some providers may opt to override
359
+ the default implementation of some properties to bypass
360
+ the file-reading mechanism.
361
+ """
353
362
354
363
@abc .abstractmethod
355
364
def read_text (self , filename ) -> Optional [str ]:
356
365
"""Attempt to load metadata file given by the name.
357
366
367
+ Python distribution metadata is organized by blobs of text
368
+ typically represented as "files" in the metadata directory
369
+ (e.g. package-1.0.dist-info). These files include things
370
+ like:
371
+
372
+ - METADATA: The distribution metadata including fields
373
+ like Name and Version and Description.
374
+ - entry_points.txt: A series of entry points as defined in
375
+ `the entry points spec <https://packaging.python.org/en/latest/specifications/entry-points/#file-format>`_.
376
+ - RECORD: A record of files according to
377
+ `this recording spec <https://packaging.python.org/en/latest/specifications/recording-installed-packages/#the-record-file>`_.
378
+
379
+ A package may provide any set of files, including those
380
+ not listed here or none at all.
381
+
358
382
:param filename: The name of the file in the distribution info.
359
383
:return: The text if found, otherwise None.
360
384
"""
361
385
362
386
@abc .abstractmethod
363
- def locate_file (self , path : Union [ str , os .PathLike [str ]] ) -> pathlib . Path :
387
+ def locate_file (self , path : str | os .PathLike [str ]) -> SimplePath :
364
388
"""
365
- Given a path to a file in this distribution, return a path
389
+ Given a path to a file in this distribution, return a SimplePath
366
390
to it.
367
391
"""
368
392
369
393
@classmethod
370
- def from_name (cls , name : str ) -> " Distribution" :
394
+ def from_name (cls , name : str ) -> Distribution :
371
395
"""Return the Distribution for the given package name.
372
396
373
397
:param name: The name of the distribution package to search for.
@@ -385,16 +409,18 @@ def from_name(cls, name: str) -> "Distribution":
385
409
raise PackageNotFoundError (name )
386
410
387
411
@classmethod
388
- def discover (cls , ** kwargs ) -> Iterable ["Distribution" ]:
412
+ def discover (
413
+ cls , * , context : Optional [DistributionFinder .Context ] = None , ** kwargs
414
+ ) -> Iterable [Distribution ]:
389
415
"""Return an iterable of Distribution objects for all packages.
390
416
391
417
Pass a ``context`` or pass keyword arguments for constructing
392
418
a context.
393
419
394
420
:context: A ``DistributionFinder.Context`` object.
395
- :return: Iterable of Distribution objects for all packages.
421
+ :return: Iterable of Distribution objects for packages matching
422
+ the context.
396
423
"""
397
- context = kwargs .pop ('context' , None )
398
424
if context and kwargs :
399
425
raise ValueError ("cannot accept context and kwargs" )
400
426
context = context or DistributionFinder .Context (** kwargs )
@@ -403,8 +429,8 @@ def discover(cls, **kwargs) -> Iterable["Distribution"]:
403
429
)
404
430
405
431
@staticmethod
406
- def at (path : Union [ str , os .PathLike [str ]] ) -> " Distribution" :
407
- """Return a Distribution for the indicated metadata path
432
+ def at (path : str | os .PathLike [str ]) -> Distribution :
433
+ """Return a Distribution for the indicated metadata path.
408
434
409
435
:param path: a string or path-like object
410
436
:return: a concrete Distribution instance for the path
@@ -413,7 +439,7 @@ def at(path: Union[str, os.PathLike[str]]) -> "Distribution":
413
439
414
440
@staticmethod
415
441
def _discover_resolvers ():
416
- """Search the meta_path for resolvers."""
442
+ """Search the meta_path for resolvers (MetadataPathFinders) ."""
417
443
declared = (
418
444
getattr (finder , 'find_distributions' , None ) for finder in sys .meta_path
419
445
)
@@ -424,7 +450,11 @@ def metadata(self) -> _meta.PackageMetadata:
424
450
"""Return the parsed metadata for this Distribution.
425
451
426
452
The returned object will have keys that name the various bits of
427
- metadata. See PEP 566 for details.
453
+ metadata per the
454
+ `Core metadata specifications <https://packaging.python.org/en/latest/specifications/core-metadata/#core-metadata>`_.
455
+
456
+ Custom providers may provide the METADATA file or override this
457
+ property.
428
458
"""
429
459
opt_text = (
430
460
self .read_text ('METADATA' )
@@ -454,6 +484,12 @@ def version(self) -> str:
454
484
455
485
@property
456
486
def entry_points (self ) -> EntryPoints :
487
+ """
488
+ Return EntryPoints for this distribution.
489
+
490
+ Custom providers may provide the ``entry_points.txt`` file
491
+ or override this property.
492
+ """
457
493
return EntryPoints ._from_text_for (self .read_text ('entry_points.txt' ), self )
458
494
459
495
@property
@@ -466,6 +502,10 @@ def files(self) -> Optional[List[PackagePath]]:
466
502
(i.e. RECORD for dist-info, or installed-files.txt or
467
503
SOURCES.txt for egg-info) is missing.
468
504
Result may be empty if the metadata exists but is empty.
505
+
506
+ Custom providers are recommended to provide a "RECORD" file (in
507
+ ``read_text``) or override this property to allow for callers to be
508
+ able to resolve filenames provided by the package.
469
509
"""
470
510
471
511
def make_file (name , hash = None , size_str = None ):
@@ -497,7 +537,7 @@ def skip_missing_files(package_paths):
497
537
498
538
def _read_files_distinfo (self ):
499
539
"""
500
- Read the lines of RECORD
540
+ Read the lines of RECORD.
501
541
"""
502
542
text = self .read_text ('RECORD' )
503
543
return text and text .splitlines ()
@@ -611,6 +651,9 @@ def _load_json(self, filename):
611
651
class DistributionFinder (MetaPathFinder ):
612
652
"""
613
653
A MetaPathFinder capable of discovering installed distributions.
654
+
655
+ Custom providers should implement this interface in order to
656
+ supply metadata.
614
657
"""
615
658
616
659
class Context :
@@ -623,6 +666,17 @@ class Context:
623
666
Each DistributionFinder may expect any parameters
624
667
and should attempt to honor the canonical
625
668
parameters defined below when appropriate.
669
+
670
+ This mechanism gives a custom provider a means to
671
+ solicit additional details from the caller beyond
672
+ "name" and "path" when searching distributions.
673
+ For example, imagine a provider that exposes suites
674
+ of packages in either a "public" or "private" ``realm``.
675
+ A caller may wish to query only for distributions in
676
+ a particular realm and could call
677
+ ``distributions(realm="private")`` to signal to the
678
+ custom provider to only include distributions from that
679
+ realm.
626
680
"""
627
681
628
682
name = None
@@ -658,11 +712,18 @@ def find_distributions(self, context=Context()) -> Iterable[Distribution]:
658
712
659
713
class FastPath :
660
714
"""
661
- Micro-optimized class for searching a path for
662
- children.
715
+ Micro-optimized class for searching a root for children.
716
+
717
+ Root is a path on the file system that may contain metadata
718
+ directories either as natural directories or within a zip file.
663
719
664
720
>>> FastPath('').children()
665
721
['...']
722
+
723
+ FastPath objects are cached and recycled for any given root.
724
+
725
+ >>> FastPath('foobar') is FastPath('foobar')
726
+ True
666
727
"""
667
728
668
729
@functools .lru_cache () # type: ignore
@@ -704,7 +765,19 @@ def lookup(self, mtime):
704
765
705
766
706
767
class Lookup :
768
+ """
769
+ A micro-optimized class for searching a (fast) path for metadata.
770
+ """
771
+
707
772
def __init__ (self , path : FastPath ):
773
+ """
774
+ Calculate all of the children representing metadata.
775
+
776
+ From the children in the path, calculate early all of the
777
+ children that appear to represent metadata (infos) or legacy
778
+ metadata (eggs).
779
+ """
780
+
708
781
base = os .path .basename (path .root ).lower ()
709
782
base_is_egg = base .endswith (".egg" )
710
783
self .infos = FreezableDefaultDict (list )
@@ -725,7 +798,10 @@ def __init__(self, path: FastPath):
725
798
self .infos .freeze ()
726
799
self .eggs .freeze ()
727
800
728
- def search (self , prepared ):
801
+ def search (self , prepared : Prepared ):
802
+ """
803
+ Yield all infos and eggs matching the Prepared query.
804
+ """
729
805
infos = (
730
806
self .infos [prepared .normalized ]
731
807
if prepared
@@ -741,13 +817,28 @@ def search(self, prepared):
741
817
742
818
class Prepared :
743
819
"""
744
- A prepared search for metadata on a possibly-named package.
820
+ A prepared search query for metadata on a possibly-named package.
821
+
822
+ Pre-calculates the normalization to prevent repeated operations.
823
+
824
+ >>> none = Prepared(None)
825
+ >>> none.normalized
826
+ >>> none.legacy_normalized
827
+ >>> bool(none)
828
+ False
829
+ >>> sample = Prepared('Sample__Pkg-name.foo')
830
+ >>> sample.normalized
831
+ 'sample_pkg_name_foo'
832
+ >>> sample.legacy_normalized
833
+ 'sample__pkg_name.foo'
834
+ >>> bool(sample)
835
+ True
745
836
"""
746
837
747
838
normalized = None
748
839
legacy_normalized = None
749
840
750
- def __init__ (self , name ):
841
+ def __init__ (self , name : Optional [ str ] ):
751
842
self .name = name
752
843
if name is None :
753
844
return
@@ -777,7 +868,7 @@ class MetadataPathFinder(DistributionFinder):
777
868
@classmethod
778
869
def find_distributions (
779
870
cls , context = DistributionFinder .Context ()
780
- ) -> Iterable [" PathDistribution" ]:
871
+ ) -> Iterable [PathDistribution ]:
781
872
"""
782
873
Find distributions.
783
874
@@ -810,7 +901,7 @@ def __init__(self, path: SimplePath) -> None:
810
901
"""
811
902
self ._path = path
812
903
813
- def read_text (self , filename : Union [ str , os .PathLike [str ] ]) -> Optional [str ]:
904
+ def read_text (self , filename : str | os .PathLike [str ]) -> Optional [str ]:
814
905
with suppress (
815
906
FileNotFoundError ,
816
907
IsADirectoryError ,
@@ -824,7 +915,7 @@ def read_text(self, filename: Union[str, os.PathLike[str]]) -> Optional[str]:
824
915
825
916
read_text .__doc__ = Distribution .read_text .__doc__
826
917
827
- def locate_file (self , path : Union [ str , os .PathLike [str ]] ) -> pathlib . Path :
918
+ def locate_file (self , path : str | os .PathLike [str ]) -> SimplePath :
828
919
return self ._path .parent / path
829
920
830
921
@property
0 commit comments