Skip to content

Commit 6b93da5

Browse files
add pidlock to public functions (#45)
* add pidlock to public functions * ruff * ruff * ruff * Remove thread_lock and simplify locking * Remove process_lock from all functions except resolve() * fully reverting find_julia.py * move process lock into deps.py * use per-project lock files * Improve lock handling in resolve() function * More explicit version bounds * lint * lint --------- Co-authored-by: Christopher Doris <github.com/cjdoris>
1 parent 3a2c660 commit 6b93da5

File tree

6 files changed

+141
-90
lines changed

6 files changed

+141
-90
lines changed

.pre-commit-config.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
repos:
22
- repo: https://github.com/astral-sh/ruff-pre-commit
33
# Ruff version.
4-
rev: v0.7.0
4+
rev: v0.9.5
55
hooks:
66
# Run the formatter.
77
- id: ruff-format

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "juliapkg"
33
version = "0.1.15"
44
description = "Julia version manager and package manager"
55
authors = [{ name = "Christopher Doris" }]
6-
dependencies = ["semver~=3.0"]
6+
dependencies = ["semver >=3.0,<4.0", "filelock >=3.16,<4.0"]
77
readme = "README.md"
88
requires-python = ">=3.8"
99
classifiers = [

src/juliapkg/compat.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,11 @@ def __str__(self):
155155
return f"~{lo.major}.{lo.minor}.{lo.patch}"
156156
lostr = f"{lo.major}.{lo.minor}.{lo.patch}"
157157
if hi.major > 0 and hi.minor == 0 and hi.patch == 0:
158-
return f"{lostr} - {hi.major-1}"
158+
return f"{lostr} - {hi.major - 1}"
159159
if hi.minor > 0 and hi.patch == 0:
160-
return f"{lostr} - {hi.major}.{hi.minor-1}"
160+
return f"{lostr} - {hi.major}.{hi.minor - 1}"
161161
if hi.patch > 0:
162-
return f"{lostr} - {hi.major}.{hi.minor}.{hi.patch-1}"
162+
return f"{lostr} - {hi.major}.{hi.minor}.{hi.patch - 1}"
163163
raise ValueError("invalid range")
164164

165165
def __repr__(self):

src/juliapkg/deps.py

+108-83
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import sys
66
from subprocess import run
77

8+
from filelock import FileLock
9+
810
from .compat import Compat, Version
911
from .find_julia import find_julia, julia_version
1012
from .install_julia import log
@@ -287,94 +289,117 @@ def merge_any(dep, kfvs, k):
287289

288290

289291
def resolve(force=False, dry_run=False):
290-
# see if we can skip resolving
291-
if not force:
292-
if STATE["resolved"]:
293-
return True
294-
deps = can_skip_resolve()
295-
if deps:
296-
STATE["resolved"] = True
297-
STATE["executable"] = deps["executable"]
298-
STATE["version"] = Version.parse(deps["version"])
299-
return True
300-
if dry_run:
292+
# fast check to see if we have already resolved
293+
if (not force) and STATE["resolved"]:
301294
return False
302295
STATE["resolved"] = False
303-
# get julia compat and required packages
304-
compat, pkgs = find_requirements()
305-
# find a compatible julia executable
306-
log(f'Locating Julia{"" if compat is None else " "+str(compat)}')
307-
exe, ver = find_julia(
308-
compat=compat, prefix=STATE["install"], install=True, upgrade=True
309-
)
310-
log(f"Using Julia {ver} at {exe}")
311-
# set up the project
296+
# use a lock to prevent concurrent resolution
312297
project = STATE["project"]
313-
log(f"Using Julia project at {project}")
314298
os.makedirs(project, exist_ok=True)
315-
if not STATE["offline"]:
316-
# write a Project.toml specifying UUIDs and compatibility of required packages
317-
with open(os.path.join(project, "Project.toml"), "wt") as fp:
318-
print("[deps]", file=fp)
319-
for pkg in pkgs:
320-
print(f'{pkg.name} = "{pkg.uuid}"', file=fp)
321-
print(file=fp)
322-
print("[compat]", file=fp)
323-
for pkg in pkgs:
324-
if pkg.version:
325-
print(f'{pkg.name} = "{pkg.version}"', file=fp)
326-
print(file=fp)
327-
# remove Manifest.toml
328-
manifest_path = os.path.join(project, "Manifest.toml")
329-
if os.path.exists(manifest_path):
330-
os.remove(manifest_path)
331-
# install the packages
332-
dev_pkgs = ", ".join([pkg.jlstr() for pkg in pkgs if pkg.dev])
333-
add_pkgs = ", ".join([pkg.jlstr() for pkg in pkgs if not pkg.dev])
334-
script = ["import Pkg", "Pkg.Registry.update()"]
335-
if dev_pkgs:
336-
script.append(f"Pkg.develop([{dev_pkgs}])")
337-
if add_pkgs:
338-
script.append(f"Pkg.add([{add_pkgs}])")
339-
script.append("Pkg.resolve()")
340-
script.append("Pkg.precompile()")
341-
log("Installing packages:")
342-
for line in script:
343-
log("julia>", line, cont=True)
344-
env = os.environ.copy()
345-
if sys.executable:
346-
# prefer PythonCall to use the current Python executable
347-
# TODO: this is a hack, it would be better for PythonCall to detect that
348-
# Julia is being called from Python
349-
env.setdefault("JULIA_PYTHONCALL_EXE", sys.executable)
350-
run(
351-
[exe, "--project=" + project, "--startup-file=no", "-e", "; ".join(script)],
352-
check=True,
353-
env=env,
299+
lock_file = os.path.join(project, "lock.pid")
300+
lock = FileLock(lock_file)
301+
try:
302+
lock.acquire(timeout=3)
303+
except TimeoutError:
304+
log(
305+
f"Waiting for lock on {lock_file} to be freed. This normally means that"
306+
" another process is resolving. If you know that no other process is"
307+
" resolving, delete this file to proceed."
354308
)
355-
# record that we resolved
356-
save_meta(
357-
{
358-
"meta_version": META_VERSION,
359-
"dev": STATE["dev"],
360-
"version": str(ver),
361-
"executable": exe,
362-
"deps_files": {
363-
filename: {
364-
"timestamp": os.path.getmtime(filename),
365-
"hash_sha256": _get_hash(filename),
366-
}
367-
for filename in deps_files()
368-
},
369-
"pkgs": [pkg.dict() for pkg in pkgs],
370-
"offline": bool(STATE["offline"]),
371-
"override_executable": STATE["override_executable"],
372-
}
373-
)
374-
STATE["resolved"] = True
375-
STATE["executable"] = exe
376-
STATE["version"] = ver
377-
return True
309+
lock.acquire()
310+
try:
311+
# see if we can skip resolving
312+
if not force:
313+
deps = can_skip_resolve()
314+
if deps:
315+
STATE["resolved"] = True
316+
STATE["executable"] = deps["executable"]
317+
STATE["version"] = Version.parse(deps["version"])
318+
return True
319+
if dry_run:
320+
return False
321+
# get julia compat and required packages
322+
compat, pkgs = find_requirements()
323+
# find a compatible julia executable
324+
log(f"Locating Julia{'' if compat is None else ' ' + str(compat)}")
325+
exe, ver = find_julia(
326+
compat=compat, prefix=STATE["install"], install=True, upgrade=True
327+
)
328+
log(f"Using Julia {ver} at {exe}")
329+
# set up the project
330+
log(f"Using Julia project at {project}")
331+
if not STATE["offline"]:
332+
# write a Project.toml specifying UUIDs and compatibility of required
333+
# packages
334+
with open(os.path.join(project, "Project.toml"), "wt") as fp:
335+
print("[deps]", file=fp)
336+
for pkg in pkgs:
337+
print(f'{pkg.name} = "{pkg.uuid}"', file=fp)
338+
print(file=fp)
339+
print("[compat]", file=fp)
340+
for pkg in pkgs:
341+
if pkg.version:
342+
print(f'{pkg.name} = "{pkg.version}"', file=fp)
343+
print(file=fp)
344+
# remove Manifest.toml
345+
manifest_path = os.path.join(project, "Manifest.toml")
346+
if os.path.exists(manifest_path):
347+
os.remove(manifest_path)
348+
# install the packages
349+
dev_pkgs = ", ".join([pkg.jlstr() for pkg in pkgs if pkg.dev])
350+
add_pkgs = ", ".join([pkg.jlstr() for pkg in pkgs if not pkg.dev])
351+
script = ["import Pkg", "Pkg.Registry.update()"]
352+
if dev_pkgs:
353+
script.append(f"Pkg.develop([{dev_pkgs}])")
354+
if add_pkgs:
355+
script.append(f"Pkg.add([{add_pkgs}])")
356+
script.append("Pkg.resolve()")
357+
script.append("Pkg.precompile()")
358+
log("Installing packages:")
359+
for line in script:
360+
log("julia>", line, cont=True)
361+
env = os.environ.copy()
362+
if sys.executable:
363+
# prefer PythonCall to use the current Python executable
364+
# TODO: this is a hack, it would be better for PythonCall to detect that
365+
# Julia is being called from Python
366+
env.setdefault("JULIA_PYTHONCALL_EXE", sys.executable)
367+
run(
368+
[
369+
exe,
370+
"--project=" + project,
371+
"--startup-file=no",
372+
"-e",
373+
"; ".join(script),
374+
],
375+
check=True,
376+
env=env,
377+
)
378+
# record that we resolved
379+
save_meta(
380+
{
381+
"meta_version": META_VERSION,
382+
"dev": STATE["dev"],
383+
"version": str(ver),
384+
"executable": exe,
385+
"deps_files": {
386+
filename: {
387+
"timestamp": os.path.getmtime(filename),
388+
"hash_sha256": _get_hash(filename),
389+
}
390+
for filename in deps_files()
391+
},
392+
"pkgs": [pkg.dict() for pkg in pkgs],
393+
"offline": bool(STATE["offline"]),
394+
"override_executable": STATE["override_executable"],
395+
}
396+
)
397+
STATE["resolved"] = True
398+
STATE["executable"] = exe
399+
STATE["version"] = ver
400+
return True
401+
finally:
402+
lock.release()
378403

379404

380405
def executable():

src/juliapkg/install_julia.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,8 @@ def download_julia(f):
173173
buf.write(data)
174174
if time.time() > t:
175175
log(
176-
f" downloaded {buf.tell()/(1<<20):.1f} MB of {size/(1<<20):.1f}"
177-
" MB",
176+
f" downloaded {buf.tell() / (1 << 20):.1f} MB of"
177+
f" {size / (1 << 20):.1f} MB",
178178
cont=True,
179179
)
180180
t = time.time() + freq

test/test_all.py

+26
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import json
22
import os
3+
import subprocess
34
import tempfile
5+
from multiprocessing import Pool
46

57
import juliapkg
68

@@ -21,6 +23,30 @@ def test_resolve():
2123
assert juliapkg.resolve() is True
2224

2325

26+
def resolve_in_tempdir(tempdir):
27+
subprocess.run(
28+
["python", "-c", "import juliapkg; juliapkg.resolve()"],
29+
env=dict(os.environ, PYTHON_JULIAPKG_PROJECT=tempdir),
30+
)
31+
32+
33+
def test_resolve_contention():
34+
with tempfile.TemporaryDirectory() as tempdir:
35+
with open(os.path.join(tempdir, "juliapkg.json"), "w") as f:
36+
f.write("""
37+
{
38+
"julia": "1",
39+
"packages": {
40+
"BenchmarkTools": {
41+
"uuid": "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf",
42+
"version": "1.5"
43+
}
44+
}
45+
}
46+
""")
47+
Pool(5).map(resolve_in_tempdir, [tempdir] * 5)
48+
49+
2450
def test_status():
2551
assert juliapkg.status() is None
2652

0 commit comments

Comments
 (0)