Skip to content

Commit f40b254

Browse files
committed
More robust setup.py code generation
In setup.py, the code generation learns some new tricks: - Only write generated files if their content would change. - If the generated files exist and we cannot import stgit, do nothing. - Modify sys.path to enable importing stgit. This specifically resolves the problem of installing stgit from a git URL, e.g. `pip install git+https://github.com/stacked-git/stgit.git`. Signed-off-by: Peter Grayson <[email protected]>
1 parent 1aa0015 commit f40b254

File tree

4 files changed

+95
-33
lines changed

4 files changed

+95
-33
lines changed

build.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def main():
6161
commands.get_commands(allow_cached=False), sys.stdout
6262
)
6363
elif options.py_cmd_list:
64-
commands.py_commands(commands.get_commands(allow_cached=False), sys.stdout)
64+
commands.write_cmdlist_py(sys.stdout)
6565
elif options.bash_completion:
6666
write_bash_completion(sys.stdout)
6767
elif options.fish_completion:

contrib/release/pkgtest.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
[x] Test installing to virtualenv
2828
[ ] Test installing to system (i.e. w/chroot)
2929
30+
[x] Test install from git URL
31+
3032
Test install sdist tarball with pip
3133
[x] Ensure generated python is in right place
3234
[x] Ensure generated completions in right place
@@ -207,6 +209,8 @@ def main():
207209
repo = prepare_test_repo(base, this_repo)
208210
prepare_virtual_env(base, python, cache_root)
209211
test_dirty_version(base, repo)
212+
test_install_from_git_url(base, repo, cache_root)
213+
test_uninstall_from_venv(base)
210214
test_sdist_creation(base, repo)
211215
test_create_git_archive(base, repo)
212216
test_build(base, repo)
@@ -321,6 +325,39 @@ def test_dirty_version(base, repo):
321325
check_call(['git', 'clean', '-qfx'], cwd=repo)
322326

323327

328+
def test_install_from_git_url(base, repo, cache):
329+
log_test('test_install_from_git_url')
330+
check_call(
331+
[
332+
venv_py_exe(base),
333+
'-m',
334+
'pip',
335+
'install',
336+
# '--no-clean',
337+
'--no-index',
338+
'--find-links',
339+
cache,
340+
f'git+file://{str(repo)}',
341+
],
342+
cwd=base,
343+
)
344+
version = version_from_stg_version(
345+
check_output([venv_py_exe(base), '-m', 'stgit', '--version'], cwd=base).decode()
346+
)
347+
log(f'{version=}')
348+
349+
share_dir = base / 'venv' / 'share' / 'stgit'
350+
assert (share_dir / 'completion' / 'stg.fish').is_file()
351+
assert (share_dir / 'completion' / 'stgit.bash').is_file()
352+
assert (share_dir / 'completion' / 'stgit.zsh').is_file()
353+
assert (share_dir / 'templates' / 'patchmail.tmpl').is_file()
354+
assert (share_dir / 'contrib').is_dir()
355+
assert (share_dir / 'examples').is_dir()
356+
357+
# Ensure cmdlist.py was generated
358+
check_call([venv_py_exe(base), '-c', 'import stgit.commands.cmdlist'], cwd=base)
359+
360+
324361
def test_sdist_creation(base, repo):
325362
log_test('test_sdist_creation')
326363
dist = base / 'dist'
@@ -556,6 +593,7 @@ def test_develop_mode(base, repo, cache):
556593

557594

558595
def test_uninstall_from_venv(base):
596+
log_test('test_uninstall_from_venv')
559597
check_call(
560598
[venv_py_exe(base), '-m', 'pip', 'uninstall', '--yes', 'stgit'], cwd=base
561599
)

setup.py

Lines changed: 51 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
#!/usr/bin/env python3
2+
import io
23
import os
4+
import sys
35
from glob import glob
6+
from importlib import import_module
47
from importlib.util import module_from_spec, spec_from_file_location
58

69
from setuptools import setup
@@ -50,34 +53,55 @@ def make_release_tree(self, base_dir, files):
5053
cmdclass = dict(sdist=_stgit_sdist, build_py=_stgit_build_py)
5154

5255

53-
def _generate_code():
54-
import stgit.commands
55-
from stgit.completion.bash import write_bash_completion
56-
from stgit.completion.fish import write_fish_completion
57-
56+
# Carefully perform code generation. First check if generated code
57+
# exists. Only write-out generated code if the content is different.
58+
# This accommodates several setup.py contexts, including:
59+
# - When running from an sdist where code is already generated
60+
# - During pip installation where the stgit package is not in sys.path
61+
# - In a git worktree where the generated code may change frequently
62+
# - When running from a read-only directory/filesystem
63+
def _maybe_generate_code():
5864
base = os.path.abspath(os.path.dirname(__file__))
59-
60-
commands = stgit.commands.get_commands(allow_cached=False)
61-
with open(os.path.join(base, 'stgit', 'commands', 'cmdlist.py'), 'w') as f:
62-
stgit.commands.py_commands(commands, f)
63-
64-
completion_dir = os.path.join(base, 'completion')
65-
os.makedirs(completion_dir, exist_ok=True)
66-
with open(os.path.join(completion_dir, 'stgit.bash'), 'w') as f:
67-
write_bash_completion(f)
68-
with open(os.path.join(completion_dir, 'stg.fish'), 'w') as f:
69-
write_fish_completion(f)
70-
71-
72-
# Attempt to generate completion scripts and cmdlist.py. When setup.py
73-
# is executed in the context of a pip installation, the stgit package
74-
# will not be available and thus this will fail with an Import error.
75-
# However, in that case the generated files will already be a part of
76-
# either the sdist or wheel package that pip is installing.
77-
try:
78-
_generate_code()
79-
except ImportError:
80-
print("Skipping stgit code generation")
65+
paths = dict(
66+
cmds=os.path.join(base, 'stgit', 'commands', 'cmdlist.py'),
67+
bash=os.path.join(base, 'completion', 'stgit.bash'),
68+
fish=os.path.join(base, 'completion', 'stg.fish'),
69+
)
70+
71+
existing_content = dict()
72+
for k, path in paths.items():
73+
if os.path.exists(path):
74+
with open(path, 'r') as f:
75+
existing_content[k] = f.read()
76+
else:
77+
existing_content[k] = None
78+
79+
sys.path.insert(0, base)
80+
try:
81+
try:
82+
gen_funcs = dict(
83+
cmds=import_module('stgit.commands').write_cmdlist_py,
84+
bash=import_module('stgit.completion.bash').write_bash_completion,
85+
fish=import_module('stgit.completion.fish').write_fish_completion,
86+
)
87+
except ImportError:
88+
if all(existing_content.values()):
89+
return # Okay, all generated content exists
90+
else:
91+
raise RuntimeError('Cannot perform code generation')
92+
finally:
93+
sys.path.pop(0)
94+
95+
for k, gen_func in gen_funcs.items():
96+
with io.StringIO() as f:
97+
gen_func(f)
98+
new_content = f.getvalue()
99+
if existing_content[k] != new_content:
100+
with open(paths[k], 'w') as f:
101+
f.write(new_content)
102+
103+
104+
_maybe_generate_code()
81105

82106

83107
setup(

stgit/commands/__init__.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import importlib
12
import os
23

34
__copyright__ = """
@@ -20,7 +21,7 @@
2021

2122
def get_command(mod_name):
2223
"""Import and return the given command module."""
23-
return __import__(__name__ + '.' + mod_name, globals(), locals(), ['*'])
24+
return importlib.import_module(__name__ + '.' + mod_name)
2425

2526

2627
_kinds = [
@@ -51,9 +52,7 @@ def get_commands(allow_cached=True):
5152
one-line command help."""
5253
if allow_cached:
5354
try:
54-
from stgit.commands.cmdlist import command_list
55-
56-
return command_list
55+
return importlib.import_module('stgit.commands.cmdlist').command_list
5756
except ImportError:
5857
# cmdlist.py doesn't exist, so do it the expensive way.
5958
pass
@@ -63,7 +62,8 @@ def get_commands(allow_cached=True):
6362
)
6463

6564

66-
def py_commands(commands, f):
65+
def write_cmdlist_py(f):
66+
commands = get_commands(allow_cached=False)
6767
lines = [
6868
'# This file is autogenerated.',
6969
'',

0 commit comments

Comments
 (0)