Source code for projspec.proj.uv

import toml

import fsspec

from projspec.proj.base import ParseFailed, Project, ProjectSpec
from projspec.proj.python_code import PythonLibrary
from projspec.utils import (
    AttrDict,
    PickleableTomlDecoder,
    deep_get,
    run_subprocess,
)


def _parse_conf(self: ProjectSpec, conf: dict | None = None):
    from projspec.artifact.installable import Wheel
    from projspec.artifact.python_env import LockFile, VirtualEnv

    # TODO, uv conf specific env modifiers:
    #  python== from dependency-groups.requires-python
    #  remove deps in exclude-dependencies
    #  add deps in dev-dependencies to "dev"
    #  swap out deps in override-dependencies in all envs
    #  process sources
    #  add index URL, pip.indexurl, info to env channels

    self._artifacts = AttrDict(
        lock_file=AttrDict(
            default=LockFile(
                proj=self.proj,
                cmd=["uv", "lock"],
                fn=f"{self.proj.url}/uv.lock",
            )
        ),
        virtual_env=AttrDict(
            default=VirtualEnv(
                proj=self.proj,
                cmd=["uv", "sync"],
                fn=f"{self.proj.url}/.venv",
            )
        ),
    )
    if conf.get("package", True):
        self._artifacts["wheel"] = Wheel(
            proj=self.proj,
            cmd=["uv", "build"],
        )
    else:
        self._artifacts.pop("wheel", None)


[docs] class UvScript(PythonLibrary): """Single-file project runnable by UV as a script Metadata are declared inline in the script header """ icon = "📜" spec_doc = "https://docs.astral.sh/uv/guides/scripts/" def match(self): # this is a file, not a directory return any(_.endswith(".py") for _ in self.proj.scanned_files) or ( self.proj.url.endswith((".py", ".pyw")) ) def parse(self): from projspec.artifact.process import Process from projspec.content.environment import Environment, Stack, Precision from projspec.artifact import LockFile found = False if self.proj.url.endswith(".py"): with self.proj.fs.open(self.proj.url, "rb") as f: scanned = {self.proj.url.rsplit("/", 1)[-1][:-3]: f.read()} script = True else: scanned = self.proj.scanned_files script = False for name, contents in scanned.items(): if not name.endswith(".py"): continue try: # TODO: optional lockfile is in <name>.lock txt = contents.decode() lines = txt.split("# /// script", 1)[-1].split("# ///", 1)[0] lines = "\n".join(_.removeprefix("# ") for _ in lines.splitlines()) meta = toml.loads(lines, decoder=PickleableTomlDecoder()) if "dependencies" not in meta: raise ParseFailed # only one env allowed here, but other uv-specific configs may be allowed # as in _parse_conf() url = self.proj.url if script else f"{self.proj.url}/{name}" self.artifacts.setdefault("lockfile", AttrDict())[name[:-3]] = LockFile( proj=self.proj, cmd=["uv", "lock", "--script", name], fn=f"{url}.lock", ) if ( "file" not in self.proj.fs.protocol and "http" not in self.proj.fs.protocol ): self.artifacts.setdefault("process", AttrDict())[ name[:-3] ] = Process(proj=self.proj, cmd=["uv", "run", "--script", url]) packages = meta["dependencies"] if ver := meta.get("requires-python"): packages.append(f"python {ver}") self.contents.setdefault("environment", AttrDict())[ name[:-3] ] = Environment( proj=self.proj, stack=Stack.PIP, precision=Precision.SPEC, packages=packages, channels=deep_get(meta, "tools.uv.index", default=[]), ) found = True except (KeyError, ValueError): pass if not found: raise ParseFailed("No python file found") @staticmethod def _create(path): # uv init --script example.py --python 3.12 with open(f"{path}/example.py", "wt") as f: f.write( """ # https://docs.astral.sh/uv/guides/scripts/#declaring-script-dependencies # /// script # dependencies = [ # "requests<3", # "rich", # ] # /// import requests from rich.pretty import pprint resp = requests.get("https://peps.python.org/api/peps.json") data = resp.json() pprint([(k, v["title"]) for k, v in data.items()][:10]) """ )
[docs] class Uv(PythonLibrary): """UV-runnable project Note: uv can run any python project, but this tests for uv-specific config. """ icon = "🚀" spec_doc = "https://docs.astral.sh/uv/concepts/configuration-files/" def match(self): if not {"uv.lock", "uv.toml", ".python-version"}.isdisjoint( self.proj.basenames ): return True if "uv" in self.proj.pyproject.get("tools", {}): # even if it is present, uv can be explicitly directed to ignore the # project https://docs.astral.sh/uv/reference/settings/#managed return self.proj.pyproject["tool"]["uv"].get("managed", True) if ( self.proj.pyproject.get("build-system", {}).get("build-backend", "") == "uv_build" ): return True if ".venv" in self.proj.basenames: try: with self.proj.fs.open(f"{self.proj.url}/.venv/pyvenv.cfg", "rt") as f: txt = f.read() return "uv =" in txt except (OSError, FileNotFoundError): pass return False def parse(self): from projspec.content.environment import Environment, Precision, Stack super().parse() meta = self.proj.pyproject conf = meta.get("tools", {}).get("uv", {}) try: with self.get_file("uv.toml") as f: conf2 = toml.load(f, decoder=PickleableTomlDecoder()) except (OSError, FileNotFoundError): conf2 = {} conf.update(conf2) try: with self.get_file("uv.lock") as f: lock = toml.load(f, decoder=PickleableTomlDecoder()) except (OSError, FileNotFoundError): lock = {} if conf: _parse_conf(self, conf) elif ".python-version" in self.proj.basenames: # percolate to any env that doesn't define a python version? with self.get_file(".python-version") as f: self._contents.setdefault("environment", {})["default"] = Environment( proj=self.proj, stack=Stack.PIP, precision=Precision.SPEC, packages=[f"python =={f.read().strip()}"], ) if lock: pkg = [f"python {lock['requires-python']}"] # TODO: check for source= packages as opposed to pip wheel installs pkg.extend([f"{_['name']}{_vers(_)}" for _ in lock["package"]]) self._contents.setdefault("environment", {})["lockfile"] = Environment( proj=self.proj, stack=Stack.PIP, precision=Precision.LOCK, packages=pkg, ) @staticmethod def _create(path: str, name: str | None = None) -> None: # TODO: check for existing pyproject and add to it cmd = ["uv", "init", "--lib", "--package", "--vcs", "none"] if name: cmd.extend(["--name", name]) run_subprocess(cmd, cwd=path, output=False)
def _vers(s: dict) -> str: # TODO: this may be useful elsewhere if s.get("version"): return f" =={s.get('version')}" return ""