Skip to content

Commit 04932fd

Browse files
committed
feat(uv): implement automatic uv.lock generation from pyproject.toml if missing
1 parent 88d8221 commit 04932fd

File tree

5 files changed

+158
-94
lines changed

5 files changed

+158
-94
lines changed

examples/build-package/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Note that this example may create resources which cost money. Run `terraform des
5656
| <a name="module_package_dir_poetry_quiet"></a> [package\_dir\_poetry\_quiet](#module\_package\_dir\_poetry\_quiet) | ../../ | n/a |
5757
| <a name="module_package_dir_uv"></a> [package\_dir\_uv](#module\_package\_dir\_uv) | ../../ | n/a |
5858
| <a name="module_package_dir_uv_no_docker"></a> [package\_dir\_uv\_no\_docker](#module\_package\_dir\_uv\_no\_docker) | ../../ | n/a |
59+
| <a name="module_package_dir_uv_no_lock"></a> [package\_dir\_uv\_no\_lock](#module\_package\_dir\_uv\_no\_lock) | ../../ | n/a |
5960
| <a name="module_package_dir_with_npm_install"></a> [package\_dir\_with\_npm\_install](#module\_package\_dir\_with\_npm\_install) | ../../ | n/a |
6061
| <a name="module_package_dir_with_npm_install_lock_file"></a> [package\_dir\_with\_npm\_install\_lock\_file](#module\_package\_dir\_with\_npm\_install\_lock\_file) | ../../ | n/a |
6162
| <a name="module_package_dir_without_npm_install"></a> [package\_dir\_without\_npm\_install](#module\_package\_dir\_without\_npm\_install) | ../../ | n/a |

examples/build-package/main.tf

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,23 @@ module "package_dir_poetry_quiet" {
137137
quiet_archive_local_exec = true # Suppress Poetry/pip output during packaging
138138
}
139139

140+
# Create zip-archive of a single directory where "uv export" & "pip install" will be executed
141+
module "package_dir_uv_no_lock" {
142+
source = "../../"
143+
144+
create_function = false
145+
runtime = "python3.12"
146+
147+
source_path = [
148+
{
149+
path = "${path.module}/../fixtures/python-app-uv-no-lock"
150+
uv_install = true
151+
}
152+
]
153+
154+
artifacts_dir = "${path.root}/builds/package_dir_uv_no_lock/"
155+
}
156+
140157
# Create zip-archive of a single directory where "uv export" & "pip install" will be executed (using docker)
141158
module "package_dir_uv" {
142159
source = "../../"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
def lambda_handler(event, context):
2+
return {"statusCode": 200, "body": "Hello from uv (no lock)!"}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[project]
2+
name = "python-app-uv-no-lock"
3+
version = "0.1.0"
4+
description = "Fixture project to test uv without uv.lock"
5+
requires-python = ">=3.12"
6+
7+
dependencies = [
8+
"requests>=2.31.0"
9+
]
10+
11+
[tool.uv]

package.py

Lines changed: 127 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -654,7 +654,9 @@ def get_build_system_from_pyproject_toml(pyproject_file):
654654
continue
655655
if bs and line.startswith("build-backend") and "poetry" in line:
656656
return "poetry"
657-
if line.startswith("[tool.uv]"):
657+
if line.startswith("[tool.uv]") or (
658+
bs and line.startswith("build-backend") and "uv" in line
659+
):
658660
return "uv"
659661

660662

@@ -720,17 +722,25 @@ def uv_install_step(
720722
if os.path.isdir(path):
721723
uv_lock_file = os.path.join(path, "uv.lock")
722724

723-
if not os.path.isfile(uv_lock_file):
725+
uv_project_path = os.path.dirname(uv_lock_file)
726+
pyproject_file = os.path.join(uv_project_path, "pyproject.toml")
727+
728+
has_lock = os.path.isfile(uv_lock_file)
729+
has_pyproject = os.path.isfile(pyproject_file)
730+
731+
if not has_lock and not has_pyproject:
724732
if required:
725-
raise RuntimeError("uv.lock not found: {}".format(uv_lock_file))
726-
else:
727-
step("uv", runtime, path, uv_export_extra_args, prefix, tmp_dir)
728-
hash(uv_lock_file)
733+
raise RuntimeError(
734+
"Neither uv.lock nor pyproject.toml found in: {}".format(path)
735+
)
736+
return
737+
738+
step("uv", runtime, path, uv_export_extra_args, prefix, tmp_dir)
729739

730-
uv_project_path = os.path.dirname(uv_lock_file)
731-
pyproject_file = os.path.join(uv_project_path, "pyproject.toml")
732-
if os.path.isfile(pyproject_file):
733-
hash(pyproject_file)
740+
if has_lock:
741+
hash(uv_lock_file)
742+
if has_pyproject:
743+
hash(pyproject_file)
734744

735745
def poetry_install_step(
736746
path, poetry_export_extra_args=[], prefix=None, required=False, tmp_dir=None
@@ -835,7 +845,12 @@ def commands_step(path, commands):
835845
)
836846
runtime = query.runtime
837847
if runtime.startswith("python"):
838-
if os.path.isfile(os.path.join(path, "uv.lock")):
848+
pyproject = os.path.join(path, "pyproject.toml")
849+
build_system = get_build_system_from_pyproject_toml(pyproject)
850+
if (
851+
os.path.isfile(os.path.join(path, "uv.lock"))
852+
or build_system == "uv"
853+
):
839854
uv_install_step(path)
840855
else:
841856
pip_requirements_step(os.path.join(path, "requirements.txt"))
@@ -1430,139 +1445,157 @@ def copy_file_to_target(file, temp_dir):
14301445

14311446
@contextmanager
14321447
def install_uv_dependencies(query, path, uv_export_extra_args, tmp_dir):
1433-
# uv.lock is required for uv
1448+
def copy_file_to_target(file, target_dir):
1449+
filename = os.path.basename(file)
1450+
target_file = os.path.join(target_dir, filename)
1451+
shutil.copyfile(file, target_file)
1452+
return target_file
1453+
1454+
def strip_editable_self_dependency(requirements_file):
1455+
cleaned = []
1456+
1457+
with open(requirements_file, "r") as f:
1458+
for line in f:
1459+
stripped = line.strip()
1460+
if stripped == "-e .":
1461+
continue
1462+
if stripped.startswith("-e file:") or stripped.startswith("file://"):
1463+
continue
1464+
cleaned.append(line.rstrip())
1465+
1466+
with open(requirements_file, "w") as f:
1467+
f.write("\n".join(cleaned) + "\n")
1468+
14341469
uv_lock_file = path
14351470
if os.path.isdir(path):
14361471
uv_lock_file = os.path.join(path, "uv.lock")
1437-
if not os.path.exists(uv_lock_file):
1438-
yield
1439-
return
1440-
1441-
# pyproject.toml is required by uv
1442-
project_path = os.path.dirname(uv_lock_file)
1472+
project_path = (
1473+
os.path.dirname(uv_lock_file) if os.path.isdir(path) else os.path.dirname(path)
1474+
)
14431475
pyproject_file = os.path.join(project_path, "pyproject.toml")
1444-
if not os.path.isfile(pyproject_file):
1445-
yield
1446-
return
14471476

14481477
runtime = query.runtime
1449-
artifacts_dir = query.artifacts_dir
14501478
docker = query.docker
14511479
docker_image_tag_id = None
14521480

1481+
uv_exec = "uv.exe" if WINDOWS and not docker else "uv"
1482+
subproc_env = None
1483+
1484+
if not os.path.exists(uv_lock_file) and os.path.exists(pyproject_file):
1485+
try:
1486+
check_call([uv_exec, "lock"], cwd=project_path)
1487+
except FileNotFoundError as e:
1488+
raise RuntimeError(
1489+
f"uv must be installed and available in PATH for runtime ({runtime})"
1490+
) from e
1491+
14531492
if docker:
14541493
docker_file = docker.docker_file
14551494
docker_image = docker.docker_image
14561495
docker_build_root = docker.docker_build_root
14571496

14581497
if docker_image:
1459-
ok = False
1460-
while True:
1461-
output = check_output(docker_image_id_command(docker_image))
1462-
if output:
1463-
docker_image_tag_id = output.decode().strip()
1464-
log.debug(
1465-
"DOCKER TAG ID: %s -> %s", docker_image, docker_image_tag_id
1466-
)
1467-
ok = True
1468-
if ok:
1469-
break
1498+
output = (
1499+
check_output(docker_image_id_command(docker_image)).decode().strip()
1500+
)
1501+
if not output:
14701502
docker_cmd = docker_build_command(
14711503
build_root=docker_build_root,
14721504
docker_file=docker_file,
14731505
tag=docker_image,
14741506
)
14751507
check_call(docker_cmd)
1476-
ok = True
1508+
output = (
1509+
check_output(docker_image_id_command(docker_image)).decode().strip()
1510+
)
1511+
docker_image_tag_id = output
14771512
elif docker_file or docker_build_root:
14781513
raise ValueError(
1479-
"docker_image must be specified for a custom image future references"
1514+
"docker_image must be specified when using docker_file or docker_build_root"
14801515
)
14811516

1482-
working_dir = os.getcwd()
1517+
log.info("Installing python dependencies with uv (no editable installs)")
14831518

1484-
log.info("Installing python dependencies with uv & pip: %s", uv_lock_file)
14851519
with tempdir(tmp_dir) as temp_dir:
1520+
pyproject_target = copy_file_to_target(pyproject_file, temp_dir)
14861521

1487-
def copy_file_to_target(file, temp_dir):
1488-
filename = os.path.basename(file)
1489-
target_file = os.path.join(temp_dir, filename)
1490-
shutil.copyfile(file, target_file)
1491-
return target_file
1492-
1493-
pyproject_target_file = copy_file_to_target(pyproject_file, temp_dir)
1494-
uv_lock_target_file = copy_file_to_target(uv_lock_file, temp_dir)
1495-
1496-
uv_exec = "uv"
1497-
python_exec = runtime
1498-
subproc_env = None
1499-
1500-
if not docker:
1501-
if WINDOWS:
1502-
uv_exec = "uv.exe"
1522+
uv_lock_target = None
1523+
if os.path.exists(uv_lock_file):
1524+
uv_lock_target = copy_file_to_target(uv_lock_file, temp_dir)
15031525

15041526
with cd(temp_dir):
15051527
uv_export = [
15061528
uv_exec,
15071529
"export",
1508-
"--frozen",
1530+
"--python",
1531+
runtime,
15091532
"--no-dev",
15101533
"-o",
15111534
"requirements.txt",
1512-
] + uv_export_extra_args
1513-
1514-
uv_commands = [
1515-
uv_export,
1516-
[
1517-
python_exec,
1518-
"-m",
1519-
"pip",
1520-
"install",
1521-
"--no-compile",
1522-
"--target=.",
1523-
"--requirement=requirements.txt",
1524-
],
15251535
]
15261536

1537+
if uv_lock_target:
1538+
uv_export.append("--frozen")
1539+
1540+
uv_export += uv_export_extra_args
1541+
15271542
if docker:
1528-
with_ssh_agent = docker.with_ssh_agent
1529-
chown_mask = "{}:{}".format(os.getuid(), os.getgid())
1530-
uv_commands += [["chown", "-R", chown_mask, "."]]
1531-
shell_commands = [shlex_join(cmd) for cmd in uv_commands]
1532-
shell_command = [" && ".join(shell_commands)]
1543+
shell_command = [
1544+
" && ".join(
1545+
[
1546+
shlex_join(uv_export),
1547+
"sed -i.bak '/^-e \\.\\$/d' requirements.txt",
1548+
shlex_join(
1549+
[
1550+
uv_exec,
1551+
"pip",
1552+
"install",
1553+
"--python",
1554+
runtime,
1555+
"--system",
1556+
"--no-compile",
1557+
"--target=.",
1558+
"--requirement=requirements.txt",
1559+
]
1560+
),
1561+
f"chown -R {os.getuid()}:{os.getgid()} .",
1562+
]
1563+
)
1564+
]
1565+
15331566
check_call(
15341567
docker_run_command(
15351568
".",
15361569
shell_command,
15371570
runtime,
15381571
image=docker_image_tag_id,
15391572
shell=True,
1540-
ssh_agent=with_ssh_agent,
1573+
ssh_agent=docker.with_ssh_agent,
15411574
docker=docker,
15421575
)
15431576
)
15441577
else:
1545-
cmd_log.info(uv_commands)
1546-
log_handler and log_handler.flush()
1547-
for uv_command in uv_commands:
1548-
try:
1549-
if query.quiet:
1550-
check_call(
1551-
uv_command,
1552-
env=subproc_env,
1553-
stdout=subprocess.DEVNULL,
1554-
stderr=subprocess.DEVNULL,
1555-
)
1556-
else:
1557-
check_call(uv_command, env=subproc_env)
1558-
except FileNotFoundError as e:
1559-
raise RuntimeError(
1560-
"UV executable must be installed and available in PATH "
1561-
"for runtime ({})".format(runtime)
1562-
) from e
1563-
1564-
os.remove(pyproject_target_file)
1565-
os.remove(uv_lock_target_file)
1578+
check_call(uv_export, env=subproc_env)
1579+
strip_editable_self_dependency("requirements.txt")
1580+
check_call(
1581+
[
1582+
uv_exec,
1583+
"pip",
1584+
"install",
1585+
"--python",
1586+
runtime,
1587+
"--system",
1588+
"--no-compile",
1589+
"--target=.",
1590+
"--requirement=requirements.txt",
1591+
],
1592+
env=subproc_env,
1593+
)
1594+
1595+
# Cleanup copied metadata
1596+
os.remove(pyproject_target)
1597+
if uv_lock_target:
1598+
os.remove(uv_lock_target)
15661599

15671600
yield temp_dir
15681601

0 commit comments

Comments
 (0)