diff --git a/gearbox/command.py b/gearbox/command.py index 72eccfa..9c1c38b 100644 --- a/gearbox/command.py +++ b/gearbox/command.py @@ -6,7 +6,7 @@ from .template import GearBoxTemplate -class Command(object): +class Command: deprecated = False def __init__(self, app, app_args, cmd_name=None): diff --git a/gearbox/commandmanager.py b/gearbox/commandmanager.py index 202d6b7..fe48f9a 100644 --- a/gearbox/commandmanager.py +++ b/gearbox/commandmanager.py @@ -10,7 +10,7 @@ LOG = logging.getLogger(__name__) -class EntryPointWrapper(object): +class EntryPointWrapper: """Wrap up a command class already imported to make it look like a plugin.""" def __init__(self, name, command_class): @@ -21,7 +21,7 @@ def load(self, require=False): return self.command_class -class CommandManager(object): +class CommandManager: """Discovers commands and handles lookup based on argv data. :param namespace: String containing the setuptools entrypoint namespace @@ -35,26 +35,16 @@ def __init__(self, namespace, convert_underscores=True): self.commands = {} self.namespace = namespace self.convert_underscores = convert_underscores - self._load_commands() - - def _load_commands(self): - # NOTE(jamielennox): kept for compatability. self.load_commands(self.namespace) def load_commands(self, namespace): """Load all the commands from an entrypoint""" - entry_points = importlib.metadata.entry_points() - if hasattr(entry_points, "select"): - entry_points = entry_points.select(group=namespace) - else: - entry_points = entry_points.get(namespace, []) - for ep in entry_points: + for ep in importlib.metadata.entry_points().select(group=namespace): LOG.debug("found command %r", ep.name) cmd_name = ( ep.name.replace("_", " ") if self.convert_underscores else ep.name ) self.commands[cmd_name] = ep - return def __iter__(self): return iter(self.commands.items()) diff --git a/gearbox/commands/basic_package/command.py b/gearbox/commands/basic_package/command.py index 2af3e47..efdac8f 100644 --- a/gearbox/commands/basic_package/command.py +++ b/gearbox/commands/basic_package/command.py @@ -1,5 +1,3 @@ -from __future__ import print_function - import re from gearbox.command import TemplateCommand @@ -12,7 +10,7 @@ def get_description(self): return "Creates a basic python package" def get_parser(self, prog_name): - parser = super(MakePackageCommand, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( "-n", diff --git a/gearbox/commands/basic_package/template/README.rst_tmpl b/gearbox/commands/basic_package/template/README.rst_tmpl index faf1f1a..9f3c758 100644 --- a/gearbox/commands/basic_package/template/README.rst_tmpl +++ b/gearbox/commands/basic_package/template/README.rst_tmpl @@ -6,12 +6,10 @@ About {{package}} Installing ------------------------------- -{{package}} can be installed from pypi:: - - easy_install {{package}} - -or:: +{{package}} can be installed from PyPI:: pip install {{package}} -should just work for most of the users +For local development (editable install):: + + pip install -e . diff --git a/gearbox/commands/basic_package/template/pyproject.toml_tmpl b/gearbox/commands/basic_package/template/pyproject.toml_tmpl new file mode 100644 index 0000000..876e7a1 --- /dev/null +++ b/gearbox/commands/basic_package/template/pyproject.toml_tmpl @@ -0,0 +1,14 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "{{project}}" +version = "{{version}}" +description = "{{description or ''}}" +readme = "README.rst" +requires-python = ">=3.10" +authors = [{name = "{{author or ''}}", email = "{{author_email or ''}}"}] +license = {text = "{{license_name or ''}}"} +keywords = ["{{keywords or ''}}"] +urls = {Homepage = "{{url or ''}}"} diff --git a/gearbox/commands/basic_package/template/setup.cfg_tmpl b/gearbox/commands/basic_package/template/setup.cfg_tmpl deleted file mode 100644 index e69de29..0000000 diff --git a/gearbox/commands/basic_package/template/setup.py_tmpl b/gearbox/commands/basic_package/template/setup.py_tmpl deleted file mode 100644 index f54717a..0000000 --- a/gearbox/commands/basic_package/template/setup.py_tmpl +++ /dev/null @@ -1,31 +0,0 @@ -from setuptools import setup, find_packages -import sys, os - -here = os.path.abspath(os.path.dirname(__file__)) -try: - README = open(os.path.join(here, 'README.rst')).read() -except IOError: - README = '' - -version = "{{version}}" - -setup(name={{repr(project)}}, - version=version, - description="{{description or ''}}", - long_description=README, - classifiers=[], # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers - keywords={{repr(keywords or '')}}, - author={{repr(author or '')}}, - author_email={{repr(author_email or '')}}, - url={{repr(url or '')}}, - license={{repr(license_name or '')}}, - packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), - include_package_data=True, - zip_safe=False, - install_requires=[ - # -*- Extra requirements: -*- - ], - entry_points=""" - # -*- Entry points: -*- - """, - ) diff --git a/gearbox/commands/help.py b/gearbox/commands/help.py index c95bac8..54c7366 100644 --- a/gearbox/commands/help.py +++ b/gearbox/commands/help.py @@ -45,7 +45,7 @@ class HelpCommand(Command): """print detailed help for another command""" def get_parser(self, prog_name): - parser = super(HelpCommand, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( "cmd", nargs="*", diff --git a/gearbox/commands/patch.py b/gearbox/commands/patch.py index de94a9b..c4c04e3 100644 --- a/gearbox/commands/patch.py +++ b/gearbox/commands/patch.py @@ -1,5 +1,3 @@ -from __future__ import print_function - import fnmatch import os import re @@ -30,7 +28,7 @@ def get_description(self): """ def get_parser(self, prog_name): - parser = super(PatchCommand, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.formatter_class = RawDescriptionHelpFormatter parser.add_argument( @@ -85,28 +83,6 @@ def get_parser(self, prog_name): return parser - def _walk_recursive(self): - for root, dirnames, filenames in os.walk(os.getcwd()): - for filename in filenames: - yield os.path.join(root, filename) - - def _walk_flat(self): - root = os.getcwd() - for filename in os.listdir(root): - yield os.path.join(root, filename) - - def _replace_regex(self, line, text, replacement): - return re.sub(text, replacement, line) - - def _replace_plain(self, line, text, replacement): - return line.replace(text, replacement) - - def _match_regex(self, line, text): - return re.search(text, line) is not None - - def _match_plain(self, line, text): - return text in line - def take_action(self, opts): walk = self._walk_flat if opts.recursive: @@ -158,3 +134,25 @@ def take_action(self, opts): if matches: with open(filepath, "w") as f: f.writelines(lines) + + def _walk_recursive(self): + for root, dirnames, filenames in os.walk(os.getcwd()): + for filename in filenames: + yield os.path.join(root, filename) + + def _walk_flat(self): + root = os.getcwd() + for filename in os.listdir(root): + yield os.path.join(root, filename) + + def _replace_regex(self, line, text, replacement): + return re.sub(text, replacement, line) + + def _replace_plain(self, line, text, replacement): + return line.replace(text, replacement) + + def _match_regex(self, line, text): + return re.search(text, line) is not None + + def _match_plain(self, line, text): + return text in line diff --git a/gearbox/commands/scaffold.py b/gearbox/commands/scaffold.py index 79ccd14..f449da8 100644 --- a/gearbox/commands/scaffold.py +++ b/gearbox/commands/scaffold.py @@ -1,5 +1,3 @@ -from __future__ import print_function - import os from argparse import RawDescriptionHelpFormatter @@ -23,7 +21,7 @@ def get_description(self): """ def get_parser(self, prog_name): - parser = super(ScaffoldCommand, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.formatter_class = RawDescriptionHelpFormatter @@ -67,16 +65,6 @@ def get_parser(self, prog_name): return parser - def _lookup(self, template, where): - template_filename = None - for root, __, files in os.walk(where): - for f in files: - fname, fext = os.path.splitext(f) - if fext == ".template" and os.path.splitext(fname)[0] == template: - template_filename = os.path.join(root, f) - break - return template_filename - def take_action(self, opts): for template in opts.scaffold_name: template_filename = None @@ -144,3 +132,13 @@ def take_action(self, opts): with open(output_path, "w") as of: of.write(text) + + def _lookup(self, template, where): + template_filename = None + for root, __, files in os.walk(where): + for f in files: + fname, fext = os.path.splitext(f) + if fext == ".template" and os.path.splitext(fname)[0] == template: + template_filename = os.path.join(root, f) + break + return template_filename diff --git a/gearbox/commands/serve.py b/gearbox/commands/serve.py index fde4003..9ac96e3 100644 --- a/gearbox/commands/serve.py +++ b/gearbox/commands/serve.py @@ -9,11 +9,9 @@ # lib/site.py import atexit -import ctypes import errno import logging import os -import platform import re import subprocess import sys @@ -30,17 +28,7 @@ MAXFD = 1024 -if platform.system() == "Windows" and not hasattr(os, "kill"): # pragma: no cover - # py 2.6 on windows - def kill(pid, sig=None): - """kill function for Win32""" - # signal is ignored, semibogus raise message - kernel32 = ctypes.windll.kernel32 - handle = kernel32.OpenProcess(1, 0, pid) - if 0 == kernel32.TerminateProcess(handle, 0): - raise OSError("No such process %s" % pid) -else: - kill = os.kill +kill = os.kill class DaemonizeException(Exception): @@ -55,7 +43,7 @@ class ServeCommand(Command): possible_subcommands = ("start", "stop", "restart", "status") def get_parser(self, prog_name): - parser = super(ServeCommand, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( "-c", @@ -512,7 +500,7 @@ def restart_with_monitor(self): # pragma: no cover if self.verbose > 0: self.out("Starting subprocess with angel") - while 1: + while True: args = self.get_fixed_argv() new_environ = os.environ.copy() new_environ[self._monitor_environ_key] = "true" @@ -580,7 +568,7 @@ def change_user_group(self, user, group): # pragma: no cover os.setuid(uid) -class LazyWriter(object): +class LazyWriter: """ File-like object that opens a file lazily when it is first written to. @@ -718,10 +706,7 @@ def get_request(self): server_class = SecureWSGIServer if threaded: - try: - from socketserver import ThreadingMixIn - except ImportError: - from SocketServer import ThreadingMixIn + from socketserver import ThreadingMixIn class GearboxWSGIServer(ThreadingMixIn, server_class): pass @@ -747,12 +732,8 @@ class GearboxWSGIServer(server_class): # For paste.deploy server instantiation (egg:gearbox#gevent) def gevent_server_factory(global_config, **kw): from gevent import reinit - - try: - from gevent.pywsgi import WSGIServer - except ImportError: - from gevent.wsgi import WSGIServer from gevent.monkey import patch_all + from gevent.pywsgi import WSGIServer reinit() patch_all(dns=False) @@ -858,27 +839,16 @@ def cherrypy_server_runner( if var is not None: kwargs[var_name] = int(var) - server = None - try: - # Try to import from newer CherryPy releases. - import cheroot.wsgi as wsgiserver - - server = wsgiserver.Server(bind_addr, app, server_name=server_name, **kwargs) - except ImportError: - # Nope. Try to import from older CherryPy releases. - # We might just take another ImportError here. Oh well. - from cherrypy import wsgiserver + import cheroot.wsgi as wsgiserver - server = wsgiserver.CherryPyWSGIServer( - bind_addr, app, server_name=server_name, **kwargs - ) + server = wsgiserver.Server(bind_addr, app, server_name=server_name, **kwargs) server.ssl_certificate = server.ssl_private_key = ssl_pem if protocol_version: server.protocol = protocol_version try: - protocol = is_ssl and "https" or "http" + protocol = "https" if is_ssl else "http" if host == "0.0.0.0": print( "serving on 0.0.0.0:%s view at %s://127.0.0.1:%s" diff --git a/gearbox/commands/setup_app.py b/gearbox/commands/setup_app.py index ff930db..409d9e9 100644 --- a/gearbox/commands/setup_app.py +++ b/gearbox/commands/setup_app.py @@ -1,5 +1,4 @@ -from __future__ import print_function - +import importlib import os from paste.deploy import appconfig @@ -12,7 +11,7 @@ def get_description(self): return "Setup an application, given a config file" def get_parser(self, prog_name): - parser = super(SetupAppCommand, self).get_parser(prog_name) + parser = super().get_parser(prog_name) parser.add_argument( "-c", @@ -79,20 +78,16 @@ def _setup_config(self, dist, filename, section, vars, verbosity): the extra attributes ``global_conf``, ``local_conf`` and ``filename`` """ - try: - top_level_lines = dist.read_text("top_level.txt").split("\n") - except AttributeError: - # Backward compatbility with older PasteDeploy - top_level_lines = dist.get_metadata_lines("top_level.txt") - modules = [ - line.strip() - for line in top_level_lines - if line.strip() and not line.strip().startswith("#") - ] + modules = self._find_websetup_modules(dist) if not modules: - print("No modules are listed in top_level.txt") - print("Try reinstalling the application to regenerate that file") + print( + "Unable to find any websetup modules from distribution metadata." + ) + print("Try reinstalling the application and rerun setup-app.") + return + + modules = sorted(set(modules)) websetup_executed = False for mod_name in modules: @@ -125,6 +120,43 @@ def _setup_config(self, dist, filename, section, vars, verbosity): if not websetup_executed: print("No websetup found in any of the top modules") + def _find_websetup_modules(self, dist): + modules = [] + for path in dist.files or (): + parts = tuple(path.parts) + if any(part.endswith((".dist-info", ".egg-info")) for part in parts): + continue + + if parts[-1] == "websetup.py": + package_parts = parts[:-1] + elif len(parts) >= 2 and parts[-2:] == ("websetup", "__init__.py"): + package_parts = parts[:-2] + else: + continue + + if not package_parts: + continue + if not all(part.isidentifier() for part in package_parts): + continue + + modules.append(".".join(package_parts)) + + if modules: + return sorted(set(modules)) + + return self._find_websetup_modules_from_top_level(dist) + + def _find_websetup_modules_from_top_level(self, dist): + top_level_text = dist.read_text("top_level.txt") + if not top_level_text: + return [] + + return [ + line.strip() + for line in top_level_text.splitlines() + if line.strip() and not line.strip().startswith("#") + ] + def _call_setup_app(self, func, filename, section, vars): filename = os.path.abspath(filename) if ":" in section: @@ -138,8 +170,4 @@ def _import_module(self, s): """ Import a module. """ - mod = __import__(s) - parts = s.split(".") - for part in parts[1:]: - mod = getattr(mod, part) - return mod + return importlib.import_module(s) diff --git a/gearbox/main.py b/gearbox/main.py index 9669b34..49280c2 100644 --- a/gearbox/main.py +++ b/gearbox/main.py @@ -1,5 +1,3 @@ -from __future__ import print_function - import argparse import importlib.metadata import inspect @@ -16,7 +14,7 @@ log = logging.getLogger("gearbox") -class GearBox(object): +class GearBox: NAME = os.path.splitext(os.path.basename(sys.argv[0]))[0] LOG_DATE_FORMAT = "%H:%M:%S" LOG_GEARBOX_FORMAT = ( @@ -95,10 +93,7 @@ def __init__(self): def _configure_logging(self): if self.options.debug: warnings.simplefilter("default") - try: - logging.captureWarnings(True) - except AttributeError: - pass + logging.captureWarnings(True) root_logger = logging.getLogger("") root_logger.setLevel(logging.INFO) @@ -169,9 +164,12 @@ def _run_subcommand(self, argv): cmd_factory, cmd_name, sub_argv = subcommand kwargs = {} - if ( - "cmd_name" in self._getargspec(cmd_factory)[0] - ): # Check to see if 'cmd_name' is in cmd_factory's args + try: + cmd_signature = inspect.signature(cmd_factory) + supports_cmd_name = "cmd_name" in cmd_signature.parameters + except (TypeError, ValueError): + supports_cmd_name = False + if supports_cmd_name: kwargs["cmd_name"] = cmd_name cmd = cmd_factory(self, self.options, **kwargs) @@ -282,41 +280,6 @@ def load_commands_for_package(self, package_name, search_paths=None): def _normalize_dist_name(name): return re.sub(r"[-_.]+", "-", name).lower() - def _getargspec(self, func): - if not hasattr(inspect, "signature"): - return inspect.getargspec(func.__init__) - else: # pragma: no cover - sig = inspect.signature(func) - args = [ - p.name - for p in sig.parameters.values() - if p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD - ] - varargs = [ - p.name - for p in sig.parameters.values() - if p.kind == inspect.Parameter.VAR_POSITIONAL - ] - varargs = varargs[0] if varargs else None - varkw = [ - p.name - for p in sig.parameters.values() - if p.kind == inspect.Parameter.VAR_KEYWORD - ] - varkw = varkw[0] if varkw else None - defaults = ( - tuple( - ( - p.default - for p in sig.parameters.values() - if p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD - and p.default is not p.empty - ) - ) - or None - ) - return args, varargs, varkw, defaults - def main(): args = sys.argv[1:] diff --git a/gearbox/template.py b/gearbox/template.py index 8abf127..15ab0bb 100644 --- a/gearbox/template.py +++ b/gearbox/template.py @@ -5,7 +5,7 @@ from .utils.copydir import copy_dir -class GearBoxTemplate(object): +class GearBoxTemplate: def template_renderer(self, content, vars, filename=None): tmpl = Template(content, name=filename) return tmpl.substitute(vars) diff --git a/gearbox/utils/copydir.py b/gearbox/utils/copydir.py index c8052de..adce9ed 100644 --- a/gearbox/utils/copydir.py +++ b/gearbox/utils/copydir.py @@ -6,17 +6,6 @@ import sys -def native_(s, encoding="latin-1", errors="strict"): - """If ``s`` is an instance of ``str``, return - ``s``, otherwise return ``str(s, encoding, errors)``""" - if isinstance(s, str): - return s - return str(s, encoding, errors) - - -fsenc = sys.getfilesystemencoding() - - def copy_dir( source, dest, @@ -104,9 +93,8 @@ def out(msg): ) continue else: - f = open(full, "rb") - content = f.read() - f.close() + with open(full, "rb") as f: + content = f.read() if sub_file: try: content = substitute_content( @@ -118,19 +106,18 @@ def out(msg): continue # pragma: no cover already_exists = os.path.exists(dest_full) if already_exists: - f = open(dest_full, "rb") - old_content = f.read() - f.close() + with open(dest_full, "rb") as f: + old_content = f.read() if old_content == content: if verbosity: out("%s%s already exists (same content)" % (pad, dest_full)) continue # pragma: no cover if interactive: if not query_interactive( - native_(full, fsenc), - native_(dest_full, fsenc), - native_(content, fsenc), - native_(old_content, fsenc), + full, + dest_full, + content.decode("latin-1"), + old_content.decode("latin-1"), simulate=simulate, out_=out_, ): @@ -140,9 +127,8 @@ def out(msg): if verbosity: out("%sCopying %s to %s" % (pad, os.path.basename(full), dest_full)) if not simulate: - f = open(dest_full, "wb") - f.write(content) - f.close() + with open(dest_full, "wb") as f: + f.write(content) class SkipTemplate(Exception): @@ -249,7 +235,7 @@ def out(msg): ) ) prompt = "Overwrite %s [y/n/d/B/?] " % dest_fn - while 1: + while True: if all_answer is None: response = input(prompt).strip().lower() else: diff --git a/gearbox/utils/log.py b/gearbox/utils/log.py index dffd11f..988313b 100644 --- a/gearbox/utils/log.py +++ b/gearbox/utils/log.py @@ -1,19 +1,15 @@ +import configparser import os from logging.config import fileConfig -try: # pragma: no cover - import configparser -except ImportError: # pragma: no cover - import ConfigParser as configparser - -def setup_logging(config_uri, fileConfig=fileConfig, configparser=configparser): +def setup_logging(config_uri, fileConfig=fileConfig): """ Set up logging via the logging module's fileConfig function with the filename specified via ``config_uri`` (a string in the form ``filename#sectionname``). - ConfigParser defaults are specified for the special ``__file__`` + Config parser defaults are specified for the special ``__file__`` and ``here`` variables, similar to PasteDeploy config loading. """ path, _ = _getpathsec(config_uri, None) diff --git a/tests/test_commands.py b/tests/test_commands.py index a5e6ad4..0cc088f 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,5 +1,6 @@ import argparse import importlib.metadata +import pathlib import sys import tempfile from unittest.mock import MagicMock, patch @@ -129,6 +130,8 @@ def test_makepackage(tmp_path, monkeypatch): project_dir = tmp_path / "TestProject" assert project_dir.is_dir() + assert (project_dir / "pyproject.toml").is_file() + assert not (project_dir / "setup.py").exists() # --- Test for setup-app command --- @@ -148,7 +151,9 @@ def test_setup_app(tmp_path): context=MagicMock( entry_point_name="main", protocol="app", - distribution=MagicMock(read_text=MagicMock(return_value="fakemodule")), + distribution=MagicMock( + files=[pathlib.PurePosixPath("fakemodule/websetup.py")] + ), ) ), ), patch.object( @@ -161,6 +166,102 @@ def test_setup_app(tmp_path): fake_setup_app.assert_called_once() +def test_setup_app_uses_websetup_file_from_dist_files(tmp_path): + project_dir = tmp_path / "project" + project_dir.mkdir() + config_file = project_dir / "development.ini" + config_file.write_text("[app:main]\nuse=egg:fakeegg\n") + + fake_setup_app = MagicMock(return_value=0) + fake_dist = MagicMock() + fake_dist.files = [pathlib.PurePosixPath("fakemodule/websetup.py")] + + with patch.object( + sys, "argv", ["gearbox", "setup-app", "-c", str(config_file)] + ), patch( + "gearbox.commands.setup_app.appconfig", + return_value=MagicMock( + context=MagicMock( + entry_point_name="main", + protocol="app", + distribution=fake_dist, + ) + ), + ), patch.object( + SetupAppCommand, + "_import_module", + return_value=MagicMock(setup_app=fake_setup_app), + ): + main() + + fake_setup_app.assert_called_once() + + +def test_setup_app_uses_websetup_package_from_dist_files(tmp_path): + project_dir = tmp_path / "project" + project_dir.mkdir() + config_file = project_dir / "development.ini" + config_file.write_text("[app:main]\nuse=egg:fakeegg\n") + + fake_setup_app = MagicMock(return_value=0) + fake_dist = MagicMock() + fake_dist.files = [pathlib.PurePosixPath("fakemodule/websetup/__init__.py")] + + with patch.object( + sys, "argv", ["gearbox", "setup-app", "-c", str(config_file)] + ), patch( + "gearbox.commands.setup_app.appconfig", + return_value=MagicMock( + context=MagicMock( + entry_point_name="main", + protocol="app", + distribution=fake_dist, + ) + ), + ), patch.object( + SetupAppCommand, + "_import_module", + return_value=MagicMock(setup_app=fake_setup_app), + ) as import_module: + main() + + import_module.assert_called_once_with("fakemodule.websetup") + fake_setup_app.assert_called_once() + + +def test_setup_app_falls_back_to_top_level_metadata(tmp_path): + project_dir = tmp_path / "project" + project_dir.mkdir() + config_file = project_dir / "development.ini" + config_file.write_text("[app:main]\nuse=egg:fakeegg\n") + + fake_setup_app = MagicMock(return_value=0) + fake_dist = MagicMock() + fake_dist.files = None + fake_dist.read_text.return_value = "fakemodule\n" + + with patch.object( + sys, "argv", ["gearbox", "setup-app", "-c", str(config_file)] + ), patch( + "gearbox.commands.setup_app.appconfig", + return_value=MagicMock( + context=MagicMock( + entry_point_name="main", + protocol="app", + distribution=fake_dist, + ) + ), + ), patch.object( + SetupAppCommand, + "_import_module", + return_value=MagicMock(setup_app=fake_setup_app), + ) as import_module: + main() + + import_module.assert_called_once_with("fakemodule.websetup") + fake_setup_app.assert_called_once() + + # --- Test for scaffold command --- def test_scaffold(tmp_path): lookup_dir = tmp_path / "scaffolds"