From 19e145aa830374d20abc1eefd77a7f83ac47b338 Mon Sep 17 00:00:00 2001 From: Tobias Stenzel Date: Tue, 28 Apr 2026 09:52:38 +0200 Subject: [PATCH 001/129] ruff format --- setup.py | 25 ++-- src/appenv.py | 223 +++++++++++++++++++--------------- tests/test_init.py | 10 +- tests/test_prepare.py | 17 +-- tests/test_reset.py | 4 +- tests/test_run.py | 37 +++--- tests/test_update_lockfile.py | 35 +++--- 7 files changed, 188 insertions(+), 163 deletions(-) diff --git a/setup.py b/setup.py index b547aee..03fb28e 100644 --- a/setup.py +++ b/setup.py @@ -7,13 +7,13 @@ from setuptools import setup, find_packages setup( - name='appenv', - version='0.2.dev0', - author='Christian Theune', - author_email='ct@flyingcircus.io', - license='BSD (3-clause)', - url='https://github.com/flyingcircusio/appenv/', - keywords='deployment', + name="appenv", + version="0.2.dev0", + author="Christian Theune", + author_email="ct@flyingcircus.io", + license="BSD (3-clause)", + url="https://github.com/flyingcircusio/appenv/", + keywords="deployment", classifiers="""\ License :: OSI Approved :: BSD License Programming Language :: Python @@ -23,11 +23,12 @@ Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3 :: Only -"""[:-1].split('\n'), +"""[:-1].split("\n"), description=__doc__.strip(), - packages=find_packages('src'), - package_dir={'': 'src'}, + packages=find_packages("src"), + package_dir={"": "src"}, include_package_data=True, zip_safe=True, - python_requires='>=3.6', - extras_require={'test': {'pytest'}}) + python_requires=">=3.6", + extras_require={"test": {"pytest"}}, +) diff --git a/src/appenv.py b/src/appenv.py index bce7837..a850ab4 100755 --- a/src/appenv.py +++ b/src/appenv.py @@ -30,10 +30,11 @@ class TColors: """Terminal colors for pretty output.""" - RED = '\033[91m' - GREEN = '\033[92m' - YELLOW = '\033[93m' - RESET = '\033[0m' + + RED = "\033[91m" + GREEN = "\033[92m" + YELLOW = "\033[93m" + RESET = "\033[0m" def cmd(c, merge_stderr=True, quiet=False): @@ -109,30 +110,35 @@ def ensure_venv(target): try: download = os.path.join(tmp_base, "download.tar.gz") with open(download, mode="wb") as f: - get("www.python.org", - "/ftp/python/{v}/Python-{v}.tgz".format(v=version), f) + get( + "www.python.org", + "/ftp/python/{v}/Python-{v}.tgz".format(v=version), + f, + ) cmd(["tar", "xf", download, "-C", tmp_base]) - assert os.path.exists( - os.path.join(tmp_base, "Python-{}".format(version))) + assert os.path.exists(os.path.join(tmp_base, "Python-{}".format(version))) for module in ["ensurepip"]: print(module) shutil.copytree( - os.path.join(tmp_base, "Python-{}".format(version), "Lib", - module), - os.path.join(target, "lib", - "python{}.{}".format(*sys.version_info[:2]), - "site-packages", module)) + os.path.join(tmp_base, "Python-{}".format(version), "Lib", module), + os.path.join( + target, + "lib", + "python{}.{}".format(*sys.version_info[:2]), + "site-packages", + module, + ), + ) # (always) prepend the site packages so we can actually have a # fixed installation. site_packages = os.path.abspath( - os.path.join(target, "lib", "python" + python_maj_min, - "site-packages")) + os.path.join(target, "lib", "python" + python_maj_min, "site-packages") + ) with open(os.path.join(site_packages, "batou.pth"), "w") as f: - f.write("import sys; sys.path.insert(0, '{}')\n".format( - site_packages)) + f.write("import sys; sys.path.insert(0, '{}')\n".format(site_packages)) finally: shutil.rmtree(tmp_base) @@ -144,15 +150,15 @@ def ensure_venv(target): def parse_preferences(): preferences = None - if os.path.exists('requirements.txt'): - with open('requirements.txt') as f: + if os.path.exists("requirements.txt"): + with open("requirements.txt") as f: for line in f: # Expected format: # # appenv-python-preference: 3.1,3.9,3.4 if not line.startswith("# appenv-python-preference: "): continue - preferences = line.split(':')[1] - preferences = [x.strip() for x in preferences.split(',')] + preferences = line.split(":")[1] + preferences = [x.strip() for x in preferences.split(",")] preferences = list(filter(None, preferences)) break return preferences @@ -168,7 +174,7 @@ def ensure_minimal_python(): print(" `# appenv-python-preference:` in requirements.txt.") return - preferences.sort(key=lambda s: [int(u) for u in s.split('.')]) + preferences.sort(key=lambda s: [int(u) for u in s.split(".")]) for version in preferences[0:1]: python = shutil.which("python{}".format(version)) @@ -181,9 +187,11 @@ def ensure_minimal_python(): break # Try whether this Python works try: - subprocess.check_call([python, "-c", "print(1)"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL) + subprocess.check_call( + [python, "-c", "print(1)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) except subprocess.CalledProcessError: continue @@ -193,8 +201,7 @@ def ensure_minimal_python(): else: print("Could not find the minimal preferred Python version.") print("To ensure a working requirements.lock on all Python versions") - print("make Python {} available on this system.".format( - preferences[0])) + print("make Python {} available on this system.".format(preferences[0])) sys.exit(66) @@ -212,13 +219,11 @@ def ensure_best_python(base): if preferences is None: if sys.version_info >= (3, 12): print("You are using a Python version >= 3.12.") - print( - "Please specify a Python version in the requirements.txt file." - ) + print("Please specify a Python version in the requirements.txt file.") print("Lockfiles created with a Python version lower than 3.12") print("may create a broken venv with a Python version >= 3.12.") # use newest Python available if nothing else is requested - preferences = ['3.{}'.format(x) for x in reversed(range(4, 20))] + preferences = ["3.{}".format(x) for x in reversed(range(4, 20))] current_python = os.path.realpath(sys.executable) for version in preferences: @@ -232,9 +237,11 @@ def ensure_best_python(base): break # Try whether this Python works try: - subprocess.check_call([python, "-c", "print(1)"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL) + subprocess.check_call( + [python, "-c", "print(1)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) except subprocess.CalledProcessError: continue argv = [os.path.basename(python)] + sys.argv @@ -242,7 +249,7 @@ def ensure_best_python(base): os.execv(python, argv) else: print("Could not find a preferred Python version.") - print("Preferences: {}".format(', '.join(preferences))) + print("Preferences: {}".format(", ".join(preferences))) sys.exit(65) @@ -295,20 +302,21 @@ def parse_requirement_string(requirement_string): # - We will not parse extras, specifiers, or markers. # check for name - name_match = re.search(f"^(?:{whitespace_regex})?{identifier_regex}", - requirement_string) + name_match = re.search( + f"^(?:{whitespace_regex})?{identifier_regex}", requirement_string + ) name = name_match.group() if name_match else None # check for URL url_match = re.search( - f"@(?:{whitespace_regex})?(?P{url_regex})" - f"(?:{whitespace_regex})?;?", requirement_string) - url = url_match.group('url') if url_match else None + f"@(?:{whitespace_regex})?(?P{url_regex})(?:{whitespace_regex})?;?", + requirement_string, + ) + url = url_match.group("url") if url_match else None return ParsedRequirement(name, url, requirement_string) class AppEnv(object): - base = None # The directory where we add the environments. Co-located # with the application script - not necessarily the appenv # script so we can link to an appenv script from multiple @@ -323,7 +331,7 @@ def __init__(self, base, original_cwd): # This used to be computed based on the application name but # as we can have multiple application names now, we always put the # environments into '.appenv'. They're hashed anyway. - self.appenv_dir = os.path.join(self.base, '.appenv') + self.appenv_dir = os.path.join(self.base, ".appenv") # Allow simplifying a lot of code by assuming that all the # meta-operations happen in the base directory. Store the original @@ -334,8 +342,7 @@ def meta(self): # Parse the appenv arguments parser = argparse.ArgumentParser() subparsers = parser.add_subparsers() - p = subparsers.add_parser( - "update-lockfile", help="Update the lock file.") + p = subparsers.add_parser("update-lockfile", help="Update the lock file.") p.set_defaults(func=self.update_lockfile) p = subparsers.add_parser("init", help="Create a new appenv project.") @@ -344,53 +351,57 @@ def meta(self): p = subparsers.add_parser("reset", help="Reset the environment.") p.set_defaults(func=self.reset) - p = subparsers.add_parser('prepare', help='Prepare the venv.') + p = subparsers.add_parser("prepare", help="Prepare the venv.") p.set_defaults(func=self.prepare) p = subparsers.add_parser( - "python", help="Spawn the embedded Python interpreter REPL") + "python", help="Spawn the embedded Python interpreter REPL" + ) p.set_defaults(func=self.python) p = subparsers.add_parser( - "run", - help="Run a script from the bin/ directory of the virtual env.") + "run", help="Run a script from the bin/ directory of the virtual env." + ) p.add_argument("script", help="Name of the script to run.") p.set_defaults(func=self.run_script) args, remaining = parser.parse_known_args() - if not hasattr(args, 'func'): + if not hasattr(args, "func"): parser.print_usage() else: args.func(args, remaining) def run(self, command, argv): self.prepare() - cmd = os.path.join(self.env_dir, 'bin', command) + cmd = os.path.join(self.env_dir, "bin", command) argv = [cmd] + argv - os.environ['APPENV_BASEDIR'] = self.base + os.environ["APPENV_BASEDIR"] = self.base os.chdir(self.original_cwd) os.execv(cmd, argv) def _assert_requirements_lock(self): - if not os.path.exists('requirements.lock'): - print('No requirements.lock found. Generate it using' - ' ./appenv update-lockfile') + if not os.path.exists("requirements.lock"): + print( + "No requirements.lock found. Generate it using ./appenv update-lockfile" + ) sys.exit(67) - with open('requirements.lock') as f: + with open("requirements.lock") as f: locked_hash = None for line in f: if line.startswith("# appenv-requirements-hash: "): - locked_hash = line.split(':')[1].strip() + locked_hash = line.split(":")[1].strip() break if locked_hash != self._hash_requirements(): - print('requirements.txt seems out of date (hash mismatch). ' - 'Regenerate using ./appenv update-lockfile') + print( + "requirements.txt seems out of date (hash mismatch). " + "Regenerate using ./appenv update-lockfile" + ) sys.exit(67) def _hash_requirements(self): - with open('requirements.txt', 'rb') as f: + with open("requirements.txt", "rb") as f: hash_content = f.read() return hashlib.new("sha256", hash_content).hexdigest() @@ -411,16 +422,17 @@ def prepare(self, args=None, remaining=None): hash_content.append(requirements) with open(__file__, "rb") as f: hash_content.append(f.read()) - env_hash = hashlib.new("sha256", - b"".join(hash_content)).hexdigest()[:8] + env_hash = hashlib.new("sha256", b"".join(hash_content)).hexdigest()[:8] env_dir = os.path.join(self.appenv_dir, env_hash) - whitelist = set([ - env_dir, - os.path.join(self.appenv_dir, "unclean"), - os.path.join(self.appenv_dir, 'current')]) - for path in glob.glob( - "{appenv_dir}/*".format(appenv_dir=self.appenv_dir)): + whitelist = set( + [ + env_dir, + os.path.join(self.appenv_dir, "unclean"), + os.path.join(self.appenv_dir, "current"), + ] + ) + for path in glob.glob("{appenv_dir}/*".format(appenv_dir=self.appenv_dir)): if path not in whitelist: print("Removing expired path: {path} ...".format(path=path)) if not os.path.isdir(path): @@ -433,8 +445,7 @@ def prepare(self, args=None, remaining=None): # interruptions to running services, but that isn't what we're # using it for at the moment try: - if not os.path.exists( - "{env_dir}/appenv.ready".format(env_dir=env_dir)): + if not os.path.exists("{env_dir}/appenv.ready".format(env_dir=env_dir)): raise Exception() except Exception: print("Existing envdir not consistent, deleting") @@ -447,14 +458,20 @@ def prepare(self, args=None, remaining=None): f.write(requirements) print("Installing ...") - pip(env_dir, [ - "install", "--no-deps", "-r", - "{env_dir}/requirements.lock".format(env_dir=env_dir)]) + pip( + env_dir, + [ + "install", + "--no-deps", + "-r", + "{env_dir}/requirements.lock".format(env_dir=env_dir), + ], + ) pip(env_dir, ["check"]) with open(os.path.join(env_dir, "appenv.ready"), "w") as f: f.write("Ready or not, here I come, you can't hide\n") - current_path = os.path.join(self.appenv_dir, 'current') + current_path = os.path.join(self.appenv_dir, "current") try: os.unlink(current_path) except FileNotFoundError: @@ -469,14 +486,14 @@ def init(self, args=None, remaining=None): while not command: command = input("What should the command be named? ").strip() dependency = input( - "What is the main dependency as found on PyPI? [{}] ".format( - command)).strip() + "What is the main dependency as found on PyPI? [{}] ".format(command) + ).strip() if not dependency: dependency = command - default_target = os.path.abspath( - os.path.join(self.original_cwd, command)) - target = input("Where should we create this? [{}] ".format( - default_target)).strip() + default_target = os.path.abspath(os.path.join(self.original_cwd, command)) + target = input( + "Where should we create this? [{}] ".format(default_target) + ).strip() if target: target = os.path.join(self.original_cwd, target) else: @@ -489,21 +506,23 @@ def init(self, args=None, remaining=None): with open(__file__, "rb") as bootstrap_file: bootstrap_data = bootstrap_file.read() os.chdir(target) - with open('appenv', "wb") as new_appenv: + with open("appenv", "wb") as new_appenv: new_appenv.write(bootstrap_data) - os.chmod('appenv', 0o755) + os.chmod("appenv", 0o755) if os.path.exists(command): os.unlink(command) - os.symlink('appenv', command) + os.symlink("appenv", command) with open("requirements.txt", "w") as requirements_txt: requirements_txt.write(dependency + "\n") print() - print("Done. You can now `cd {}` and call" - " `./{}` to bootstrap and run it.".format( - os.path.relpath(target, self.original_cwd), command)) + print( + "Done. You can now `cd {}` and call `./{}` to bootstrap and run it.".format( + os.path.relpath(target, self.original_cwd), command + ) + ) def python(self, args, remaining): - self.run('python', remaining) + self.run("python", remaining) def run_script(self, args, remaining): self.run(args.script, remaining) @@ -511,7 +530,9 @@ def run_script(self, args, remaining): def reset(self, args=None, remaining=None): print( "Resetting ALL application environments in {appenvdir} ...".format( - appenvdir=self.appenv_dir)) + appenvdir=self.appenv_dir + ) + ) cmd(["rm", "-rf", self.appenv_dir]) def update_lockfile(self, args=None, remaining=None): @@ -527,32 +548,31 @@ def update_lockfile(self, args=None, remaining=None): extra_specs = [] result = pip( - tmpdir, ["freeze", "--all", "--exclude", "pip"], - merge_stderr=False).decode('ascii') + tmpdir, ["freeze", "--all", "--exclude", "pip"], merge_stderr=False + ).decode("ascii") # They changed this behaviour in https://github.com/pypa/pip/pull/12032 pinned_versions = {} for line in result.splitlines(): - if line.strip().startswith('-e '): + if line.strip().startswith("-e "): # We'd like to pick up the original -e statement here. continue parsed_requirement = parse_requirement_string(line) pinned_versions[parsed_requirement.name] = parsed_requirement requested_versions = {} - with open('requirements.txt') as f: + with open("requirements.txt") as f: for line in f.readlines(): - if line.strip().startswith('-e '): + if line.strip().startswith("-e "): extra_specs.append(line.strip()) continue - if line.strip().startswith('--'): + if line.strip().startswith("--"): extra_specs.append(line.strip()) continue # filter comments, in particular # appenv-python-preferences - if line.strip().startswith('#'): + if line.strip().startswith("#"): continue parsed_requirement = parse_requirement_string(line) - requested_versions[ - parsed_requirement.name] = parsed_requirement + requested_versions[parsed_requirement.name] = parsed_requirement final_versions = {} for spec in requested_versions.values(): @@ -569,10 +589,11 @@ def update_lockfile(self, args=None, remaining=None): lines.extend(extra_specs) lines.sort() with open(os.path.join(self.base, "requirements.lock"), "w") as f: - f.write('# appenv-requirements-hash: {}\n'.format( - self._hash_requirements())) - f.write('\n'.join(lines)) - f.write('\n') + f.write( + "# appenv-requirements-hash: {}\n".format(self._hash_requirements()) + ) + f.write("\n".join(lines)) + f.write("\n") cmd(["rm", "-rf", tmpdir]) @@ -591,7 +612,7 @@ def main(): application_name = os.path.splitext(os.path.basename(__file__))[0] appenv = AppEnv(base, original_cwd) - if application_name == 'appenv': + if application_name == "appenv": appenv.meta() else: appenv.run(application_name, sys.argv[1:]) diff --git a/tests/test_init.py b/tests/test_init.py index 704d87b..ce48fcb 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -9,10 +9,10 @@ def test_init(workdir, monkeypatch): assert not os.path.exists(os.path.join(workdir, "ducker")) - env = appenv.AppEnv(os.path.join(workdir, 'ducker'), os.getcwd()) + env = appenv.AppEnv(os.path.join(workdir, "ducker"), os.getcwd()) env.init() - assert os.readlink(os.path.join(workdir, "ducker", "ducker")) == 'appenv' + assert os.readlink(os.path.join(workdir, "ducker", "ducker")) == "appenv" with open(os.path.join(workdir, "ducker", "appenv")) as f: ducker_appenv = f.read() @@ -29,7 +29,7 @@ def test_init(workdir, monkeypatch): monkeypatch.setattr("sys.stdin", io.StringIO("ducker\n\n\n")) env.init() - assert os.readlink(os.path.join(workdir, "ducker", "ducker")) == 'appenv' + assert os.readlink(os.path.join(workdir, "ducker", "ducker")) == "appenv" with open(os.path.join(workdir, "ducker", "appenv")) as f: ducker_appenv = f.read() @@ -46,7 +46,7 @@ def test_init(workdir, monkeypatch): def test_init_explicit_target(workdir, monkeypatch): monkeypatch.setattr("sys.stdin", io.StringIO("ducker\n\nbaz\n")) - env = appenv.AppEnv(os.path.join(workdir, 'ducker'), os.getcwd()) + env = appenv.AppEnv(os.path.join(workdir, "ducker"), os.getcwd()) env.init() assert os.path.exists(os.path.join(workdir, "baz")) @@ -65,7 +65,7 @@ def test_init_explicit_target(workdir, monkeypatch): def test_init_explicit_package_and_target(workdir, monkeypatch): monkeypatch.setattr("sys.stdin", io.StringIO("foo\nbar\nbaz\n")) - env = appenv.AppEnv(os.path.join(workdir, 'ducker'), os.getcwd()) + env = appenv.AppEnv(os.path.join(workdir, "ducker"), os.getcwd()) env.init() assert os.path.exists(os.path.join(workdir, "baz")) diff --git a/tests/test_prepare.py b/tests/test_prepare.py index e199950..8af0528 100644 --- a/tests/test_prepare.py +++ b/tests/test_prepare.py @@ -4,10 +4,10 @@ def test_prepare_creates_envdir(workdir, monkeypatch): - monkeypatch.setattr('sys.stdin', io.StringIO('ducker\nducker<2.0.2\n\n')) - os.makedirs(os.path.join(workdir, 'ducker')) + monkeypatch.setattr("sys.stdin", io.StringIO("ducker\nducker<2.0.2\n\n")) + os.makedirs(os.path.join(workdir, "ducker")) - env = appenv.AppEnv(os.path.join(workdir, 'ducker'), os.getcwd()) + env = appenv.AppEnv(os.path.join(workdir, "ducker"), os.getcwd()) env.init() assert not os.path.exists(env.appenv_dir) env.update_lockfile() @@ -17,13 +17,14 @@ def test_prepare_creates_envdir(workdir, monkeypatch): def test_prepare_creates_venv_symlink(workdir, monkeypatch): # asserts that appenv_dir / "current" -> env_dir - monkeypatch.setattr('sys.stdin', io.StringIO('ducker\nducker<2.0.2\n\n')) - os.makedirs(os.path.join(workdir, 'ducker')) + monkeypatch.setattr("sys.stdin", io.StringIO("ducker\nducker<2.0.2\n\n")) + os.makedirs(os.path.join(workdir, "ducker")) - env = appenv.AppEnv(os.path.join(workdir, 'ducker'), os.getcwd()) + env = appenv.AppEnv(os.path.join(workdir, "ducker"), os.getcwd()) env.init() env.update_lockfile() env.prepare() assert os.path.islink(os.path.join(env.appenv_dir, "current")) - assert (os.path.realpath(os.path.join( - env.appenv_dir, "current")) == os.path.realpath(env.env_dir)) + assert os.path.realpath( + os.path.join(env.appenv_dir, "current") + ) == os.path.realpath(env.env_dir) diff --git a/tests/test_reset.py b/tests/test_reset.py index a93d5a9..c70a106 100644 --- a/tests/test_reset.py +++ b/tests/test_reset.py @@ -4,7 +4,7 @@ def test_reset_nonexisting_envdir_silent(tmpdir): - env = appenv.AppEnv(os.path.join(tmpdir, 'ducker'), os.getcwd()) + env = appenv.AppEnv(os.path.join(tmpdir, "ducker"), os.getcwd()) assert not os.path.exists(env.appenv_dir) env.reset() assert not os.path.exists(env.appenv_dir) @@ -12,7 +12,7 @@ def test_reset_nonexisting_envdir_silent(tmpdir): def test_reset_removes_envdir_with_subdirs(tmpdir): - env = appenv.AppEnv(os.path.join(tmpdir, 'ducker'), os.getcwd()) + env = appenv.AppEnv(os.path.join(tmpdir, "ducker"), os.getcwd()) os.makedirs(env.appenv_dir) assert os.path.exists(env.appenv_dir) env.reset() diff --git a/tests/test_run.py b/tests/test_run.py index 8607220..dba7dc9 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -18,7 +18,7 @@ def test_bootstrap_lockfile_missing_dependency(): def test_bootstrap_and_run_with_lockfile(workdir, monkeypatch): monkeypatch.setattr("sys.stdin", io.StringIO("ducker\nducker==2.0.1\n\n")) - env = appenv.AppEnv(os.path.join(workdir, 'ducker'), os.getcwd()) + env = appenv.AppEnv(os.path.join(workdir, "ducker"), os.getcwd()) env.init() env.update_lockfile() @@ -37,7 +37,7 @@ def test_bootstrap_and_run_with_lockfile(workdir, monkeypatch): def test_bootstrap_and_run_python_with_lockfile(workdir, monkeypatch): monkeypatch.setattr("sys.stdin", io.StringIO("ducker\nducker==2.0.1\n\n")) - env = appenv.AppEnv(os.path.join(workdir, 'ducker'), os.getcwd()) + env = appenv.AppEnv(os.path.join(workdir, "ducker"), os.getcwd()) env.init() env.update_lockfile() @@ -48,8 +48,7 @@ def test_bootstrap_and_run_python_with_lockfile(workdir, monkeypatch): with open("ducker", "w") as f: f.write(script) - output = subprocess.check_output( - './appenv python -c "print(1)"', shell=True) + output = subprocess.check_output('./appenv python -c "print(1)"', shell=True) assert output == b"1\n" @@ -57,7 +56,7 @@ def test_bootstrap_and_run_without_lockfile(workdir, monkeypatch): """It raises as error if no requirements.lock is present.""" monkeypatch.setattr("sys.stdin", io.StringIO("ducker\nducker==2.0.1\n\n")) - env = appenv.AppEnv(os.path.join(workdir, 'ducker'), os.getcwd()) + env = appenv.AppEnv(os.path.join(workdir, "ducker"), os.getcwd()) env.init() @@ -71,14 +70,14 @@ def test_bootstrap_and_run_without_lockfile(workdir, monkeypatch): with pytest.raises(subprocess.CalledProcessError) as err: subprocess.check_output(["./ducker", "--help"]) assert err.value.output == ( - b"No requirements.lock found. Generate it using" - b" ./appenv update-lockfile\n") + b"No requirements.lock found. Generate it using ./appenv update-lockfile\n" + ) def test_bootstrap_and_run_with_outdated_lockfile(workdir, monkeypatch): monkeypatch.setattr("sys.stdin", io.StringIO("ducker\nducker==2.0.1\n\n")) - env = appenv.AppEnv(os.path.join(workdir, 'ducker'), os.getcwd()) + env = appenv.AppEnv(os.path.join(workdir, "ducker"), os.getcwd()) env.init() env.update_lockfile() @@ -89,22 +88,24 @@ def test_bootstrap_and_run_with_outdated_lockfile(workdir, monkeypatch): with open("ducker", "w") as f: f.write(script) - output = subprocess.check_output( - './appenv python -c "print(1)"', shell=True) + output = subprocess.check_output('./appenv python -c "print(1)"', shell=True) assert output == b"1\n" - with open("requirements.txt", 'w') as f: - f.write('ducker==2.0.1') + with open("requirements.txt", "w") as f: + f.write("ducker==2.0.1") s = subprocess.Popen( - './appenv python -c "print(1)"', shell=True, stdout=subprocess.PIPE) + './appenv python -c "print(1)"', shell=True, stdout=subprocess.PIPE + ) stdout, stderr = s.communicate() - assert stdout == b"""\ + assert ( + stdout + == b"""\ requirements.txt seems out of date (hash mismatch). Regenerate using ./appenv update-lockfile -""" # noqa +""" + ) # noqa - subprocess.check_call('./appenv update-lockfile', shell=True) + subprocess.check_call("./appenv update-lockfile", shell=True) - output = subprocess.check_output( - './appenv python -c "print(1)"', shell=True) + output = subprocess.check_output('./appenv python -c "print(1)"', shell=True) assert output == b"1\n" diff --git a/tests/test_update_lockfile.py b/tests/test_update_lockfile.py index c618f4f..89bef0d 100644 --- a/tests/test_update_lockfile.py +++ b/tests/test_update_lockfile.py @@ -8,9 +8,9 @@ def test_init_and_create_lockfile(workdir, monkeypatch): - monkeypatch.setattr('sys.stdin', io.StringIO('ducker\nducker<2.0.2\n\n')) + monkeypatch.setattr("sys.stdin", io.StringIO("ducker\nducker<2.0.2\n\n")) - env = appenv.AppEnv(os.path.join(workdir, 'ducker'), os.getcwd()) + env = appenv.AppEnv(os.path.join(workdir, "ducker"), os.getcwd()) env.init() lockfile = os.path.join(workdir, "ducker", "requirements.lock") @@ -21,19 +21,20 @@ def test_init_and_create_lockfile(workdir, monkeypatch): assert os.path.exists(lockfile) with open(lockfile) as f: lockfile_content = f.read() - assert """\ + assert ( + """\ # appenv-requirements-hash: ffa75c00de4879b41008d0e9f6b9953cf7d65bb5f5b85d1d049e783b2486614d -ducker==2.0.1""" in lockfile_content # noqa +ducker==2.0.1""" + in lockfile_content + ) # noqa -@pytest.mark.skipif( - sys.version_info[0:2] != (3, 6), reason='Isolated CI builds') +@pytest.mark.skipif(sys.version_info[0:2] != (3, 6), reason="Isolated CI builds") def test_update_lockfile_minimal_python(workdir, monkeypatch): """It uses the minimal python version even if it is not best python.""" - monkeypatch.setattr('sys.stdin', - io.StringIO('pytest\npytest==6.1.2\nppytest\n')) + monkeypatch.setattr("sys.stdin", io.StringIO("pytest\npytest==6.1.2\nppytest\n")) - env = appenv.AppEnv(os.path.join(workdir, 'ppytest'), os.getcwd()) + env = appenv.AppEnv(os.path.join(workdir, "ppytest"), os.getcwd()) env.init() lockfile = os.path.join(workdir, "ppytest", "requirements.lock") @@ -58,14 +59,12 @@ def test_update_lockfile_minimal_python(workdir, monkeypatch): assert "typing-extensions==" in lockfile_content -@pytest.mark.skipif( - sys.version_info[0:2] < (3, 8), reason='Isolated CI builds') +@pytest.mark.skipif(sys.version_info[0:2] < (3, 8), reason="Isolated CI builds") def test_update_lockfile_missing_minimal_python(workdir, monkeypatch): """It raises an error if the minimal python is not available.""" - monkeypatch.setattr('sys.stdin', - io.StringIO('pytest\npytest==6.1.2\nppytest\n')) + monkeypatch.setattr("sys.stdin", io.StringIO("pytest\npytest==6.1.2\nppytest\n")) - env = appenv.AppEnv(os.path.join(workdir, 'ppytest'), os.getcwd()) + env = appenv.AppEnv(os.path.join(workdir, "ppytest"), os.getcwd()) env.init() requirements_file = os.path.join(workdir, "ppytest", "requirements.txt") @@ -84,7 +83,7 @@ def new_which(string): else: return old_which(string) - with unittest.mock.patch('shutil.which') as which: + with unittest.mock.patch("shutil.which") as which: which.side_effect = new_which with pytest.raises(SystemExit) as e: env.update_lockfile() @@ -98,10 +97,12 @@ def test_parse_requirement_name(): "foo[bar,baz]~=1.0", "foo==1.0", "foo[bar,baz]!=1.0", - "foo<1.0",] + "foo<1.0", + ] req_strings_with_url = [ "foo[bar,baz] @ https://example.com", - "foo[bar,baz] @ https://example.com ; python_version < '3.6'",] + "foo[bar,baz] @ https://example.com ; python_version < '3.6'", + ] for req_string in req_strings_without_url: req = appenv.parse_requirement_string(req_string) assert req.name == "foo" From cd30f6536b952d86176c4be7a9b0668e1a6c9cac Mon Sep 17 00:00:00 2001 From: Tobias Stenzel Date: Tue, 28 Apr 2026 09:38:54 +0200 Subject: [PATCH 002/129] feat: migrate project to uv, add Sphinx docs, restructure tests Replace setup.py/setup.cfg/pytest.ini/tox.ini/requirements.txt with pyproject.toml + uv.lock. Add new CLI commands: migrate, version, uv. Improve init to merge into existing pyproject.toml and support --path/uvx. Deprecate 'run' subcommand in favor of uv run and symlink dispatch. Fix symlink handling: helpful errors when binary not found, warn on non-symlink .venv, clean up dangling current symlinks after migration. Add Sphinx documentation with Furo theme, MyST parser, and autoapi: user guide (commands, installation, workflows), developer guide (architecture, contributing), and .readthedocs.yaml. Restructure test suite with pytest markers (unit/integration/slow), new test modules (test_main, test_migrate, test_uv_bin, test_cli, test_subprocess), and .pyi type stubs for all test modules. Consolidate CI workflows (lint.yml into main.yml), remove bootstrap scripts, update example from requirements.txt to pyproject.toml. Quality audit: ruff green, ty green, tests green, e2e orange (B+ 87/100). --- .../2026-04-25_quality-audit.md | 100 + .github/workflows/lint.yml | 41 - .github/workflows/main.yml | 73 +- .gitignore | 123 +- .pre-commit-config.yaml | 30 +- .readthedocs.yaml | 24 + .vulture_exclude | 11 + README.md | 170 +- bootstrap | 14 - bootstrap-dev.sh | 10 - docs-validation-fix-plan.md | 334 ++ docs-validation-report.md | 89 + docs/.gitignore | 7 + docs/Makefile | 27 + docs/conf.py | 156 + docs/dev-guide/architecture.md | 128 + docs/dev-guide/contributing.md | 105 + docs/index.md | 86 + docs/llms.txt | 90 + docs/user-guide/commands.md | 262 ++ docs/user-guide/installation.md | 117 + docs/user-guide/locking-behavior.md | 66 + docs/user-guide/workflows.md | 267 ++ example/http | 1 + example/pyproject.toml | 8 + example/requirements.lock | 2 - example/requirements.txt | 1 - example/uv.lock | 418 +++ pyproject.toml | 199 ++ pytest.ini | 3 - requirements.txt | 4 - setup.cfg | 7 - setup.py | 34 - src/appenv.py | 1748 ++++++++--- src/appenv.pyi | 211 ++ src/py.typed | 0 tests/__init__.py | 0 tests/conftest.py | 108 +- tests/conftest.pyi | 27 + tests/integration/__init__.py | 0 tests/integration/test_cli.py | 241 ++ tests/integration/test_subprocess.py | 131 + tests/test_docs_spec.py | 189 ++ tests/test_init.py | 421 ++- tests/test_init.pyi | 31 + tests/test_main.py | 1151 +++++++ tests/test_main.pyi | 68 + tests/test_migrate.py | 403 +++ tests/test_migrate.pyi | 29 + tests/test_prepare.py | 996 +++++- tests/test_prepare.pyi | 64 + tests/test_reset.py | 172 +- tests/test_reset.pyi | 17 + tests/test_run.py | 111 - tests/test_update_lockfile.py | 458 ++- tests/test_update_lockfile.pyi | 32 + tests/test_uv_bin.py | 986 ++++++ tests/test_uv_bin.pyi | 76 + tests/test_venv.py | 36 - tox.ini | 24 - uv.lock | 2710 +++++++++++++++++ 61 files changed, 12262 insertions(+), 1185 deletions(-) create mode 100644 .agents/reports/quality-audit.md/2026-04-25_quality-audit.md delete mode 100644 .github/workflows/lint.yml create mode 100644 .readthedocs.yaml create mode 100644 .vulture_exclude delete mode 100755 bootstrap delete mode 100755 bootstrap-dev.sh create mode 100644 docs-validation-fix-plan.md create mode 100644 docs-validation-report.md create mode 100644 docs/.gitignore create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/dev-guide/architecture.md create mode 100644 docs/dev-guide/contributing.md create mode 100644 docs/index.md create mode 100644 docs/llms.txt create mode 100644 docs/user-guide/commands.md create mode 100644 docs/user-guide/installation.md create mode 100644 docs/user-guide/locking-behavior.md create mode 100644 docs/user-guide/workflows.md create mode 120000 example/http create mode 100644 example/pyproject.toml delete mode 100644 example/requirements.lock delete mode 100644 example/requirements.txt create mode 100644 example/uv.lock create mode 100644 pyproject.toml delete mode 100644 pytest.ini delete mode 100644 requirements.txt delete mode 100644 setup.cfg delete mode 100644 setup.py create mode 100644 src/appenv.pyi create mode 100644 src/py.typed create mode 100644 tests/__init__.py create mode 100644 tests/conftest.pyi create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_cli.py create mode 100644 tests/integration/test_subprocess.py create mode 100644 tests/test_docs_spec.py create mode 100644 tests/test_init.pyi create mode 100644 tests/test_main.py create mode 100644 tests/test_main.pyi create mode 100644 tests/test_migrate.py create mode 100644 tests/test_migrate.pyi create mode 100644 tests/test_prepare.pyi create mode 100644 tests/test_reset.pyi delete mode 100644 tests/test_run.py create mode 100644 tests/test_update_lockfile.pyi create mode 100644 tests/test_uv_bin.py create mode 100644 tests/test_uv_bin.pyi delete mode 100644 tests/test_venv.py delete mode 100644 tox.ini create mode 100644 uv.lock diff --git a/.agents/reports/quality-audit.md/2026-04-25_quality-audit.md b/.agents/reports/quality-audit.md/2026-04-25_quality-audit.md new file mode 100644 index 0000000..52bd27f --- /dev/null +++ b/.agents/reports/quality-audit.md/2026-04-25_quality-audit.md @@ -0,0 +1,100 @@ +# Quality Audit Report — 2026-04-25 + +## Human Summary + +The quality meta-audit of appenv found a well-maintained project with trustworthy quality gates. The tool tolerance audit revealed no error-hiding — the ruff config is well-curated with only 1 cosmetic E501 violation, 0 type ignores, and 2 justified noqa comments. The test suite is exceptional: 0 mocks, 4.8:1 test-to-source ratio, and genuine integration tests using pexpect and subprocess. Two real bugs were found and fixed: an E501 line-length violation blocking tox CI, and unknown CLI arguments silently exiting 0 instead of non-zero. The project scores B+ (87/100), with the only notable gap being 4/9 subcommands with unit-only coverage (reset, python, run, uv). + +## Completion Checklist +- [x] Entry point inventory completed (all subcommands, scripts, APIs catalogued) +- [x] E2E smoke test completed (basic invocation tested) +- [x] All raw data collected in `.agents/tmp/quality/` (baseline/, extreme/, analysis/, e2e/) +- [x] All 4 investigation streams completed with structured review results +- [x] Tool tolerance audit produced with per-tool signals (ruff/ty/noqa) +- [x] Test structure report with mock health metrics +- [x] E2E coverage assessed for every entry point (PROVEN/SUSPECTED/UNKNOWN/BROKEN) +- [ ] Full CLI test executed (not triggered — conditions not met) +- [x] Fixes applied for critical findings (E501 + exit code bug) +- [x] Baseline re-run confirms no regressions +- [ ] Git commit: pending + +## Entry Point Inventory + +| Entry Point | Type | Source | E2E Status | Evidence | +|-------------|------|--------|------------|----------| +| update-lockfile | cli-subcommand | src/appenv.py:723 | PROVEN | test_update_lockfile.py (8 tests) + test_subprocess indirect | +| init | cli-subcommand | src/appenv.py:737 | PROVEN | test_init.py (9 tests) + test_cli.py::test_init_cli (pexpect) | +| migrate | cli-subcommand | src/appenv.py:746 | PROVEN | test_migrate.py (9 tests) + test_cli.py::test_migrate_cli (pexpect) | +| reset | cli-subcommand | src/appenv.py:757 | SUSPECTED | test_reset.py (8 tests, unit-only) | +| version | cli-subcommand | src/appenv.py:760 | PROVEN | test_main.py::test_show_version + smoke test | +| prepare | cli-subcommand | src/appenv.py:763 | PROVEN | test_prepare.py (19 tests) + test_subprocess indirect | +| python | cli-subcommand | src/appenv.py:766 | SUSPECTED | test_main.py::test_python_method_calls_run (unit-only) | +| run | cli-subcommand | src/appenv.py:771 | SUSPECTED | test_main.py (2 tests, unit-only; deprecated command) | +| uv | cli-subcommand | src/appenv.py:778 | SUSPECTED | test_prepare.py::test_run_uv_sets_environment_and_execs (unit-only) | +| run mode (symlink) | dispatch | src/appenv.py:1353 | PROVEN | test_subprocess.py::test_subprocess_main_flow | + +## Tool Tolerance Audit + +| Tool | Baseline | Extreme | Delta | Signal | +|------|----------|---------|-------|--------| +| ruff (configured) | **0 issues** (was 1, fixed) | — | 1 fix applied | green | +| ruff (--select ALL) | — | ~200+ findings | All from excluded categories: ANN (in .pyi), T201 (CLI prints), D (docstrings), COM812, S (subprocess FPs) | green | +| ty | 0 errors | — | — | green | +| noqa | 2 (SLF001) | — | Both justified (argparse internals) | green | +| type:ignore | 0 | — | — | green | + +## Test Structure + +- Total tests: 175 active / 2 slow deselected +- Distribution: unit 171, integration 4, e2e 0 (test_subprocess serves this role) +- Mock health: 0 MagicMock, 0 with spec=, 0 with autospec= (hand-written MockUvBin fake instead) +- RED FLAGS: 0/10 — no mock-only concerns +- Test-to-source ratio: 4.8:1 (6512 test lines / 1358 source lines) +- Test suppressions: 2 skipif (justified — Windows, uv availability), 0 xfail +- Signal: green + +## E2E Coverage Assessment + +- PROVEN: 5/9 subcommands (update-lockfile, init, migrate, prepare, version) + run mode +- SUSPECTED: 4/9 subcommands (reset, python, run, uv) — unit-only, no integration test +- UNKNOWN: 0 +- BROKEN: 0 +- Full CLI test triggered: NO (conditions not met) +- Signal: orange + +## Stream Signals + +- Code Architecture: green — single-file monolith by design, clean class separation +- Code Quality: green — 0 ruff errors (post-fix), 0 ty errors, minimal noqa +- Test Structure: green — 0 mocks, 4.8:1 test ratio, real integration tests +- E2E Coverage + Production Reality: orange — 5/9 PROVEN, 4/9 SUSPECTED, 2 exit-code bugs found + +## Critical Findings Fixed + +1. **E501 line too long** (src/appenv.py:834) — input prompt string exceeded 88-char limit. Fixed by splitting the input() call across lines. This also fixes the tox `fix` environment which was failing CI. + +2. **Unknown arguments exit code 0** (src/appenv.py:789-791) — `appenv --nonexistent` silently showed help and exited 0. Fixed to print "Error: unrecognized arguments: ..." and exit with EXIT_CODE_USAGE (64). + +## Code Volume + +| File | Change | +|------|--------| +| src/appenv.py | +5/-2 lines (E501 split + exit code logic) | + +## Post-Fix Quality Gates + +| Tool | Result | +|------|--------| +| ruff check | 0 issues | +| ty check | 0 errors, 0 warnings | +| pytest | 175 passed, 2 deselected | +| architecture tests | N/A (no test_architecture.py — appropriate for single-file project) | +| E2E smoke | PASS (all 10 help commands, --nonexistent exits 64) | + +## Recommendations + +1. **Medium**: Add integration tests for 4 SUSPECTED subcommands (reset, python, run, uv) — especially `reset` which performs filesystem operations +2. **Low**: Run slow tests in CI with a separate job to validate real venv creation +3. **Low**: Consider adding a `test_architecture.py` if the project grows beyond single-file scope + +## Raw Data Location +`.agents/tmp/quality/` — baseline/, extreme/, analysis/, e2e/ diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 686c4d4..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Lint - -on: - push: - pull_request: - type: [ "opened", "reopened", "synchronize" ] - -env: - FORCE_COLOR: 1 - -jobs: - build: - runs-on: ubuntu-20.04 - - steps: - - uses: actions/checkout@v2 - - - name: Cache - uses: actions/cache@v2 - with: - path: | - ~/.cache/pip - ~/.cache/pre-commit - key: - lint-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/.pre-commit-config.yaml') }} - restore-keys: | - lint-v1- - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - - name: Install dependencies - run: | - python -m pip install -U pip - python -m pip install pre-commit - - name: Lint - run: | - pre-commit run --all-files --show-diff-on-failure - env: - PRE_COMMIT_COLOR: always diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ca04670..1037bce 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,65 +1,52 @@ -# This is a basic workflow to help you get started with Actions +name: CI -name: Unit tests - -# Controls when the action will run. Triggers the workflow on push or pull request -# events but only for the master branch on: push: pull_request: - type: [ "opened", "reopened", "synchronize" ] + types: [opened, reopened, synchronize] -# A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: - # This workflow contains a single job called "build" - build-old-python: - strategy: - matrix: - python-version: ['3.6', '3.7'] + lint: + runs-on: ubuntu-24.04 - # The type of runner that the job will run on - runs-on: ubuntu-20.04 - - # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v7 with: - python-version: ${{ matrix.python-version }} + enable-cache: true - # Runs a set of commands using the runners shell - - name: Setup - run: pip install tox + - run: uv tool install pre-commit --with pre-commit-uv - - name: Show environment - run: set + - uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + + - run: pre-commit run --all-files + + - run: uv tool install tox --with tox-uv-bare --with tox-gh - - name: Test - run: tox -e py -- -vv + - run: tox -e fix - build-new-python: + test: + runs-on: ubuntu-24.04 strategy: + fail-fast: false matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - # The type of runner that the job will run on - runs-on: ubuntu-22.04 - - # Steps represent a sequence of tasks that will be executed as part of the job steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - # Runs a set of commands using the runners shell - - name: Setup - run: pip install tox + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true - - name: Show environment - run: set + - run: uv tool install tox --with tox-uv-bare --with tox-gh - - name: Test - run: tox -e py -- -vv + - run: tox diff --git a/.gitignore b/.gitignore index 381980b..9d39ebd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ -example/.ducker -bin/ -include/ -htmlcov/ -lib/ -pyvenv.cfg +# Appenv runtime directory (created on first run) +.appenv/ + +# Example project artifacts +example/.venv/ +example/.appenv/ +example/.httpie *.sublime-* @@ -12,127 +13,49 @@ __pycache__/ *.py[cod] *$py.class -# C extensions -*.so - # Distribution / packaging -.Python build/ -develop-eggs/ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ -parts/ sdist/ var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ *.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt # Unit test / coverage reports +report.xml htmlcov/ .tox/ -.nox/ .coverage .coverage.* .cache -nosetests.xml coverage.xml -*.cover -*.py,cover -.hypothesis/ +coverage.json .pytest_cache/ -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - # Sphinx documentation docs/_build/ -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - # pyenv .python-version -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ +# mypy +.mypy_cache/ -# Spyder project settings -.spyderproject -.spyproject +# ruff +.ruff_cache/ -# Rope project settings -.ropeproject +# Profiling artifacts +*.prof +*.pstats -# mkdocs documentation -/site +# Complexipy analysis artifacts +.complexipy_cache/ +complexipy_results_*.json -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json +# uv cache +.uv/ -# Pyre type checker -.pyre/ +# opencode +.agents/tmp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc455aa..038c073 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,16 +1,20 @@ +exclude: ^appenv$ + repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 hooks: - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace -- repo: https://github.com/google/yapf - rev: 'v0.40.2' # Use the sha / tag you want to point at + - id: detect-private-key + - id: check-added-large-files + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-toml + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.6 hooks: - - id: yapf - args: [-i, -p] -- repo: https://github.com/pycqa/flake8 - rev: '7.0.0' # pick a git hash / tag to point to - hooks: - - id: flake8 + - id: ruff + args: [--fix] + - id: ruff-format diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..3b693fc --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,24 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Uses uv to manage virtualenv for building the docs, see: +# https://docs.readthedocs.com/platform/stable/build-customization.html#install-dependencies-with-uv +build: + os: ubuntu-24.04 + tools: + python: "3.14" + jobs: + pre_create_environment: + - asdf plugin add uv + - asdf install uv latest + - asdf global uv latest + create_environment: + - uv venv "${READTHEDOCS_VIRTUALENV_PATH}" + install: + - UV_PROJECT_ENVIRONMENT="${READTHEDOCS_VIRTUALENV_PATH}" uv sync --frozen --group docs +sphinx: + configuration: docs/conf.py + fail_on_warning: false diff --git a/.vulture_exclude b/.vulture_exclude new file mode 100644 index 0000000..3961639 --- /dev/null +++ b/.vulture_exclude @@ -0,0 +1,11 @@ +# Vulture whitelist for false positives +# Run: vulture src/ tests/ .vulture_exclude --min-confidence=80 + +# src/appenv.py:159 - 'quiet' param in cmd() not implemented (API compat) +quiet # noqa + +# test_main.py:769 - lambda parameter must match function signature +b # noqa + +# test_update_lockfile.py:183 - pytest fixture declared but unused +tmp_path # noqa diff --git a/README.md b/README.md index f9a2bfa..0526ee2 100644 --- a/README.md +++ b/README.md @@ -1,96 +1,144 @@ # appenv -Self-contained bootstrapping/updating of Python applications deployed through shared repositories. +appenv pins Python packages to exact versions and exposes their binaries +via symlinks — one file, no installation step. Drop it into a repository, +commit it, and every checkout (local or remote) gets the same tools at the +same versions by running `./http`, `./mkdocs`, `./batou`, or whatever you need. -> The following examples use the `ducker` package to illustrate how to use ->`appenv`. `ducker` and `appenv` are not related at all. +## Quick Start -## Bootstrapping an application / project - -Use `curl -sL https://github.com/flyingcircusio/appenv/raw/master/bootstrap | sh` for bootstrapping a new project. +Drop appenv into a repository: +```bash +cd myproject +curl -sL https://raw.githubusercontent.com/flyingcircusio/appenv/master/src/appenv.py -o appenv +chmod +x appenv ``` -$ curl -sL https://github.com/flyingcircusio/appenv/raw/master/bootstrap | sh -Let's create a new appenv project. -What should the command be named? ducker -What is the main dependency as found on PyPI? [ducker] -Where should we create this? [/private/tmp/ducker] +Declare which tools you need: -Creating appenv setup in /private/tmp/ducker ... +```bash +$ ./appenv init +Let's create a new appenv project in /tmp/myproject +I'll ask a few questions, then create pyproject.toml here -Done. You can now `cd ducker` and call `./ducker` to bootstrap and run it. +Binary to expose (creates ./ symlink) [app] http +Enter dependencies (one per line, empty line to finish): + Default: http + Dependency: httpie + Dependency: +Project name [myproject]: +Description []: +Minimum Python version [3.13]: +Created pyproject.toml +Generating new lock file ... +✓ Created (+42 lines) -$ cd ducker -$ ./ducker -Running unclean installation from requirements.txt -Ensuring unclean install ... -Please initiate a query. -Ducker (? for help) q -``` +=== Appenv project initialized === + +Use `./http` to set up environment and run -## Freezing requirements for repeatable builds +$ ./http GET https://httpbin.org/get +200 +``` -Using frozen requirements makes the builds repeatable for you and your team -and also speeds up subsequent invocations: +If appenv is published on PyPI, you can skip the download: +```bash +mkdir myproject && cd myproject +uvx appenv init ``` -$ ./appenv update-lockfile -Updating lockfile -Installing packages ... -$ time ./ducker wikipedia -Installing ducker ... -./ducker wikipedia 2.91s user 0.99s system 88% cpu 4.407 total +**What just happened?** + +- `curl` downloaded a single file: `appenv` +- `init` created `pyproject.toml` and a symlink `http → appenv` +- `./http` set up the venv with pinned versions from `uv.lock`, then ran the `http` binary (from the httpie package) + +The symlink name determines which installed binary gets executed. +Create additional symlinks to expose more binaries from your dependencies: -$ time ./ducker wikpedia -./ducker wikipedia 0.22s user 0.11s system 90% cpu 0.371 total +```bash +ln -s appenv ruff +./ruff check . +``` + +Plugin-based tools work the same way — just add plugins as dependencies: +```bash +./appenv uv add mkdocs-material +./mkdocs build # theme is available immediately ``` -## Using a specific version of Python for your application +For dev tools, `uv run` works transparently (uses the `.venv` symlink): -`appenv` tries to use the best Python version available. It bootstraps with -the Python 3 interpreter available in your PATH as `python3` and then can -either detect the newest Python or select the best python of your choice. +```bash +uv run pytest -xvs +``` -Two disable the automatic detection of the newest version and provide a -list of acceptable Python versions (tried in the order you list them) -add the following line to your requirements.txt file: +The repository now contains: ``` -# appenv-python-preference: 3.6,3.9,3.8 +myproject/ +├── appenv # The appenv script (single file, committed to git) +├── http -> appenv # Runs the `http` binary from installed deps +├── pyproject.toml # Project config and dependency list +└── uv.lock # Exact versions of all dependencies (committed to git) ``` -The best version that is found on the system will be used to re-spawn appenv -and then also used to manage the virtual environments for your application. +### Using an existing appenv project -AppEnv itself is tested against Python 3.6+. - -## Learning more about appenv +Someone gave you a project that already uses appenv? Just run the command: +```bash +git clone && cd +./http # First run sets up everything automatically ``` -$ ./appenv --help -usage: appenv [-h] {update-lockfile,init,reset,prepare,python,run} ... - -positional arguments: - {update-lockfile,init,reset,prepare,python,run} - update-lockfile Update the lock file. - init Create a new appenv project. - reset Reset the environment. - prepare Prepare the venv. - python Spawn the embedded Python interpreter REPL - run Run a script from the bin/ directory of the virtual env. - -options: - -h, --help show this help message and exit + +No `uv.lock` yet? Generate it: + +```bash +./appenv update-lockfile ``` -## Testing +Only needed again after manually editing `pyproject.toml` — `uv add`/`uv remove` +update the lockfile automatically. + +### Upgrading from requirements.txt -If you want to contribute, please install `tox` and run it. +Still using `requirements.txt` instead of `pyproject.toml`? +```bash +uvx appenv migrate ``` -$ tox +## Documentation + +Full documentation at [flyingcircusio.github.io/appenv](https://flyingcircusio.github.io/appenv/): + +- [Installation](docs/user-guide/installation.md) -- how to get appenv +- [Commands Reference](docs/user-guide/commands.md) -- all commands with options +- [Workflows](docs/user-guide/workflows.md) -- common usage patterns +- [Locking Behavior](docs/user-guide/locking-behavior.md) -- how uv.lock works +- [Architecture](docs/dev-guide/architecture.md) -- internals and design +- [Contributing](docs/dev-guide/contributing.md) -- development setup + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `APPENV_VERBOSE` | Show uv commands being executed | +| `APPENV_EXTRAS` | Comma-separated dependency groups to install | +| `APPENV_BASEDIR` | Auto-set to project root | +| `APPENV_BEST_PYTHON` | Selected Python interpreter | + +## Requirements + +- Python 3.9+ (managed environments require 3.10+) +- uv 0.5.0+ (auto-installed if not found) + +## Testing + +```bash +tox ``` diff --git a/bootstrap b/bootstrap deleted file mode 100755 index c614ffd..0000000 --- a/bootstrap +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh -{ # prevent execution of partial downloads. -set -e - -oops() { - echo "$0:" "$@" >&2 - exit 1 -} - - -t="$(mktemp -d)/appenv" -curl -sL https://raw.githubusercontent.com/flyingcircusio/appenv/master/src/appenv.py -o $t || oops "failed to download appenv" -python3 $t init < /dev/tty -} diff --git a/bootstrap-dev.sh b/bootstrap-dev.sh deleted file mode 100755 index 080b995..0000000 --- a/bootstrap-dev.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh - -set -ex - -PYTHONABS=${1:-python3} -PYTHON=$(basename $PYTHONABS) - -rm -rf .Python bin lib include -$PYTHONABS -m venv . -bin/pip install --upgrade -r requirements.txt diff --git a/docs-validation-fix-plan.md b/docs-validation-fix-plan.md new file mode 100644 index 0000000..034fd40 --- /dev/null +++ b/docs-validation-fix-plan.md @@ -0,0 +1,334 @@ +# Documentation Validation Fix Plan + +Prioritized fixes for all issues found in the documentation validation report. + +--- + +## Executive Summary + +| Priority | Count | Issues | +|----------|-------|--------| +| CRITICAL | 3 | Must fix immediately | +| STRUCTURAL | 3 | Fix before next release | +| MEDIUM | 3 | Address in next sprint | +| LOW | 3 | Backlog | +| **Total** | **12** | | + +--- + +## Critical Fixes + +Issues requiring immediate action. + +### Issue #1: Python Version Mismatch + +**File:** `docs/user-guide/installation.md` +**Line:** 5 + +**Current:** +```markdown +- **Python**: 3.10 or later +``` + +**Fix:** +```markdown +- **Python**: 3.9 or later +``` + +**Rationale:** `pyproject.toml:12` specifies `requires-python = ">=3.9"`. Documentation must match implementation constraints. + +--- + +### Issue #2: Phantom Commands in Help Formatter + +**File:** `src/appenv.py` +**Lines:** 51, 53 + +**Problem:** Help formatter references commands that don't exist. + +**Current (lines 50-54):** +```python +groups = ( + ("Project", ["init", "migrate", "update-lockfile"]), + ("Venv", ["develop", "prepare", "reset"]), + ("Tools", ["python", "run", "uv"]), + ("Debug", ["version", "settings", "profiling"]), +) +``` + +**Phantom commands:** +- Line 51: `develop` — doesn't exist +- Line 53: `settings`, `profiling` — don't exist + +**Fix:** Remove non-existent commands from groups: + +```python +groups = ( + ("Project", ["init", "migrate", "update-lockfile"]), + ("Venv", ["prepare", "reset"]), + ("Tools", ["python", "run", "uv"]), + ("Debug", ["version"]), +) +``` + +**Rationale:** Commands showing in help but not executable confuse users. Either remove from help or implement the commands. + +--- + +### Issue #3: Missing `.pyi` Reference Update + +**File:** `docs/dev-guide/architecture.md` +**Line:** 12 + +**Current:** +```markdown +- **Type stubs in `.pyi` files**: Implementation lives in `src/appenv.py` with minimal type hints. Full annotations go in `src/appenv.pyi`, following PEP 561. +``` + +**Fix:** +```markdown +- **Type stubs in `.pyi` files**: Implementation lives in `src/appenv.py` with minimal type hints. Full annotations go in `../../src/appenv.pyi`, following PEP 561. +``` + +**Rationale:** Path from `docs/dev-guide/architecture.md` to `src/` is `../../src/`, not `../src/`. + +--- + +## Structural Fixes + +Index files and navigation structure. + +### Issue #4: Missing Dev Guide Index + +**File:** `docs/dev-guide/index.md` +**Status:** Does not exist + +**Fix:** Create `docs/dev-guide/index.md`: + +```markdown +# Developer Guide + +```{toctree} +:maxdepth: 2 +:caption: Developer Guide + +architecture +contributing +``` + +**Note:** Architecture is at `architecture.md`, contributing at `contributing.md` (both in `dev-guide/`). + +--- + +### Issue #5: Missing User Guide Index + +**File:** `docs/user-guide/index.md` +**Status:** Does not exist + +**Fix:** Create `docs/user-guide/index.md`: + +```markdown +# User Guide + +```{toctree} +:maxdepth: 2 +:caption: User Guide + +installation +locking-behavior +commands +workflows +``` + +--- + +### Issue #6: Orphan Snippet + +**File:** `docs/_snippets/quickstart.md` +**Status:** Not in any toctree + +**Current:** Included via `{include}` in `docs/index.md` (line 7) + +**Fix:** Either: +- (A) Add to toctree in `index.md`: + ```markdown + ## Quickstart + + ```{include} _snippets/quickstart.md + ``` + + ```{toctree} + :maxdepth: 1 + + _snippets/quickstart + ``` +- (B) Document intentional exclusion in comment + +**Recommendation:** Option A — add to toctree for full navigation. + +--- + +## Medium Priority + +Failure modes and edge cases. + +### Issue #7: Cross-Reference Fix + +**File:** `docs/dev-guide/contributing.md` +**Line:** 35 + +**Current:** +```markdown +See {doc}`architecture` for the full error handling strategy. +``` + +**Fix:** +```markdown +See {doc}`dev-guide/architecture` for the full error handling strategy. +``` + +**Rationale:** `{doc}` requires full path when not in same directory. + +--- + +### Issue #8: Missing Migrate Failure Modes + +**File:** `docs/user-guide/commands.md` +**Section:** `## migrate` (lines 77-101) + +**Current failure cases (lines 99-101):** +- No `requirements.txt` found +- `pyproject.toml` already has `[project]` section + +**Missing failure modes:** +- Unparseable lines in requirements.txt +- Conflicting extras (e.g., multiple `extra` sections) +- Invalid version specifiers + +**Fix:** Add to failure cases section: + +```markdown +### Failure Cases + +- No `requirements.txt` found — exits normally with suggestion to use `init` +- `pyproject.toml` already has `[project]` section — exits normally without changes +- Unparseable lines in requirements.txt — reports line number and parse error +- Conflicting extras — warns and skips, keeps first definition +- Invalid version specifiers — reports specific specifier and error +``` + +--- + +### Issue #9: Missing Migration Edge Cases + +**File:** `docs/user-guide/workflows.md` +**Section:** `## Migrate from requirements.txt` (lines 25-47) + +**Current content:** Basic workflow without edge cases. + +**Missing edge cases:** +- Version specifier conversion failures (e.g., unsupported operators) +- Conflicting extras between requirements.txt and generated pyproject.toml + +**Fix:** Add note after step 3: + +```markdown +### Edge Cases + +- **Version specifier conversion**: Some pip specifiers (e.g., `~=`) don't map directly to PEP 440. The tool attempts closest equivalent but review generated `pyproject.toml`. +- **Conflicting extras**: If requirements.txt specifies extras that conflict with generated `[project.optional-dependencies]`, review and merge manually. +``` + +--- + +## Low Priority + +Quality improvements. + +### Issue #10: Architecture Structure + +**File:** `docs/dev-guide/architecture.md` +**Status:** Feedback + +**Observation:** Structure mirrors source code organization. + +**Suggestion:** Consider reorganizing by reader task: +- "Reading appenv code" (entry points, flow) +- "Adding a new command" (dispatch, subparsers) +- "Debugging issues" (error codes, logging) + +**Priority:** Backlog — improves clarity but not broken. + +--- + +### Issue #11: Command Overview Table + +**File:** `docs/user-guide/commands.md` +**Status:** Enhancement + +**Suggestion:** Add discoverability table at start: + +```markdown +## Command Overview + +| Command | Purpose | +|---------|---------| +| `init` | Create new project | +| `migrate` | Convert requirements.txt | +| `prepare` | Create venv | +| `reset` | Clean venv | +| `update-lockfile` | Generate lockfile | +| `python` | REPL in venv | +| `run` | Execute venv binary | +| `uv` | Pass-through to uv | +| `version` | Show version | +``` + +**Priority:** Backlog — improves discoverability. + +--- + +### Issue #12: Quickstart Use-Case Context + +**File:** `docs/_snippets/quickstart.md` +**Status:** Enhancement + +**Missing context:** +- `Ctrl+C` handling (SIGINT exits cleanly) +- Prompt skipping (all options can be passed as arguments) + +**Suggestion:** Add note after first code block: + +```markdown +> **Tip:** Press `Ctrl+C` to exit prompts early. Pass `--help` to see all options for non-interactive use. +``` + +**Priority:** Backlog — UX improvement. + +--- + +## Files to Change + +| Priority | File | Change Type | +|----------|------|-------------| +| CRITICAL | `docs/user-guide/installation.md` | Edit line 5 | +| CRITICAL | `src/appenv.py` | Edit lines 50-54 | +| CRITICAL | `docs/dev-guide/architecture.md` | Edit line 12 | +| STRUCTURAL | `docs/dev-guide/index.md` | Create new file | +| STRUCTURAL | `docs/user-guide/index.md` | Create new file | +| STRUCTURAL | `docs/index.md` | Add toctree entry | +| MEDIUM | `docs/dev-guide/contributing.md` | Edit line 35 | +| MEDIUM | `docs/user-guide/commands.md` | Add failure modes | +| MEDIUM | `docs/user-guide/workflows.md` | Add edge cases note | +| LOW | `docs/user-guide/commands.md` | Add overview table | +| LOW | `docs/_snippets/quickstart.md` | Add tip | +| LOW | `docs/dev-guide/architecture.md` | Consider restructure | + +--- + +## Summary + +- **3 Critical fixes** must be applied before next release +- **3 Structural fixes** required for proper navigation +- **3 Medium priority** address documentation gaps +- **3 Low priority** are enhancements for backlog \ No newline at end of file diff --git a/docs-validation-report.md b/docs-validation-report.md new file mode 100644 index 0000000..1fe4c7c --- /dev/null +++ b/docs-validation-report.md @@ -0,0 +1,89 @@ +# Documentation Validation Report + +## Summary + +- **Factual issues**: 5 +- **Structural violations**: 3 (SVR-03, SVR-06, SVR-08 failed) +- **Quality score**: 51/100 — Grade: F + +--- + +## Track A: Factual Validation Results + +### API Coverage Issues + +1. **Phantom command in help: `develop`** - /srv/s-dev/git/appenv/src/appenv.py:51 + - GroupedHelpFormatter lists "develop" in the "Venv" group, but no corresponding add_parser("develop") exists + +2. **Phantom command in help: `settings`** - /srv/s-dev/git/appenv/src/appenv.py:53 + - GroupedHelpFormatter lists "settings" in the "Debug" group, but no corresponding parser exists + +3. **Phantom command in help: `profiling`** - /srv/s-dev/git/appenv/src/appenv.py:53 + - GroupedHelpFormatter lists "profiling" in the "Debug" group, but no corresponding parser exists + +4. **Documentation states .pyi file exists** - /srv/s-dev/git/appenv/docs/dev-guide/architecture.md:12 + - Documentation claims src/appenv.pyi exists but only appenv.py is present + +### Test Coverage Issues + +NO ISSUES - Comprehensive test suite exists covering all major functionality + +### Code Example Issues + +NO ISSUES - Code blocks are appropriate CLI examples, not executable Python requiring imports + +### Internal Consistency Issues + +1. **Potentially broken cross-reference** - /srv/s-dev/git/appenv/docs/dev-guide/contributing.md:35 + - References {doc}`architecture` but may need {doc}`dev-guide/architecture` + +--- + +## Track B: Structural Validation Results + +| Rule | Status | Details | +|------|--------|---------| +| SVR-01 | PASS | All 8 files use kebab-case | +| SVR-02 | PASS | All directories use kebab-case (dev-guide/, user-guide/, _snippets/, autoapi/) | +| SVR-03 | FAIL | **Missing index.md with toctree**: dev-guide/ and user-guide/ directories have no index.md | +| SVR-04 | PASS | Max depth: 2 levels (e.g., docs/user-guide/installation.md) | +| SVR-05 | PASS | Top-level dirs organized by reader need: user-guide/, dev-guide/ | +| SVR-06 | FAIL | **Orphan file**: docs/_snippets/quickstart.md not referenced by any toctree | +| SVR-07 | PASS | Root clean: only index.md at docs root | +| SVR-08 | FAIL | No index.md in dev-guide/ or user-guide/ subdirectories | +| SVR-09 | PASS | autoapi_type="python", autoapi_dirs=["../src"], autoapi_file_patterns=["*.py"] | +| SVR-10 | PASS | All required extensions present | +| SVR-11 | N/A | Sphinx not available in environment | +| SVR-12 | PASS | autoapi/src/appenv/index.rst exists (9KB generated) | + +--- + +## Phase 2: Qualitative Audit Results + +**Score**: 51/100 — Grade: F + +### Critical Findings + +- [Art. 3] /srv/s-dev/git/appenv/docs/user-guide/installation.md:5 — False confidence: States "Python 3.10 or later" but pyproject.toml:12 specifies requires-python = ">=3.9". Documentation claims 3.10 minimum when code supports 3.9. + +### Warnings + +- [Art. 3] /srv/s-dev/git/appenv/docs/user-guide/installation.md:6 — No version anchor for uv minimum. States "0.5.0 or later" but doesn't say "as of current version". + +- [Art. 5] /srv/s-dev/git/appenv/docs/user-guide/workflows.md:85 — Unverified claim: ./appenv uv lock --upgrade-package requests documented but no verification this produces expected behavior. + +- [Art. 5] /srv/s-dev/git/appenv/docs/user-guide/commands.md:97-100 — Missing failure modes. Describes what migrate DOES but not what happens when requirements.txt has unparseable lines. + +- [Art. 2] /srv/s-dev/git/appenv/docs/dev-guide/architecture.md — Structure mirrors source. Organized by components (Python Version Selection, uv Management, Venv Lifecycle) not by reader tasks. + +- [Art. 3] /srv/s-dev/git/appenv/docs/user-guide/workflows.md — No audience decision. Mixes basic walkthrough with advanced technical details. + +- [Art. 5] /srv/s-dev/git/appenv/docs/user-guide/workflows.md:25-47 — Missing edge cases. Migration doesn't document: version specifier conversion failures, conflicting extras. + +### Suggestions + +- [Art. 7] /srv/s-dev/git/appenv/docs/user-guide/commands.md — Discoverability: No command overview table at start. + +- [Art. 1] /srv/s-dev/git/appenv/docs/user-guide/workflows.md:88 — Minor fluff: "within version constraints" is self-evident. + +- [Art. 7] /srv/s-dev/git/appenv/docs/_snippets/quickstart.md:10 — Missing use-case context: Doesn't explain what happens if you ctrl+C or skip prompts. \ No newline at end of file diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..07bef47 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,7 @@ +# Sphinx build artifacts +_build/ +autoapi/ + +# OS files +.DS_Store +Thumbs.db diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..890f6d7 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,27 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = uv run --group docs sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +.PHONY: all help clean html + +all: html + +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(SPHINXOPTS) $(SOURCEDIR) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +# Catch-all target: route all unknown targets to Sphinx +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..00324f6 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,156 @@ +"""Sphinx configuration for appenv documentation.""" + +import sys +from pathlib import Path + +# Add src to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +# Project information +project = "appenv" +copyright = "2026, Flying Circus" +author = "Christian Theune" + +# Read version from source +version_path = Path(__file__).parent.parent / "src" / "appenv.py" +for line in version_path.read_text().splitlines(): + if line.startswith("__version__"): + release = version = line.split('"')[1] + break +else: + release = version = "dev" + +# Extensions +extensions = [ + # Markdown support + "myst_parser", + # Automatic API documentation + "autoapi.extension", + # Source code viewing + "sphinx.ext.viewcode", + # Type hints in documentation + "sphinx_autodoc_typehints", + # AI-friendly output (llms.txt) + "sphinx_llm.txt", + # UX enhancements + "sphinx_copybutton", + "sphinx_design", + "sphinx_togglebutton", + # Diagrams + "sphinx.ext.graphviz", + # Planned features / TODOs + "sphinx.ext.todo", + # Social sharing metadata + "sphinxext.opengraph", +] + +# source_suffix: autoapi generates .rst internally, so register both +source_suffix = {".md": "markdown", ".rst": "restructuredtext"} + +# autoapi configuration +autoapi_type = "python" +autoapi_dirs = ["../src"] +autoapi_file_patterns = ["*.py"] +autoapi_generate_api_docs = True +autoapi_add_toctree_entry = True +autoapi_options = [ + "members", + "undoc-members", + "show-inheritance", + "show-module-summary", +] +autoapi_keep_files = True +autoapi_python_use_implicit_namespaces = True + + +# Skip private members (underscore prefix except dunder) +def autoapi_skip_member( + _app, _what: str, name: str, _obj, skip: bool, _options +) -> bool: + if name.startswith("_") and not name.startswith("__"): + return True + return skip + + +def _suppress_autoapi_orphan_warnings(app, env, docnames): + """Post-build hook: fix autoapi toctree for single-file modules. + + autoapi generates an empty toctree in autoapi/index.rst when the source + is a single .py file (not a package). Add the generated module page so + it is not reported as orphan. + """ + autoapi_index = Path(app.srcdir) / "autoapi" / "index.rst" + if not autoapi_index.exists(): + return + content = autoapi_index.read_text() + if "src/appenv/index" in content: + return + content = content.replace( + ".. toctree::\n :titlesonly:\n\n\n", + ".. toctree::\n :titlesonly:\n\n src/appenv/index\n\n", + ) + autoapi_index.write_text(content) + + +def setup(app): + app.connect("autoapi-skip-member", autoapi_skip_member) + app.connect("env-before-read-docs", _suppress_autoapi_orphan_warnings) + + +# MyST configuration +myst_enable_extensions = [ + "colon_fence", + "deflist", + "fieldlist", + "tasklist", + "dollarmath", +] +myst_heading_anchors = 3 +myst_all_links_external = False + +# sphinx-copybutton configuration +copybutton_prompt_text = r">>> |\.\.\. |\$ |In \[\d*\]: | " +copybutton_prompt_is_regexp = True + +# Type hints presentation +autodoc_typehints = "description" +autodoc_typehints_description_target = "documented" + +# sphinx-llm configuration +llms_txt_build_parallel = True +llms_txt_full_build = True +llms_txt_suffix_mode = "auto" +llms_txt_description = ( + "appenv - Self-contained bootstrapping and updating of Python CLI applications" +) + +# sphinx.ext.todo configuration +todo_include_todos = True + +# sphinx.ext.graphviz configuration +graphviz_output_format = "svg" + +# Theme configuration +html_theme = "furo" +html_title = "appenv Documentation" +html_theme_options = { + "source_repository": "https://github.com/flyingcircusio/appenv/", + "source_branch": "main", + "source_directory": "docs/", + "sidebar_hide_name": False, + "navigation_with_keys": True, + "light_css_variables": { + "font-stack": "Inter, sans-serif", + }, +} + +# Templates path +templates_path = ["_templates"] + +# Exclude patterns +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +# Suppress specific warnings that are known and harmless +suppress_warnings = [ + "myst.header", +] diff --git a/docs/dev-guide/architecture.md b/docs/dev-guide/architecture.md new file mode 100644 index 0000000..a5f311b --- /dev/null +++ b/docs/dev-guide/architecture.md @@ -0,0 +1,128 @@ +# Architecture + +How appenv's components fit together and why. + +## Design Philosophy + +appenv is a single-file Python CLI that pins packages to exact versions and exposes their binaries via symlinks, using [uv](https://docs.astral.sh/uv/) for environment management. The single-file constraint is deliberate: appenv gets copied into project repositories as a self-contained bootstrap script with zero runtime dependencies. Commit it alongside `pyproject.toml` and `uv.lock`, and every checkout — local or on a remote deployment target — gets the same tools at the same versions by running `./http` or `./batou`. + +This shapes every architectural decision: + +- **Symlink dispatch**: When invoked as `./http` (a symlink to `appenv`), it prepares the venv and runs `.appenv/venv/bin/http`. When invoked as `./appenv`, it parses subcommands via argparse. The script detects its own filename (`Path(__file__).stem`) to choose the mode. Multiple symlinks can coexist to expose different binaries from the same venv. +- **Type hints inline and stubs**: Implementation lives in `appenv.py` with type annotations. Type hints are provided both inline in appenv.py and as PEP 561 type stubs (appenv.pyi). The stubs enable type checking for downstream consumers without importing the module. +- **Guard-then-act pattern**: `ensure_*` functions validate preconditions and exit with specific error codes if unsatisfied. The main flow only proceeds after all guards pass. + +## Command Dispatch + +The entry point `main()` does three things: + +1. **Clear PYTHONPATH** — prevents host environment contamination in the venv +2. **Select best Python** via `ensure_best_python()` (see [](#python-version-selection)) +3. **Dispatch based on filename**: + - Filename is `appenv` → `meta()` (argparse subcommand handling) + - Filename is anything else → `run()` (exec the venv binary) + +### Run Mode + +`./http arg1 arg2` → appenv prepares the venv, then `os.execv()` replaces the current process with `.appenv/venv/bin/http arg1 arg2`. The original Python process is gone — no wrapper, no subprocess overhead. + +### Meta Mode + +`./appenv ` → argparse dispatches to handler methods grouped by category (Project, Venv, Tools, Debug). Subcommands are defined in `AppEnv.meta()` with `func` defaults pointing to handler methods. + +## Python Version Selection + +(architecture-python-version-selection)= + +`ensure_best_python()` runs before any other setup. It reads `requires-python` from `pyproject.toml`, scans PATH for `python3.X` binaries (newest first), and re-execs with the best match via `os.execv()`. A guard (`APPENV_BEST_PYTHON` env var) prevents infinite re-exec loops. + +**Base directory**: `APPENV_BASEDIR` overrides the project location. By default, appenv uses `Path(__file__).parent` — meaning appenv must be located next to `pyproject.toml`. This is intentional: the bootstrap script lives beside the project it manages. + +If no compatible Python is found, it lists available versions and exits with `EXIT_CODE_DATAERR` (65). + +Each candidate is probed by running `python -c "print(1)"` to verify the binary actually works — important on systems like NixOS where symlink targets may have been garbage-collected. + +## uv Management + +### Discovery Chain + +`UvBin` discovers the `uv` binary through a five-step cascade. Each step validates the version before accepting — see `UvVersion.minimum()` in the API reference for the current minimum: + +1. **PATH** — `shutil.which("uv")`, validate version +2. **Cached binary** — `.appenv/.uv/bin/uv` from a previous nix/pip install +3. **Nix channel** — `nix-build -A uv` into `.appenv/.uv` +4. **Nix flake** — `nix build nixpkgs#uv` into `.appenv/.uv` (more expensive, fresher packages) +5. **pip install** — `pip install uv -t .appenv/.uv` as last resort + +When a PATH uv is valid, any previously cached `.appenv/.uv` is cleaned up automatically. The cascade handles environments where uv may not be pre-installed (CI, NixOS, minimal containers). + +### Version Enforcement + +`ensure_uv()` wraps `UvBin` construction and exits with `EXIT_CODE_UNAVAILABLE` (68) if the discovered binary doesn't meet the minimum version. This guard runs before any venv operations. + +## Venv Lifecycle + +The venv lives at `.appenv/venv` — a real virtual environment managed by uv. `.venv` is a symlink to `.appenv/venv` for IDE and tool compatibility (editors, linters, debuggers that expect `.venv` by convention). + +### Creation + +`_prepare_venv()` handles the full lifecycle: + +1. Run guards: `ensure_pyproject()`, `ensure_lock_file()`, `ensure_uv()` +2. Set `UV_PROJECT_ENVIRONMENT` to `.appenv/venv` so uv targets the right directory +3. **Corruption recovery**: if `.appenv/venv` exists but `bin/python` is missing (NixOS garbage collection), the venv is removed and recreated +4. Create venv with `uv venv --python ` — explicitly uses the current Python to prevent uv from downloading its own (which breaks on NixOS) +5. Sync dependencies via `uv sync` +6. Update `.venv` symlink (removed and recreated if stale) + +### Sync Modes + +- **Production** (`run`, `prepare`, symlink dispatch): `uv sync --no-dev --frozen` — only production dependencies, lockfile must exist and be unchanged. The symlink dispatch (`./http`) is equivalent to `prepare` followed by `os.execv`. + +## Project Layout Conventions + +appenv expects specific files relative to the project root. Paths are conventions, not configuration: + +`pyproject.toml` +: Project definition with `[project]` section and `requires-python`. Required for all operations. + +`uv.lock` +: Dependency lockfile created by `./appenv update-lockfile`. Required before `run` or `prepare`. + +`appenv` +: The bootstrap script — a copy of `src/appenv.py`. + +`` +: Symlink to `appenv`. Running `./` executes the `` binary from the installed dependencies. Multiple symlinks can expose different binaries from the same venv. + +`.appenv/` +: Internal state directory (venv, cached uv binary, logs). Managed entirely by appenv. + +`.venv` +: Symlink to `.appenv/venv`. Created for tool compatibility — do not delete manually. + +## Error Handling Strategy + +appenv uses BSD sysexits.h exit codes to communicate specific failure modes: + +**64 (USAGE)** +: Incorrect command usage — deprecated subcommand invoked or invalid arguments. + +**65 (DATAERR)** +: Input data is malformed — missing `[project]` section in `pyproject.toml`, no compatible Python found. + +**67 (NOINPUT)** +: Required file missing — no `pyproject.toml`, no `uv.lock`. + +**68 (UNAVAILABLE)** +: Required tool unavailable — uv not found or too old. + +The `cmd()` subprocess wrapper converts `CalledProcessError` to `ValueError` with captured output, giving calling code both the exit code and full stderr/stdout for error reporting. + +## Logging Architecture + +Each command gets its own log file at `.appenv/logs/.log`. Logs use `TimedRotatingFileHandler` with daily rotation and 7-day retention. + +Verbose mode (`APPENV_VERBOSE=1`) adds a console handler with dimmed caller info (`funcName:lineno`) prepended to each message. Useful during development without cluttering normal output. + +The logger is a module-level `logging.getLogger("appenv")` singleton, configured per-command by `setup_logging()`. All components use it for structured debug output. diff --git a/docs/dev-guide/contributing.md b/docs/dev-guide/contributing.md new file mode 100644 index 0000000..f7711d2 --- /dev/null +++ b/docs/dev-guide/contributing.md @@ -0,0 +1,105 @@ +# Contributing + +How to set up a development environment and submit changes. + +## Development Setup + +```bash +git clone https://github.com/flyingcircusio/appenv.git +cd appenv +``` + +Run tests: + +```bash +uv run pytest +``` + +All CI checks (lint, format, type-check, test) run via: + +```bash +tox +``` + +## Code Style + +appenv follows the style defined in `pyproject.toml` under `[tool.ruff]`. Run `uv run ruff check --fix .` and `uv run ruff format .` to apply. + +### Type Annotations + +Type annotations go in `.pyi` stub files, not in `.py` source files. This is the PEP 561 pattern: `src/appenv.py` has the implementation with minimal typing, `src/appenv.pyi` has full type annotations. The `src/py.typed` marker file signals PEP 561 compliance to type checkers. + +### Exit Codes + +Use BSD sysexits.h constants (`EXIT_CODE_DATAERR`, `EXIT_CODE_NOINPUT`, `EXIT_CODE_UNAVAILABLE`) defined at module level. See {doc}`architecture` for the full error handling strategy. + +### Spec Comments + +Non-trivial error handling uses `# SPEC:` comments to trace requirements: + +```python +# SPEC: SRS-F001-cmd-wrapper - Enrich subprocess errors with command output context +try: + result = subprocess.check_output(cmd_list, stderr=subprocess.STDOUT) +except subprocess.CalledProcessError as e: + raise ValueError(e.output.decode("utf-8", "replace")) from e +``` + +## Running Tests + +```bash +# All tests +uv run pytest + +# Specific file +uv run pytest tests/test_pyproject.py + +# With coverage report +uv run pytest --cov=appenv --cov-report=term-missing +``` + +Integration tests in `tests/integration/` exercise the full bootstrap workflow end-to-end. + +## Quality Gates + +All of these must pass before submitting a PR: + +| Gate | Command | What it checks | +|------|---------|----------------| +| Lint | `uv run ruff check .` | Code quality rules | +| Format | `uv run ruff format --check .` | Formatting consistency | +| Types | `uv run ty check .` | Type correctness via `.pyi` stubs | +| Dead code | `uv run vulture .` | Unused code detection | +| Tests | `uv run pytest` | All tests pass | +| Full CI | `tox` | All environments (fix, cov, multiple Python versions) | + +Pre-commit hooks run these automatically. + +## Documentation + +Docs are built with Sphinx using MyST markdown and autoapi: + +```bash +tox -e docs +``` + +- **User docs**: `docs/user-guide/` — usage and workflows +- **Dev docs**: `docs/dev-guide/` — architecture and this guide +- **API reference**: auto-generated from source by autoapi — do not write API docs by hand + +See {doc}`architecture` for how components interact. + +## Pull Request Process + +1. Fork and create a feature branch +2. Make changes with accompanying tests +3. Run `tox` — all environments must pass +4. Update documentation if behavior changed +5. Submit pull request + +**PR checklist:** + +- [ ] `tox` passes cleanly +- [ ] New behavior has tests +- [ ] Documentation updated if applicable +- [ ] No `# noqa` without justification diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..cdc1b95 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,86 @@ +# appenv + +appenv is a single Python file that pins packages to exact versions and exposes +their binaries via symlinks. Drop it into a repository, commit it, and every +checkout gets the same tools at the same versions — locally and on remote machines. + +No venv activation, no pip, no Python packaging knowledge required. + +## Quick Start + +```bash +mkdir myproject && cd myproject +curl -sL https://raw.githubusercontent.com/flyingcircusio/appenv/master/src/appenv.py -o appenv +chmod +x appenv +./appenv init +``` + +The init command walks you through project setup: + +``` +Let's create a new appenv project in /home/user/myproject +I'll ask a few questions, then create pyproject.toml here + +Binary to expose (creates ./ symlink) [app] http + +Enter dependencies (one per line, empty line to finish): + Default: http + Dependency: httpie + Dependency: + +Project name [myproject]: +Description []: My HTTP client +Minimum Python version [3.13]: +Created pyproject.toml +Generating new lock file ... + +=== Appenv project initialized === + +Use `./http` to run the http binary +``` + +Run the exposed binary: + +```bash +./http GET https://httpbin.org/get +``` + +## Core Concepts + +- **Single-file deployment**: `appenv.py` is the entire tool — drop it into any repository +- **Symlink dispatch**: `./http` (where `http → appenv`) runs the `http` binary from the pinned venv +- **Reproducible everywhere**: commit `appenv`, `pyproject.toml`, and `uv.lock` — every checkout gets identical versions +- **Multiple binaries**: create additional symlinks to expose more tools from the same venv + +## Project Structure + +``` +. +├── appenv # Main application entrypoint +├── pyproject.toml # Project configuration and dependencies +├── docs/ # Documentation +│ ├── index.md +│ ├── user-guide/ +│ └── dev-guide/ +├── src/ # Source code +│ └── appenv.py +└── tests/ # Test suite +``` + +## Conventions + +- appenv runs on Python 3.9+. Managed environments require Python 3.10+. +- [uv](https://docs.astral.sh/uv/) must be available on the system (see {doc}`user-guide/installation`) +- The appenv filename becomes the CLI command via symlink dispatch — `./http` where `http → appenv` +- `pyproject.toml` must be present next to the appenv file +- Dependencies are resolved and locked by uv into `uv.lock` + +```{toctree} +:hidden: +user-guide/installation +user-guide/commands +user-guide/workflows +user-guide/locking-behavior +dev-guide/architecture +dev-guide/contributing +``` diff --git a/docs/llms.txt b/docs/llms.txt new file mode 100644 index 0000000..691043b --- /dev/null +++ b/docs/llms.txt @@ -0,0 +1,90 @@ +# appenv Documentation + +Self-contained bootstrapping and updating of Python CLI applications using pyproject.toml and uv. + +**Test Coverage: 91%** - Tier 1: Full Documentation Priority + +## Overview + +appenv is a single-file Python application bootstrapping mechanism for CLI applications. It manages virtual environments using uv and supports pyproject.toml-based workflows. + +Requirements: +- Python 3.10 or later +- uv 0.5.0 or later (auto-installed if not found) + +## User Guide + +### Installation +- Bootstrap Script: `curl -sL https://github.com/flyingcircusio/appenv/raw/master/bootstrap | sh` +- Clone Repository: `git clone https://github.com/flyingcircusio/appenv.git` +- Copy appenv.py: `curl -sL https://github.com/flyingcircusio/appenv/raw/master/src/appenv.py -o appenv` + +### Quick Start +- Interactive setup with `./appenv init` +- First run automatically installs dependencies +- Automatic Python version selection based on requires-python + +### Commands +| Command | Description | +|---------|-------------| +| `./appenv prepare` | Create venv with production deps | +| `./appenv update-lockfile` | Update uv.lock | +| `./appenv reset` | Remove virtual environment | +| `./appenv python` | Start Python REPL | +| `./appenv run