Skip to content

Commit 95f5f68

Browse files
committed
Fix empty .start file logic bug
* The presence of a readable but empty .start file should suppress import lines in .pth files. * Slightly cleaner tearDown() * Slightly cleaner filename calculation * Add a test to check that two .start files containing the same entry point are not deduplicated.
1 parent e3be2af commit 95f5f68

2 files changed

Lines changed: 66 additions & 19 deletions

File tree

Lib/site.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -155,31 +155,32 @@ def _init_pathinfo():
155155
def _read_pthstart_file(sitedir, name, suffix):
156156
"""Parse a .start or .pth file and return (lines, filename).
157157
158-
Always returns a 2-tuple. On failure (hidden, unreadable, etc.),
159-
returns ([], filename) so callers can proceed without checking.
158+
On success, ``lines`` is a (possibly empty) list of the file's lines.
159+
On failure (file missing, hidden, unreadable, or .start with bad
160+
encoding), ``lines`` is ``None`` so callers can distinguish a
161+
successfully-read empty file from one that could not be read.
160162
"""
161-
content = ""
162163
filename = os.path.join(sitedir, name)
163164
_trace(f"Reading startup configuration file: {filename}")
164165

165166
try:
166167
st = os.lstat(filename)
167168
except OSError as exc:
168169
_print_error(f"Cannot stat {filename!r}", exc)
169-
return [], filename
170+
return None, filename
170171

171172
if ((getattr(st, 'st_flags', 0) & stat.UF_HIDDEN) or
172173
(getattr(st, 'st_file_attributes', 0) & stat.FILE_ATTRIBUTE_HIDDEN)):
173174
_trace(f"Skipping hidden {suffix} file: {filename!r}")
174-
return [], filename
175+
return None, filename
175176

176177
_trace(f"Processing {suffix} file: {filename!r}")
177178
try:
178179
with io.open_code(filename) as f:
179180
raw_content = f.read()
180181
except OSError as exc:
181182
_print_error(f"Cannot read {filename!r}", exc)
182-
return [], filename
183+
return None, filename
183184

184185
try:
185186
# Accept BOM markers in .start and .pth files as we do in source files (Windows PowerShell
@@ -193,7 +194,7 @@ def _read_pthstart_file(sitedir, name, suffix):
193194
content = raw_content.decode(locale.getencoding())
194195
_trace(f"Using fallback encoding {locale.getencoding()!r}")
195196
else:
196-
return [], filename
197+
return None, filename
197198

198199
return content.splitlines(), filename
199200

@@ -205,6 +206,8 @@ def _read_pth_file(sitedir, name, known_paths):
205206
file (PEP 829).
206207
"""
207208
lines, filename = _read_pthstart_file(sitedir, name, ".pth")
209+
if lines is None:
210+
return
208211

209212
for n, line in enumerate(lines, 1):
210213
line = line.strip()
@@ -236,6 +239,14 @@ def _read_pth_file(sitedir, name, known_paths):
236239
def _read_start_file(sitedir, name):
237240
"""Parse a .start file and return a list of entry point strings."""
238241
lines, filename = _read_pthstart_file(sitedir, name, ".start")
242+
if lines is None:
243+
return
244+
245+
# PEP 829: the *presence* of a matching .start file disables `import`
246+
# line processing in the matched .pth file, regardless of whether the
247+
# .start file produced any entry points. Register the filename as a
248+
# key now so an empty (or comment-only) .start file still suppresses.
249+
entrypoints = _pending_entrypoints.setdefault(filename, [])
239250

240251
for n, line in enumerate(lines, 1):
241252
line = line.strip()
@@ -248,7 +259,7 @@ def _read_start_file(sitedir, name):
248259
f"skipping invalid entry point: {line}")
249260
continue
250261

251-
_pending_entrypoints.setdefault(filename, []).append(line)
262+
entrypoints.append(line)
252263

253264

254265
def _extend_syspath():

Lib/test/test_site.py

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -935,24 +935,21 @@ def setUp(self):
935935
site._pending_importexecs.clear()
936936

937937
def tearDown(self):
938-
site._pending_entrypoints.clear()
939-
site._pending_entrypoints.update(self.saved_entrypoints)
940-
site._pending_syspaths.clear()
941-
site._pending_syspaths.update(self.saved_syspaths)
942-
site._pending_importexecs.clear()
943-
site._pending_importexecs.update(self.saved_importexecs)
938+
site._pending_entrypoints = self.saved_entrypoints.copy()
939+
site._pending_syspaths = self.saved_syspaths.copy()
940+
site._pending_importexecs = self.saved_importexecs.copy()
944941

945942
def _make_start(self, content, name='testpkg'):
946943
"""Write a <name>.start file and return its basename."""
947-
basename = name + '.start'
944+
basename = f"{name}.start"
948945
filepath = os.path.join(self.tmpdir, basename)
949946
with open(filepath, 'w', encoding='utf-8') as f:
950947
f.write(content)
951948
return basename
952949

953950
def _make_pth(self, content, name='testpkg'):
954951
"""Write a <name>.pth file and return its basename."""
955-
basename = name + '.pth'
952+
basename = f"{name}.pth"
956953
filepath = os.path.join(self.tmpdir, basename)
957954
with open(filepath, 'w', encoding='utf-8') as f:
958955
f.write(content)
@@ -966,6 +963,9 @@ def _all_entrypoints(self):
966963
result.append((filename, entry))
967964
return result
968965

966+
def _just_entrypoints(self):
967+
return [entry for filename, entry in self._all_entrypoints()]
968+
969969
# --- _read_start_file tests ---
970970

971971
def test_read_start_file_basic(self):
@@ -995,14 +995,21 @@ def test_read_start_file_missing_colon_skipped(self):
995995
self.assertEqual(site._pending_entrypoints[fullname], ['os.path:join'])
996996

997997
def test_read_start_file_empty(self):
998+
# PEP 829: an empty .start file is still registered as present
999+
# (with an empty entry-point list) so that it suppresses `import`
1000+
# lines in any matching .pth file.
9981001
self._make_start("", name='foo')
9991002
site._read_start_file(self.sitedir, 'foo.start')
1000-
self.assertEqual(site._pending_entrypoints, {})
1003+
fullname = os.path.join(self.sitedir, 'foo.start')
1004+
self.assertEqual(site._pending_entrypoints, {fullname: []})
10011005

10021006
def test_read_start_file_comments_only(self):
1007+
# As with an empty file, a comments-only .start file is registered
1008+
# as present so it can suppress matching .pth `import` lines.
10031009
self._make_start("# just a comment\n# another\n", name='foo')
10041010
site._read_start_file(self.sitedir, 'foo.start')
1005-
self.assertEqual(site._pending_entrypoints, {})
1011+
fullname = os.path.join(self.sitedir, 'foo.start')
1012+
self.assertEqual(site._pending_entrypoints, {fullname: []})
10061013

10071014
def test_read_start_file_nonexistent(self):
10081015
with captured_stderr():
@@ -1026,6 +1033,14 @@ def test_read_start_file_duplicates_not_deduplicated(self):
10261033
self.assertEqual(site._pending_entrypoints[fullname],
10271034
['os.path:join', 'os.path:join'])
10281035

1036+
def test_two_start_files_with_duplicates_not_deduplicated(self):
1037+
self._make_start("os.path:join", name="foo")
1038+
self._make_start("os.path:join", name="bar")
1039+
site._read_start_file(self.sitedir, 'foo.start')
1040+
site._read_start_file(self.sitedir, 'bar.start')
1041+
self.assertEqual(self._just_entrypoints(),
1042+
['os.path:join', 'os.path:join'])
1043+
10291044
# --- _read_pth_file tests ---
10301045

10311046
def test_read_pth_file_paths(self):
@@ -1060,7 +1075,7 @@ def test_read_pth_file_deduplication(self):
10601075
all_dirs = []
10611076
for dirs in site._pending_syspaths.values():
10621077
all_dirs.extend(dirs)
1063-
self.assertEqual(all_dirs.count(subdir), 1)
1078+
self.assertEqual(all_dirs, [subdir])
10641079

10651080
def test_read_pth_file_bad_line_continues(self):
10661081
# PEP 829: errors on individual lines don't abort the file.
@@ -1164,6 +1179,27 @@ def test_exec_imports_not_suppressed_by_different_start(self):
11641179
# Should execute the import line without error.
11651180
site._exec_imports()
11661181

1182+
def test_exec_imports_suppressed_by_empty_matching_start(self):
1183+
self._make_start("", name='foo')
1184+
self._make_pth("import epmod; epmod.startup()", name='foo')
1185+
mod_dir = os.path.join(self.sitedir, 'epmod')
1186+
os.mkdir(mod_dir)
1187+
init_file = os.path.join(mod_dir, '__init__.py')
1188+
with open(init_file, 'w') as f:
1189+
f.write("""\
1190+
called = False
1191+
def startup():
1192+
global called
1193+
called = True
1194+
""")
1195+
sys.path.insert(0, self.sitedir)
1196+
self.addCleanup(sys.modules.pop, 'epmod', None)
1197+
site._read_pth_file(self.sitedir, 'foo.pth', set())
1198+
site._read_start_file(self.sitedir, 'foo.start')
1199+
site._exec_imports()
1200+
import epmod
1201+
self.assertFalse(epmod.called)
1202+
11671203
# --- _extend_syspath tests ---
11681204

11691205
def test_extend_syspath_existing_dir(self):

0 commit comments

Comments
 (0)