Skip to content

Commit f05e9f6

Browse files
committed
Prototype of package.site.toml files
1 parent 8e9d21c commit f05e9f6

2 files changed

Lines changed: 500 additions & 5 deletions

File tree

Lib/site.py

Lines changed: 163 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@
7979
import stat
8080
import 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
8387
PREFIXES = [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+
166294
def 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

232360
def 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

Comments
 (0)