Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add TOML support for dependency files #48

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

## Unreleased
* Support for `juliapkg.toml` files.

## v0.1.15 (2024-11-08)
* Bug fixes.

Expand Down
28 changes: 20 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
108 changes: 73 additions & 35 deletions src/juliapkg/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import sys
from subprocess import run

import tomli
import tomli_w
from filelock import FileLock

from .compat import Compat, Version
Expand All @@ -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"]
Expand Down Expand Up @@ -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)))
Expand All @@ -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():
Expand Down Expand Up @@ -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)
):
Expand All @@ -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)"
Expand Down
1 change: 0 additions & 1 deletion src/juliapkg/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
47 changes: 42 additions & 5 deletions test/test_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import tempfile
from multiprocessing import Pool

import tomli

import juliapkg


Expand Down Expand Up @@ -74,21 +76,29 @@ 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,
uuid="0001",
)

assert deps() == {"packages": {"Example1": {"uuid": "0001"}}}
assert os.path.exists(os.path.join(tdir, "juliapkg.toml"))

juliapkg.add("Example2", target=tdir, uuid="0002")

Expand All @@ -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"))
Loading