Skip to content

Commit

Permalink
🔀 Merge pull request #6 from ThatXliner/requires-python
Browse files Browse the repository at this point in the history
✨ Second implementation of respecting Python version
  • Loading branch information
ThatXliner authored Nov 21, 2023
2 parents 63a8119 + ccab40a commit 315b0d3
Show file tree
Hide file tree
Showing 10 changed files with 155 additions and 37 deletions.
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,19 @@ jobs:
strategy:
matrix:
python-version:
- 3.7
- 3.8
- 3.9
- "3.10"
- "3.11"
- "3.12"
- |
3.7
3.8
3.9
3.10
3.11
3.12
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,19 @@ pprint([(k, v["title"]) for k, v in data.items()][:10])
## Caveats

- [Does not support Windows](https://pexpect.readthedocs.io/en/stable/overview.html#pexpect-on-windows)
- [Does not respect `requires-python`](https://github.com/ThatXliner/idae/issues/2)
- Fails silently if the dependencies could not be found
- [Crappy interface](https://github.com/ThatXliner/idae/issues/1)

## How it works

1. Detect script file
2. Use [venv][] to create a temporary virtual environment in the [user cache directory][] using the Python executable used to run `idae`
3. Find [PEP 723][] requirements
4. Install them into the venv
5. Run the script within the venv
2. Detect appropriate Python executable
3. Use [venv][] to create a temporary virtual environment in the [user cache directory][] using the executable detected
4. Find [PEP 723][] `pip` requirements
5. Install them into the virtual environment
6. Run the script within the virtual environment

Whenever you want run `idae clean` to remove all cached environments to free up space.
Run `idae clean` to remove all cached environments to free up space. Environments are cached per set of requirements.

[venv]: https://docs.python.org/3/library/venv.html
[user cache directory]: https://platformdirs.readthedocs.io/en/latest/api.html#cache-directory
Expand Down
42 changes: 30 additions & 12 deletions idae/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
from packaging.version import Version

from idae.pep723 import read
from idae.venv import clean_venvs, get_venv
from idae.resolver import get_python
from idae.venv import Python, clean_venvs, get_venv


def main() -> None:
Expand All @@ -27,24 +28,41 @@ def main() -> None:
# Get scrip dependencies
script = Path(sys.argv[1]).resolve()
pyproject = read(str(script.read_text()))
script_deps = (
[]
if pyproject is None
else list(map(Requirement, pyproject["run"]["dependencies"]))
)
# Create or fetch a cached venv
# TODO(ThatXliner): get from requires-python
# https://github.com/ThatXliner/idae/issues/2
py_ver = Version(
script_deps = []
python_version = Version(
f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
)
venv_path = get_venv(script_deps, py_ver)
python_executable = sys.executable
if pyproject is not None and "run" in pyproject:
script_deps = (
[]
if "dependencies" not in pyproject["run"]
else list(map(Requirement, pyproject["run"]["dependencies"]))
)
if "requires-python" in pyproject["run"]:
python = get_python(pyproject["run"]["requires-python"])
# TODO(ThatXliner): A flag for ignoring this
# https://github.com/ThatXliner/idae/issues/1
if not python:
msg = f"Python version {pyproject['run']['requires-python']} not found"
raise RuntimeError(msg)
python_version = python.version
# python.executable may be a symlink
# which shouldn't cause problems.
# If it does, then change this code to use
# python.real_path
python_executable = str(python.executable)

venv_path = get_venv(
script_deps,
Python(version=python_version, executable=python_executable),
)

# Run the script inside the venv
terminal = shutil.get_terminal_size()
# Copied from poetry source, slightly modified
child = pexpect.spawn(
str((venv_path / "bin/python").resolve()),
str(venv_path / "bin/python"),
sys.argv[1:],
dimensions=(terminal.lines, terminal.columns),
)
Expand Down
17 changes: 17 additions & 0 deletions idae/resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Resolve Python versions."""

from __future__ import annotations

import findpython # type: ignore[import]
from packaging.specifiers import SpecifierSet


def get_python(version: str) -> findpython.PythonVersion | None:
"""Resolve the version string."""
# Order from latest version to earliest
pythons = {python.version: python for python in findpython.find_all()}
target = SpecifierSet(version)
for python_version, python in pythons.items():
if python_version in target:
return python
return None
23 changes: 18 additions & 5 deletions idae/venv.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import shutil
import subprocess
import venv
from dataclasses import dataclass
from typing import TYPE_CHECKING

import platformdirs
Expand All @@ -19,14 +19,27 @@
CACHE_DIR = platformdirs.user_cache_path("idae")


def get_venv(requirements: list[Requirement], python_version: Version) -> Path:
@dataclass
class Python:
"""Object representing a Python executable."""

version: Version
executable: str


def get_venv(requirements: list[Requirement], python: Python) -> Path:
"""Create or fetch a cached venv."""
dep_hash = hash_dependencies(requirements)
venv_path = CACHE_DIR / f"{python_version.major}.{python_version.minor}" / dep_hash
venv_path = CACHE_DIR / f"{python.version.major}.{python.version.minor}" / dep_hash
if venv_path.is_dir():
return venv_path

venv.create(venv_path, with_pip=True)
# This automatically includes pip
subprocess.run(
[python.executable, "-m", "venv", venv_path], # noqa: S603
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=True,
)
# Install dependencies into the venv (if any)
if requirements:
subprocess.run(
Expand Down
45 changes: 33 additions & 12 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ pexpect = "^4.8.0"
tomli = { version = "^2.0.1", python = "<3.11" }
platformdirs = ">=4.0.0"
packaging = ">=23.2"
findpython = "^0.4.0"
resolvelib = "^1.0.1"

[tool.poetry.scripts]
idae = "idae.__main__:main"
Expand Down
16 changes: 16 additions & 0 deletions tests/examples/impossible_python.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env idae
# /// pyproject
# [run]
# requires-python = ">=69420"
# dependencies = [
# "requests<3",
# "rich",
# ]
# ///

import requests
from rich.pretty import pprint

resp = requests.get("https://peps.python.org/api/peps.json") # noqa:
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
1 change: 0 additions & 1 deletion tests/examples/rich_requests.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#!/usr/bin/env idae
# /// pyproject
# [run]
# requires-python = ">=3.11"
# dependencies = [
# "requests<3",
# "rich",
Expand Down
25 changes: 24 additions & 1 deletion tests/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import sys

import pexpect
import pytest

from idae.__main__ import main

Expand Down Expand Up @@ -38,7 +39,11 @@ def patched_init(self, *args, **kwargs):


def test_main(monkeypatch):
monkeypatch.setattr(sys, "argv", ["idae", "tests/examples/rich_requests.py"])
monkeypatch.setattr(
sys,
"argv",
["idae", "tests/examples/rich_requests.py"],
)
monkeypatch.setattr(
pexpect.spawn,
"interact",
Expand All @@ -48,3 +53,21 @@ def test_main(monkeypatch):
monkeypatch.setattr(pexpect.spawn, "__init__", patched_init)
pexpect.spawn._original_init = original_init # noqa: SLF001
main()


def test_impossible_python(monkeypatch):
monkeypatch.setattr(
sys,
"argv",
["idae", "tests/examples/impossible_python.py"],
)
monkeypatch.setattr(
pexpect.spawn,
"interact",
lambda self, *_, **__: self.expect(EXAMPLE_OUTPUT),
)
original_init = pexpect.spawn.__init__
monkeypatch.setattr(pexpect.spawn, "__init__", patched_init)
pexpect.spawn._original_init = original_init # noqa: SLF001
with pytest.raises(RuntimeError):
main()

0 comments on commit 315b0d3

Please sign in to comment.