Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
321 changes: 172 additions & 149 deletions README.md

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions examples/inline/empty_40_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# ducktools.env
# MIT License
#
# Copyright (c) 2024 David C Ellis
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

# This script will only start working after Python 4 is released :)

# /// script
# requires-python = ">=4.0"
# ///

import sys

print(f"running in {sys.version_info}")
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"License :: OSI Approved :: MIT License",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX :: Linux",
]
dynamic = ["version"]
license = "MIT"
license-files = ["LICENSE"]

[project.scripts]
"ducktools-env" = "ducktools.env.__main__:main"
Expand Down
5 changes: 1 addition & 4 deletions src/ducktools/env/_lazy_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,6 @@

# ducktools-pythonfinder
"list_python_installs",
"PythonInstall",
"get_installed_uv_pythons",
]

laz = LazyImporter()
Expand Down Expand Up @@ -81,5 +79,4 @@
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from packaging.version import Version, InvalidVersion

from ducktools.pythonfinder import list_python_installs, PythonInstall
from ducktools.pythonfinder.shared import get_uv_pythons as get_installed_uv_pythons
from ducktools.pythonfinder import list_python_installs
9 changes: 2 additions & 7 deletions src/ducktools/env/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
# SOFTWARE.
import shutil
import subprocess
import sys
import zipapp

from pathlib import Path
Expand Down Expand Up @@ -56,12 +55,12 @@ def create_bundle(

:param spec: Bundle environment spec
:param paths: ManagedPaths object containing application path info
:param installer_command: appropriate UV or PIP 'install' command
:param installer_command: pip 'install' command
:param output_file: output path for the bundle, if not provided the
scriptfile path will be used with `.pyz` added as
file extension
:param compressed: Compress the archive bundle
:raises ScriptNameClash: error raised if the script name clashes with a
:raises ScriptNameClash: error raised if the script name clashes with a
name required for bootstrapping.
"""
script_path = Path(spec.script_path)
Expand All @@ -84,13 +83,9 @@ def create_bundle(
build_path = Path(build_folder)
print(f"Building bundle in '{build_folder}'")
print("Copying libraries into build folder")
# Don't copy UV - it's platform dependent
# Don't copy __pycache__ folders either
uv_base_exe = "uv.exe" if sys.platform == "win32" else "uv"
ignore_patterns = shutil.ignore_patterns(
"__pycache__",
uv_base_exe,
f"{uv_base_exe}.version"
)

# Copy pip and ducktools zipapps into folder
Expand Down
77 changes: 22 additions & 55 deletions src/ducktools/env/catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,6 @@ def _create_venv(
self,
*,
spec: EnvironmentSpec,
uv_path: str | None,
installer_command: list[str],
env: ENV_TYPE,
):
Expand Down Expand Up @@ -290,26 +289,15 @@ def _create_venv(
with open(requirements_path, 'w') as f:
f.write(spec.lockdata)
try:
if uv_path:
dependency_command = [
*installer_command,
"install",
"--python",
env.python_path,
"--no-deps",
"-r",
requirements_path,
]
else:
dependency_command = [
*installer_command,
"--python",
env.python_path,
"install",
"--no-deps",
"-r",
requirements_path,
]
dependency_command = [
*installer_command,
"--python",
env.python_path,
"install",
"--no-deps",
"-r",
requirements_path,
]
_laz.subprocess.run(
dependency_command,
check=True,
Expand All @@ -321,22 +309,13 @@ def _create_venv(
else:
log(f"Installing dependencies from PyPI: {dep_list}")
try:
if uv_path:
dependency_command = [
*installer_command,
"install",
"--python",
env.python_path,
*deps,
]
else:
dependency_command = [
*installer_command,
"--python",
env.python_path,
"install",
*deps,
]
dependency_command = [
*installer_command,
"--python",
env.python_path,
"install",
*deps,
]
_laz.subprocess.run(
dependency_command,
check=True,
Expand All @@ -347,20 +326,12 @@ def _create_venv(
raise VenvBuildError(f"Failed to install dependencies: {e}")

# Get pip-freeze list to use for installed modules
if uv_path:
freeze_command = [
*installer_command,
"freeze",
"--python",
env.python_path,
]
else:
freeze_command = [
*installer_command,
"--python",
env.python_path,
"freeze",
]
freeze_command = [
*installer_command,
"--python",
env.python_path,
"freeze",
]
freeze = _laz.subprocess.run(
freeze_command,
capture_output=True,
Expand Down Expand Up @@ -545,7 +516,6 @@ def create_env(
*,
spec: EnvironmentSpec,
config: Config,
uv_path: str | None,
installer_command: list[str],
base_python,
) -> ENV_TYPE:
Expand Down Expand Up @@ -576,7 +546,6 @@ def create_env(
try:
self._create_venv(
spec=spec,
uv_path=uv_path,
installer_command=installer_command,
env=new_env,
)
Expand Down Expand Up @@ -709,7 +678,6 @@ def create_env(
*,
spec: EnvironmentSpec,
config: Config,
uv_path: str,
installer_command: list[str],
base_python,
):
Expand Down Expand Up @@ -756,7 +724,6 @@ def create_env(
try:
self._create_venv(
spec=spec,
uv_path=uv_path,
installer_command=installer_command,
env=new_env,
)
Expand Down
4 changes: 0 additions & 4 deletions src/ducktools/env/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,6 @@ class Config(Prefab, kw_only=True):
cache_maxcount: int = 10
cache_lifetime: float = 14.0

# Use uv and allow uv to auto install Python
use_uv: bool = True
uv_install_python: bool = True

@property
def cache_lifetime_delta(self) -> _timedelta:
return _timedelta(days=self.cache_lifetime)
Expand Down
56 changes: 10 additions & 46 deletions src/ducktools/env/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,7 @@
[
FromImport(".bundle", "create_bundle"),
FromImport(".scripts.get_pip", "retrieve_pip"),
MultiFromImport(
".scripts.get_uv",
["get_local_uv", "get_available_pythons", "install_uv_python"]
),
FromImport(".scripts.get_uv", "get_local_uv"),
MultiFromImport(
".scripts.create_zipapp",
["build_env_folder", "build_zipapp"]
Expand Down Expand Up @@ -157,15 +154,10 @@ def install_outdated(self):
def retrieve_pip(self) -> str:
return _laz_internal.retrieve_pip(paths=self.paths)

def retrieve_uv(self, required=False) -> str | None:
def retrieve_uv(self) -> str:
# Retrieve the path to the uv executable
# if uv is installed
if self.config.use_uv or required:
uv_path = _laz_internal.get_local_uv()
else:
uv_path = None

if uv_path is None and required:
uv_path = _laz_internal.get_local_uv()
if uv_path is None:
raise RuntimeError(
"UV is required for this process but is unavailable."
)
Expand All @@ -186,26 +178,6 @@ def _get_python_install(self, spec: EnvironmentSpec):
):
install = inst
break
else:
# If no Python was matched try to install a matching python from UV
if self.config.uv_install_python and (uv_path := self.retrieve_uv()):
uv_pythons = _laz_internal.get_available_pythons(uv_path)
matched_python = False
for ver in uv_pythons:
if spec.details.requires_python_spec.contains(ver):
# Install matching python
_laz_internal.install_uv_python(
uv_path=uv_path,
version_str=ver,
)
matched_python = ver
break
if matched_python:
# Recover the actual install
for inst in _laz.get_installed_uv_pythons():
if inst.version_str == matched_python:
install = inst
break

if install is None:
raise PythonVersionNotFound(
Expand All @@ -214,18 +186,12 @@ def _get_python_install(self, spec: EnvironmentSpec):

return install

def install_base_command(self, use_uv=True) -> list[str]:
def install_base_command(self) -> list[str]:
# Get the installer command for python packages
# Pip or the faster uv_pip if it is available
if use_uv and (uv_path := self.retrieve_uv()):
return [uv_path, "pip"]
else:
pip_path = self.retrieve_pip()
return [sys.executable, pip_path, "--disable-pip-version-check"]
pip_path = self.retrieve_pip()
return [sys.executable, pip_path, "--disable-pip-version-check"]

def build_env_folder(self, clear_old_builds=True) -> None:
# build_env_folder will use PIP as uv will fail
# if there is no environment
# build-env-folder installs into a target directory
# instead of using a venv
base_command = [sys.executable, self.retrieve_pip(), "--disable-pip-version-check"]
Expand Down Expand Up @@ -267,7 +233,7 @@ def _spec_from_script(

spec = EnvironmentSpec.from_script(script_path)
if generate_lock:
spec.generate_lockdata(uv_path=self.retrieve_uv(required=True))
spec.generate_lockdata(uv_path=self.retrieve_uv())
elif lock_path:
with open(lock_path, 'r') as f:
spec.lockdata = f.read()
Expand Down Expand Up @@ -299,7 +265,6 @@ def get_script_env(self, spec: EnvironmentSpec):
env = self.app_catalogue.create_env(
spec=spec,
config=self.config,
uv_path=self.retrieve_uv(),
installer_command=self.install_base_command(),
base_python=base_python
)
Expand All @@ -313,7 +278,6 @@ def get_script_env(self, spec: EnvironmentSpec):
env = self.temp_catalogue.create_env(
spec=spec,
config=self.config,
uv_path=self.retrieve_uv(),
installer_command=self.install_base_command(),
base_python=base_python,
)
Expand Down Expand Up @@ -487,7 +451,7 @@ def create_bundle(
spec=spec,
output_file=output_file,
paths=self.paths,
installer_command=self.install_base_command(use_uv=False),
installer_command=self.install_base_command(),
compressed=compressed,
)

Expand All @@ -504,7 +468,7 @@ def generate_lockfile(
:return: Path to the output lockfile
"""""
spec = EnvironmentSpec.from_script(script_path=script_path)
spec.generate_lockdata(uv_path=self.retrieve_uv(required=True))
spec.generate_lockdata(uv_path=self.retrieve_uv())

lockfile_path = lockfile_path if lockfile_path else f"{script_path}.{LOCKFILE_EXTENSION}"

Expand Down
4 changes: 0 additions & 4 deletions src/ducktools/env/scripts/create_zipapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,8 @@ def build_zipapp(
if p != build_path:
shutil.rmtree(p)

# UV should not be bundled - binary is not cross platform
uv_base_exe = "uv.exe" if sys.platform == "win32" else "uv"
ignore_patterns = shutil.ignore_patterns(
"__pycache__",
uv_base_exe,
f"{uv_base_exe}.version"
)

print("Copying pip.pyz and ducktools-env")
Expand Down
Loading