diff --git a/CHANGELOG.md b/CHANGELOG.md index 14c41a9..7cb8bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## Unreleased +* Support for `juliapkg.toml` files. + ## v0.1.15 (2024-11-08) * Bug fixes. diff --git a/README.md b/README.md index 9d7440e..51c686e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Do you want to use [Julia](https://julialang.org/) in your Python script/project/package? No problem! JuliaPkg will help you out! -- Declare the version of Julia you require in a `juliapkg.json` file. +- Declare the version of Julia you require in a `juliapkg.toml` file. - Add any packages you need too. - Call `juliapkg.resolve()` et voila, your dependencies are there. - Use `juliapkg.executable()` to find the Julia executable and `juliapkg.project()` to @@ -32,17 +32,27 @@ pip install juliapkg adds a required package. Its name and UUID are required. - `rm(pkg, target=None)` remove a package. -Note that these functions edit `juliapkg.json` but do not actually install anything until +Note that these functions edit `juliapkg.toml` but do not actually install anything until `resolve()` is called, which happens automatically in `executable()` and `project()`. -The `target` specifies the `juliapkg.json` file to edit, or the directory containing it. +The `target` specifies the `juliapkg.toml` file to edit, or the directory containing it. If not given, it will be your virtual environment or Conda environment if you are using one, -otherwise `~/.pyjuliapkg.json`. +otherwise `~/.julia/environments/pyjuliapkg/juliapkg.toml`. -### juliapkg.json +### Dependency file format -You can also edit `juliapkg.json` directly if you like. Here is an example which requires +You can also edit `juliapkg.toml` directly if you like. Here is an example which requires Julia v1.*.* and the Example package v0.5.*: + +```toml +julia = "1" +[packages.Example] +uuid = "7876af07-990d-54b4-ab0e-23690620f79a" +version = "0.5" +``` + +For backward compatibility, JSON format is also supported using `juliapkg.json` files with the same structure: + ```json { "julia": "1", @@ -55,6 +65,8 @@ Julia v1.*.* and the Example package v0.5.*: } ``` +When both TOML and JSON files exist in the same directory, the TOML file takes precedence. + ## Using Julia - `juliapkg.executable()` returns a compatible Julia executable. @@ -102,11 +114,11 @@ More strategies may be added in a future release. ### Adding Julia dependencies to Python packages -JuliaPkg looks for `juliapkg.json` files in many locations, namely: +JuliaPkg looks for dependency files (`juliapkg.toml` or `juliapkg.json`) in many locations, namely: - `{project}/pyjuliapkg` where project is as above (depending on your environment). - Every directory and direct sub-directory in `sys.path`. -The last point means that if you put a `juliapkg.json` file in a package, then install that +The last point means that if you put a `juliapkg.toml` or `juliapkg.json` file in a package, then install that package, then JuliaPkg will find those dependencies and install them. You can use `add`, `rm` etc. above with `target='/path/to/your/package'` to modify the diff --git a/pyproject.toml b/pyproject.toml index d13bb6e..d2d780c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,12 @@ name = "juliapkg" version = "0.1.15" description = "Julia version manager and package manager" authors = [{ name = "Christopher Doris" }] -dependencies = ["semver >=3.0,<4.0", "filelock >=3.16,<4.0"] +dependencies = [ + "semver >=3.0,<4.0", + "filelock >=3.16,<4.0", + "tomli >=2.0.1,<3.0", + "tomli-w >=1.0.0,<2.0", +] readme = "README.md" requires-python = ">=3.8" classifiers = [ diff --git a/src/juliapkg/deps.py b/src/juliapkg/deps.py index b65ca19..bffaa56 100644 --- a/src/juliapkg/deps.py +++ b/src/juliapkg/deps.py @@ -5,6 +5,8 @@ import sys from subprocess import run +import tomli +import tomli_w from filelock import FileLock from .compat import Compat, Version @@ -18,6 +20,9 @@ META_VERSION = 5 # increment whenever the format changes +# Allowed dependency filenames in order of preference +ALLOWED_DEPS_FILES = ["juliapkg.toml", "juliapkg.json"] + def load_meta(): fn = STATE["meta"] @@ -179,11 +184,14 @@ def deps_files(): path = os.getcwd() if not os.path.isdir(path): continue - fn = os.path.join(path, "juliapkg.json") - ans.append(fn) - for subdir in os.listdir(path): - fn = os.path.join(path, subdir, "juliapkg.json") + # Look for all allowed filenames + for filename in ALLOWED_DEPS_FILES: + fn = os.path.join(path, filename) ans.append(fn) + for subdir in os.listdir(path): + for filename in ALLOWED_DEPS_FILES: + fn = os.path.join(path, subdir, filename) + ans.append(fn) return list( set( os.path.normcase(os.path.normpath(os.path.abspath(fn))) @@ -193,17 +201,63 @@ def deps_files(): ) +def _load_deps(file_path): + basename = os.path.basename(file_path) + if basename not in ALLOWED_DEPS_FILES: + raise ValueError( + f"Dependency file must be one of {ALLOWED_DEPS_FILES}, got: {file_path}" + ) + if basename == "juliapkg.toml": + with open(file_path, "rb") as fp: + return tomli.load(fp) + elif basename == "juliapkg.json": + with open(file_path) as fp: + return json.load(fp) + else: + assert False + + +def _write_deps(deps, file_path): + basename = os.path.basename(file_path) + if basename not in ALLOWED_DEPS_FILES: + raise ValueError( + f"Dependency file must be one of {ALLOWED_DEPS_FILES}, got: {file_path}" + ) + if basename == "juliapkg.toml": + with open(file_path, "wb") as fp: + tomli_w.dump(deps, fp) + elif basename == "juliapkg.json": + with open(file_path, "w") as fp: + json.dump(deps, fp) + else: + assert False + + +def load_cur_deps(target=None): + fn = cur_deps_file(target=target) + if os.path.exists(fn): + return _load_deps(fn) + return {} + + +def write_cur_deps(deps, target=None): + fn = cur_deps_file(target=target) + if deps: + os.makedirs(os.path.dirname(fn), exist_ok=True) + _write_deps(deps, fn) + else: + if os.path.exists(fn): + os.remove(fn) + + def find_requirements(): # read all dependencies into a dict: name -> key -> file -> value # read all julia compats into a dict: file -> compat - import json - compats = {} all_deps = {} for fn in deps_files(): log("Found dependencies: {}".format(fn)) - with open(fn) as fp: - deps = json.load(fp) + deps = _load_deps(fn) for name, kvs in deps.get("packages", {}).items(): dep = all_deps.setdefault(name, {}) for k, v in kvs.items(): @@ -413,10 +467,16 @@ def project(): def cur_deps_file(target=None): - if target is None: - return STATE["deps"] - elif os.path.isdir(target): - return os.path.abspath(os.path.join(target, "juliapkg.json")) + if target is None or os.path.isdir(target): + # Default to first allowed file if no file exists + paths = [ + os.path.join(STATE["prefix"] if target is None else target, f) + for f in ALLOWED_DEPS_FILES + ] + for path in paths: + if os.path.exists(path): + return path + return paths[0] # Default to highest priority elif os.path.isfile(target) or ( os.path.isdir(os.path.dirname(target)) and not os.path.exists(target) ): @@ -428,34 +488,12 @@ def cur_deps_file(target=None): ) -def load_cur_deps(target=None): - fn = cur_deps_file(target=target) - if os.path.exists(fn): - with open(fn) as fp: - deps = json.load(fp) - else: - deps = {} - return deps - - -def write_cur_deps(deps, target=None): - fn = cur_deps_file(target=target) - if deps: - os.makedirs(os.path.dirname(fn), exist_ok=True) - with open(fn, "w") as fp: - json.dump(deps, fp) - else: - if os.path.exists(fn): - os.remove(fn) - - def status(target=None): res = resolve(dry_run=True) print("JuliaPkg Status") fn = cur_deps_file(target=target) if os.path.exists(fn): - with open(fn) as fp: - deps = json.load(fp) + deps = _load_deps(fn) else: deps = {} st = "" if deps else " (empty project)" diff --git a/src/juliapkg/state.py b/src/juliapkg/state.py index f001f94..da7cb18 100644 --- a/src/juliapkg/state.py +++ b/src/juliapkg/state.py @@ -84,7 +84,6 @@ def reset_state(): # meta file STATE["prefix"] = os.path.join(STATE["project"], "pyjuliapkg") - STATE["deps"] = os.path.join(STATE["prefix"], "juliapkg.json") STATE["meta"] = os.path.join(STATE["prefix"], "meta.json") STATE["install"] = os.path.join(STATE["prefix"], "install") diff --git a/test/test_all.py b/test/test_all.py index d2acb9a..d5809ac 100644 --- a/test/test_all.py +++ b/test/test_all.py @@ -4,6 +4,8 @@ import tempfile from multiprocessing import Pool +import tomli + import juliapkg @@ -74,14 +76,21 @@ def test_add_rm(): with tempfile.TemporaryDirectory() as tdir: def deps(): - fn = os.path.join(tdir, "juliapkg.json") - if not os.path.exists(fn): - return None - with open(os.path.join(tdir, "juliapkg.json")) as fp: - return json.load(fp) + # Try both TOML and JSON files + toml_fn = os.path.join(tdir, "juliapkg.toml") + json_fn = os.path.join(tdir, "juliapkg.json") + + if os.path.exists(toml_fn): + with open(toml_fn, "rb") as fp: + return tomli.load(fp) + elif os.path.exists(json_fn): + with open(json_fn) as fp: + return json.load(fp) + return None assert deps() is None + # Test adding with default TOML juliapkg.add( "Example1", target=tdir, @@ -89,6 +98,7 @@ def deps(): ) assert deps() == {"packages": {"Example1": {"uuid": "0001"}}} + assert os.path.exists(os.path.join(tdir, "juliapkg.toml")) juliapkg.add("Example2", target=tdir, uuid="0002") @@ -112,3 +122,30 @@ def deps(): juliapkg.rm("Example1", target=tdir) assert deps() == {"packages": {"Example2": {"uuid": "0002"}}} + + # Verify JSON wasn't created + assert not os.path.exists(os.path.join(tdir, "juliapkg.json")) + + +def test_json_fallback(): + with tempfile.TemporaryDirectory() as tdir: + # Create a JSON file first + json_fn = os.path.join(tdir, "juliapkg.json") + with open(json_fn, "w") as fp: + json.dump({"packages": {"Example1": {"uuid": "0001"}}}, fp) + + # Load should read from JSON + deps = juliapkg.deps.load_cur_deps(target=tdir) + assert deps == {"packages": {"Example1": {"uuid": "0001"}}} + + # Add should write to JSON since it exists + juliapkg.add("Example2", target=tdir, uuid="0002") + + with open(json_fn) as fp: + deps = json.load(fp) + assert deps == { + "packages": {"Example1": {"uuid": "0001"}, "Example2": {"uuid": "0002"}} + } + + # Verify TOML wasn't created + assert not os.path.exists(os.path.join(tdir, "juliapkg.toml"))