7979import stat
8080import errno
8181
82+ lazy import importlib
83+ lazy import tomllib
84+ lazy import traceback
85+
8286# Prefixes for site-packages; add additional prefixes like /usr/local here
8387PREFIXES = [sys .prefix , sys .exec_prefix ]
8488# Enable per user site-packages directory
@@ -163,6 +167,130 @@ def _init_pathinfo():
163167 return d
164168
165169
170+ class _SiteTOMLData :
171+ """Parsed data from a single .site.toml file."""
172+ __slots__ = ('filename' , 'sitedir' , 'metadata' , 'dirs' , 'init' )
173+
174+ def __init__ (self , filename , sitedir , metadata , dirs , init ):
175+ self .filename = filename # str: basename e.g. "foo.site.toml"
176+ self .sitedir = sitedir # str: absolute path to site-packages dir
177+ self .metadata = metadata # dict: raw [metadata] table (may be empty)
178+ self .dirs = dirs # list[str]: validated [paths].dirs (may be empty)
179+ self .init = init # list[str]: validated [entrypoints].init (may be empty)
180+
181+
182+ def _read_site_toml (sitedir , name ):
183+ """Parse a .site.toml file and return a _SiteTOMLData, or None on error."""
184+ fullname = os .path .join (sitedir , name )
185+
186+ # Check that name.site.toml file exists and is not hidden.
187+ try :
188+ st = os .lstat (fullname )
189+ except OSError :
190+ return None
191+ if ((getattr (st , 'st_flags' , 0 ) & stat .UF_HIDDEN ) or
192+ (getattr (st , 'st_file_attributes' , 0 ) & stat .FILE_ATTRIBUTE_HIDDEN )):
193+ _trace (f"Skipping hidden .site.toml file: { fullname !r} " )
194+ return None
195+
196+ _trace (f"Processing .site.toml file: { fullname !r} " )
197+
198+ try :
199+ with io .open_code (fullname ) as f :
200+ raw = f .read ()
201+ except OSError :
202+ return None
203+
204+ try :
205+ data = tomllib .loads (raw .decode ("utf-8" ))
206+ except Exception as exc :
207+ _trace (f"Error parsing { fullname !r} : { exc } " )
208+ return None
209+
210+ metadata = data .get ("metadata" , [])
211+
212+ # Validate [paths].dirs
213+ dirs = []
214+ if (paths_table := data .get ("paths" )) is not None :
215+ if (raw_dirs := paths_table .get ("dirs" )) is not None :
216+ if (isinstance (raw_dirs , list ) and
217+ all (isinstance (d , str ) for d in raw_dirs )):
218+ dirs = raw_dirs
219+ else :
220+ _trace (f"Invalid 'dirs' in { fullname !r} : "
221+ f"expected list of strings" )
222+
223+ # Validate [entrypoints].init
224+ init = []
225+ if (ep_table := data .get ("entrypoints" )) is not None :
226+ if (raw_init := ep_table .get ("init" )) is not None :
227+ if (isinstance (raw_init , list ) and
228+ all (isinstance (e , str ) for e in raw_init )):
229+ init = raw_init
230+ else :
231+ _trace (f"Invalid 'init' in { fullname !r} : "
232+ f"expected list of strings" )
233+
234+ return _SiteTOMLData (name , sitedir , metadata , dirs , init )
235+
236+
237+ def _process_site_toml_paths (toml_data_list , known_paths ):
238+ """Process [paths] from all parsed .site.toml data."""
239+ for td in toml_data_list :
240+ for dir_entry in td .dirs :
241+ try :
242+ # The {sitedir} placeholder expands to the site directory where the pkg.site.toml
243+ # file was found. When placed at the beginning of the path, this is the explicit
244+ # way to name directories relative to sitedir.
245+ dir_entry = dir_entry .replace ("{sitedir}" , td .sitedir )
246+ # For backward compatibility with .pth files, relative directories are implicitly
247+ # anchored to sitedir.
248+ if not os .path .isabs (dir_entry ):
249+ dir_entry = os .path .join (td .sitedir , dir_entry )
250+ dir , dircase = makepath (dir_entry )
251+ if dircase not in known_paths and os .path .exists (dir ):
252+ sys .path .append (dir )
253+ known_paths .add (dircase )
254+ except Exception as exc :
255+ fullname = os .path .join (td .sitedir , td .filename )
256+ print (f"Error processing path { dir_entry !r} "
257+ f"from { fullname } :" ,
258+ file = sys .stderr )
259+ for record in traceback .format_exception (exc ):
260+ for line in record .splitlines ():
261+ print (' ' + line , file = sys .stderr )
262+
263+
264+ def _process_site_toml_entrypoints (toml_data_list ):
265+ """Execute [entrypoints] from all parsed .site.toml data."""
266+ for td in toml_data_list :
267+ for entry in td .init :
268+ try :
269+ # Parse "package.module:callable" format. When the optional :callable is not given,
270+ # the entire string will end up in the last item, so swap things around.
271+ modname , colon , funcname = entry .rpartition (':' )
272+ if colon != ':' :
273+ modname = funcname
274+ funcname = None
275+
276+ _trace (f"Executing entrypoint: { entry !r} "
277+ f"from { td .filename !r} " )
278+
279+ mod = importlib .import_module (modname )
280+
281+ # Call the callable if given.
282+ if funcname is not None :
283+ func = getattr (mod , funcname )
284+ func ()
285+ except Exception as exc :
286+ fullname = os .path .join (td .sitedir , td .filename )
287+ print (f"Error in entrypoint { entry !r} from { fullname } :" ,
288+ file = sys .stderr )
289+ for record in traceback .format_exception (exc ):
290+ for line in record .splitlines ():
291+ print (' ' + line , file = sys .stderr )
292+
293+
166294def addpackage (sitedir , name , known_paths ):
167295 """Process a .pth file within the site-packages directory:
168296 For each line in the file, either combine it with sitedir to a path
@@ -230,8 +358,8 @@ def addpackage(sitedir, name, known_paths):
230358
231359
232360def addsitedir (sitedir , known_paths = None ):
233- """Add 'sitedir' argument to sys.path if missing and handle .pth files in
234- 'sitedir'"""
361+ """Add 'sitedir' argument to sys.path if missing and handle .site.toml
362+ and .pth files in 'sitedir'"""
235363 _trace (f"Adding directory: { sitedir !r} " )
236364 if known_paths is None :
237365 known_paths = _init_pathinfo ()
@@ -246,10 +374,40 @@ def addsitedir(sitedir, known_paths=None):
246374 names = os .listdir (sitedir )
247375 except OSError :
248376 return
249- names = [name for name in names
250- if name .endswith (".pth" ) and not name .startswith ("." )]
251- for name in sorted (names ):
377+
378+ # Phase 1: Discover and parse .site.toml files, sorted alphabetically.
379+ toml_names = sorted (
380+ name for name in names
381+ if name .endswith (".site.toml" ) and not name .startswith ("." )
382+ )
383+
384+ toml_data_list = []
385+ superseded_pth = set ()
386+
387+ for name in toml_names :
388+ # "foo.site.toml" supersedes "foo.pth"
389+ base = name .removesuffix (".site.toml" )
390+ pth_name = base + ".pth"
391+ if pth_name in names :
392+ superseded_pth .add (pth_name )
393+ td = _read_site_toml (sitedir , name )
394+ if td is not None :
395+ toml_data_list .append (td )
396+
397+ # Phase 2: Process all .site.toml data (paths first, then entrypoints)
398+ if toml_data_list :
399+ _process_site_toml_paths (toml_data_list , known_paths )
400+ _process_site_toml_entrypoints (toml_data_list )
401+
402+ # Phase 3: Process remaining .pth files
403+ pth_names = sorted (
404+ name for name in names
405+ if name .endswith (".pth" ) and not name .startswith ("." )
406+ and name not in superseded_pth
407+ )
408+ for name in pth_names :
252409 addpackage (sitedir , name , known_paths )
410+
253411 if reset :
254412 known_paths = None
255413 return known_paths
0 commit comments