├── .github ├── dependabot.yml └── workflows │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── pyproject.toml ├── src └── juliapkg │ ├── __init__.py │ ├── compat.py │ ├── deps.py │ ├── find_julia.py │ ├── install_julia.py │ ├── juliapkg.json │ └── state.py └── test ├── juliapkg_test_editable_setuptools ├── juliapkg_test_editable_setuptools │ ├── __init__.py │ └── juliapkg.json └── pyproject.toml ├── test_all.py ├── test_compat.py └── test_internals.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: github-actions 9 | directory: / 10 | schedule: 11 | interval: weekly 12 | - package-ecosystem: pip 13 | directory: / 14 | schedule: 15 | interval: weekly 16 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | tags: 11 | - '*' 12 | 13 | jobs: 14 | tests: 15 | name: Test (${{ matrix.os }}, python ${{ matrix.pyversion }}) 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, windows-latest, macos-latest] 20 | pyversion: ["3.x", "3.8"] 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python ${{ matrix.pyversion }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.pyversion }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install ruff pytest pytest-cov 31 | pip install -e . -e test/juliapkg_test_editable_setuptools 32 | - name: Lint with ruff 33 | run: | 34 | ruff format --check 35 | ruff check 36 | - name: Test with pytest 37 | run: | 38 | pytest --cov=src test 39 | - name: Upload coverage to Codecov 40 | uses: codecov/codecov-action@v5 41 | env: 42 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | __pycache__ 3 | /dist 4 | /build 5 | .python-version 6 | requirements.lock 7 | requirements-dev.lock 8 | uv.lock 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.9.5 5 | hooks: 6 | # Run the formatter. 7 | - id: ruff-format 8 | # Run the linter. 9 | - id: ruff 10 | args: [ --fix ] 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | * Support editable dependencies from setuptools (experimental). 5 | * Add `update()` function. 6 | * Improved input validation. 7 | 8 | ## v0.1.17 (2025-05-13) 9 | * Respect `JULIAUP_DEPOT_PATH` when searching for Julia using juliaup. 10 | * Add special handling of `<=python` version for OpenSSL compatibility between Julia and Python. 11 | * Bug fixes. 12 | 13 | ## v0.1.16 (2025-02-18) 14 | * Adds file-locking to protect multiple concurrent resolves. 15 | 16 | ## v0.1.15 (2024-11-08) 17 | * Bug fixes. 18 | 19 | ## v0.1.14 (2024-10-20) 20 | * When testing if a file has changed, now checks the actual content in addition to the 21 | modification time. 22 | 23 | ## v0.1.13 (2024-05-12) 24 | * Internal changes. 25 | 26 | ## v0.1.12 (2024-05-12) 27 | * Hyphen compat bounds (e.g. "1.6 - 1") now supported. 28 | * Resolving no longer throws an error from nested environments. 29 | * Bug fixes. 30 | 31 | ## v0.1.11 (2024-03-15) 32 | * Moved repo to [JuliaPy github org](https://github.com/JuliaPy). 33 | * Julia registry is now always updated when resolving. 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Christopher Rowley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JuliaPkg 2 | 3 | [![Project Status: Active – The project has reached a stable, usable state and is being actively developed.](https://www.repostatus.org/badges/latest/active.svg)](https://www.repostatus.org/#active) 4 | [![Tests](https://github.com/JuliaPy/pyjuliapkg/actions/workflows/tests.yml/badge.svg)](https://github.com/JuliaPy/pyjuliapkg/actions/workflows/tests.yml) 5 | [![Codecov](https://codecov.io/gh/JuliaPy/pyjuliapkg/branch/main/graph/badge.svg?token=A813UUIHGS)](https://codecov.io/gh/JuliaPy/pyjuliapkg) 6 | 7 | Do you want to use [Julia](https://julialang.org/) in your Python script/project/package? 8 | No problem! JuliaPkg will help you out! 9 | - Declare the version of Julia you require in a `juliapkg.json` file. 10 | - Add any packages you need too. 11 | - Call `juliapkg.resolve()` et voila, your dependencies are there. 12 | - Use `juliapkg.executable()` to find the Julia executable and `juliapkg.project()` to 13 | find the project where the packages were installed. 14 | - Virtual environments? PipEnv? Poetry? Conda? No problem! JuliaPkg will set up a 15 | different project for each environment you work in, keeping your dependencies isolated. 16 | 17 | ## Install 18 | 19 | ```sh 20 | pip install juliapkg 21 | ``` 22 | 23 | ## Declare dependencies 24 | 25 | ### Functional interface 26 | 27 | - `status(target=None)` shows the status of dependencies. 28 | - `require_julia(version, target=None)` declares that you require the given version of 29 | Julia. The `version` is a Julia compat specifier, so `1.5` matches any `1.*.*` version at 30 | least `1.5`. 31 | - `add(pkg, uuid, dev=False, version=None, path=None, subdir=None, url=None, rev=None, target=None)` 32 | adds a required package. Its name and UUID are required. 33 | - `rm(pkg, target=None)` remove a package. 34 | 35 | Note that these functions edit `juliapkg.json` but do not actually install anything until 36 | `resolve()` is called, which happens automatically in `executable()` and `project()`. 37 | 38 | The `target` specifies the `juliapkg.json` file to edit, or the directory containing it. 39 | If not given, it will be your virtual environment or Conda environment if you are using one, 40 | otherwise `~/.pyjuliapkg.json`. 41 | 42 | ### juliapkg.json 43 | 44 | You can also edit `juliapkg.json` directly if you like. Here is an example which requires 45 | Julia v1.*.* and the Example package v0.5.*: 46 | ```json 47 | { 48 | "julia": "1", 49 | "packages": { 50 | "Example": { 51 | "uuid": "7876af07-990d-54b4-ab0e-23690620f79a", 52 | "version": "0.5" 53 | } 54 | } 55 | } 56 | ``` 57 | 58 | ## Using Julia 59 | 60 | - `juliapkg.executable()` returns a compatible Julia executable. 61 | - `juliapkg.project()` returns the project into which the packages have been installed. 62 | - `juliapkg.resolve(force=False, dry_run=False)` ensures all the dependencies are installed. You don't 63 | normally need to do this because the other functions resolve automatically. 64 | - `juliapkg.update(dry_run=False)` updates the dependencies. 65 | 66 | ## Details 67 | 68 | ### Configuration 69 | 70 | JuliaPkg does not generally need configuring, but for advanced usage the following options 71 | are available. Options can be specified either as an environment variable or as an `-X` 72 | option to `python`. The `-X` option has higher precedence. 73 | 74 | | Environment Variable | `-X` Option | Description | 75 | | --- | --- | --- | 76 | | `PYTHON_JULIAPKG_EXE=` | `-X juliapkg-exe=` | The Julia executable to use. | 77 | | `PYTHON_JULIAPKG_PROJECT=` | `-X juliapkg-project=` | The Julia project where packages are installed. | 78 | | `PYTHON_JULIAPKG_OFFLINE=` | `-X juliapkg-offline=` | Work in Offline Mode - does not install Julia or any packages. | 79 | 80 | ### Which Julia gets used? 81 | 82 | JuliaPkg tries the following strategies in order to find Julia on your system: 83 | - If the `-X juliapkg-exe` argument to `python` is set, that is used. 84 | - If the environment variable `PYTHON_JULIAPKG_EXE` is set, that is used. 85 | - If `julia` is in your `PATH`, and is compatible, that is used. 86 | - If [`juliaup`](https://github.com/JuliaLang/juliaup) is in your `PATH`, it is used to install a compatible version of Julia. 87 | - Otherwise, JuliaPkg downloads a compatible version of Julia and installs it into the 88 | Julia project. 89 | 90 | More strategies may be added in a future release. 91 | 92 | ### Where are Julia packages installed? 93 | 94 | JuliaPkg installs packages into a project whose location is determined by trying the 95 | following strategies in order: 96 | - If the `-X juliapkg-project` argument to `python` is set, that is used. 97 | - If the environment variable `PYTHON_JULIAPKG_PROJECT` is set, that is used. 98 | - If you are in a Python virtual environment or Conda environment, then `{env}/julia_env` 99 | subdirectory is used. 100 | - Otherwise `~/.julia/environments/pyjuliapkg` is used (respects `JULIA_DEPOT`). 101 | 102 | More strategies may be added in a future release. 103 | 104 | ### Adding Julia dependencies to Python packages 105 | 106 | JuliaPkg looks for `juliapkg.json` files in many locations, namely: 107 | - `{project}/pyjuliapkg` where project is as above (depending on your environment). 108 | - Every installed package (looks through `sys.path` and `sys.meta_path`). 109 | 110 | The last point means that if you put a `juliapkg.json` file in a package, then install that 111 | package, then JuliaPkg will find those dependencies and install them. 112 | 113 | You can use `add`, `rm` etc. above with `target='/path/to/your/package'` to modify the 114 | dependencies of your package. 115 | 116 | ### Offline mode 117 | 118 | If you set the environment variable `PYTHON_JULIAPKG_OFFLINE=yes` (or call `python` with the 119 | option `-X juliapkg-offline=yes`) then JuliaPkg will operate in offline mode. This means it 120 | will not attempt to download Julia or any packages. 121 | 122 | Resolving will fail if Julia is not already installed. It is up to you to install any 123 | required Julia packages. 124 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "juliapkg" 3 | version = "0.1.17" 4 | description = "Julia version manager and package manager" 5 | authors = [{ name = "Christopher Doris" }] 6 | dependencies = ["semver >=3.0,<4.0", "filelock >=3.16,<4.0"] 7 | readme = "README.md" 8 | requires-python = ">=3.8" 9 | classifiers = [ 10 | "License :: OSI Approved :: MIT License", 11 | "Programming Language :: Python :: 3", 12 | ] 13 | 14 | [project.urls] 15 | Homepage = "http://github.com/JuliaPy/pyjuliapkg" 16 | Repository = "http://github.com/JuliaPy/pyjuliapkg.git" 17 | Issues = "http://github.com/JuliaPy/pyjuliapkg/issues" 18 | Changelog = "https://github.com/JuliaPy/pyjuliapkg/blob/main/CHANGELOG.md" 19 | 20 | [build-system] 21 | requires = ["hatchling"] 22 | build-backend = "hatchling.build" 23 | 24 | [tool.ruff.lint] 25 | select = ["E", "W", "F", "I"] 26 | 27 | [tool.uv] 28 | dev-dependencies = ["pytest", "pre-commit", "juliapkg_test_editable_setuptools"] 29 | 30 | [tool.uv.sources] 31 | juliapkg_test_editable_setuptools = { path = "test/juliapkg_test_editable_setuptools", editable = true } 32 | 33 | [tool.hatch.build.targets.wheel] 34 | packages = ["src/juliapkg"] 35 | -------------------------------------------------------------------------------- /src/juliapkg/__init__.py: -------------------------------------------------------------------------------- 1 | from .deps import ( 2 | PkgSpec, 3 | add, 4 | executable, 5 | offline, 6 | project, 7 | require_julia, 8 | resolve, 9 | rm, 10 | status, 11 | update, 12 | ) 13 | 14 | __all__ = [ 15 | "status", 16 | "resolve", 17 | "executable", 18 | "project", 19 | "PkgSpec", 20 | "require_julia", 21 | "add", 22 | "rm", 23 | "offline", 24 | "update", 25 | ] 26 | -------------------------------------------------------------------------------- /src/juliapkg/compat.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from semver import Version 4 | 5 | _re_partial_version = re.compile(r"^([0-9]+)(?:\.([0-9]+)(?:\.([0-9]+))?)?$") 6 | 7 | 8 | def _parse_partial_version(x): 9 | m = _re_partial_version.match(x) 10 | if m is None: 11 | return None, None 12 | major, minor, patch = m.groups() 13 | v = Version(major, minor or 0, patch or 0) 14 | n = 1 if minor is None else 2 if patch is None else 3 15 | return (v, n) 16 | 17 | 18 | class Compat: 19 | """A Julia compat specifier.""" 20 | 21 | def __init__(self, clauses=[]): 22 | self.clauses = [clause for clause in clauses if not clause.is_empty()] 23 | 24 | def __str__(self): 25 | return ", ".join(str(clause) for clause in self.clauses) 26 | 27 | def __repr__(self): 28 | return f"{type(self).__name__}({self.clauses!r})" 29 | 30 | def __contains__(self, v): 31 | return any(v in clause for clause in self.clauses) 32 | 33 | def __and__(self, other): 34 | clauses = [] 35 | for clause1 in self.clauses: 36 | for clause2 in other.clauses: 37 | clause = clause1 & clause2 38 | if not clause.is_empty(): 39 | clauses.append(clause) 40 | return Compat(clauses) 41 | 42 | def __bool__(self): 43 | return bool(self.clauses) 44 | 45 | def __eq__(self, other): 46 | return self.clauses == other.clauses 47 | 48 | @classmethod 49 | def parse(cls, verstr): 50 | """Parse a Julia compat specifier from a string. 51 | 52 | A specifier is a comma-separated list of clauses. The prefixes '^', '~' and '=' 53 | are supported. No prefix is equivalent to '^'. 54 | """ 55 | clauses = [] 56 | if verstr.strip(): 57 | for part in verstr.split(","): 58 | clause = Range.parse(part) 59 | clauses.append(clause) 60 | return cls(clauses) 61 | 62 | 63 | class Range: 64 | def __init__(self, lo, hi): 65 | self.lo = lo 66 | self.hi = hi 67 | 68 | @classmethod 69 | def tilde(cls, v, n): 70 | lo = Version( 71 | v.major, 72 | v.minor if n >= 2 else 0, 73 | v.patch if n >= 3 else 0, 74 | ) 75 | hi = ( 76 | v.bump_major() 77 | if n < 2 78 | else v.bump_minor() 79 | if v.major != 0 or v.minor != 0 or n < 3 80 | else v.bump_patch() 81 | ) 82 | return Range(lo, hi) 83 | 84 | @classmethod 85 | def caret(cls, v, n): 86 | lo = Version( 87 | v.major, 88 | v.minor if n >= 2 else 0, 89 | v.patch if n >= 3 else 0, 90 | ) 91 | hi = ( 92 | v.bump_major() 93 | if v.major != 0 or n < 2 94 | else v.bump_minor() 95 | if v.minor != 0 or n < 3 96 | else v.bump_patch() 97 | ) 98 | return Range(lo, hi) 99 | 100 | @classmethod 101 | def equality(cls, v): 102 | lo = v 103 | hi = v.bump_patch() 104 | return Range(lo, hi) 105 | 106 | @classmethod 107 | def hyphen(cls, v1, v2, n): 108 | lo = v1 109 | hi = v2.bump_major() if n < 2 else v2.bump_minor() if n < 3 else v2.bump_patch() 110 | return Range(lo, hi) 111 | 112 | @classmethod 113 | def parse(cls, x): 114 | x = x.strip() 115 | if x.startswith("~"): 116 | # tilde specifier 117 | v, n = _parse_partial_version(x[1:]) 118 | if v is not None: 119 | return cls.tilde(v, n) 120 | elif x.startswith("="): 121 | # equality specifier 122 | v, n = _parse_partial_version(x[1:]) 123 | if v is not None and n == 3: 124 | return cls.equality(v) 125 | elif " - " in x: 126 | # range specifier 127 | part1, part2 = x.split(" - ", 1) 128 | v1, _ = _parse_partial_version(part1.strip()) 129 | v2, n = _parse_partial_version(part2.strip()) 130 | if v1 is not None and v2 is not None: 131 | return cls.hyphen(v1, v2, n) 132 | else: 133 | # caret specifier 134 | v, n = _parse_partial_version(x[1:] if x.startswith("^") else x) 135 | if v is not None: 136 | return cls.caret(v, n) 137 | raise ValueError(f"invalid version specifier: {x}") 138 | 139 | def __str__(self): 140 | lo = self.lo 141 | hi = self.hi 142 | if self == Range.equality(lo): 143 | return f"={lo.major}.{lo.minor}.{lo.patch}" 144 | if self == Range.caret(lo, 1): 145 | return f"^{lo.major}" 146 | if self == Range.caret(lo, 2): 147 | return f"^{lo.major}.{lo.minor}" 148 | if self == Range.caret(lo, 3): 149 | return f"^{lo.major}.{lo.minor}.{lo.patch}" 150 | if self == Range.tilde(lo, 1): 151 | return f"~{lo.major}" 152 | if self == Range.tilde(lo, 2): 153 | return f"~{lo.major}.{lo.minor}" 154 | if self == Range.tilde(lo, 3): 155 | return f"~{lo.major}.{lo.minor}.{lo.patch}" 156 | lostr = f"{lo.major}.{lo.minor}.{lo.patch}" 157 | if hi.major > 0 and hi.minor == 0 and hi.patch == 0: 158 | return f"{lostr} - {hi.major - 1}" 159 | if hi.minor > 0 and hi.patch == 0: 160 | return f"{lostr} - {hi.major}.{hi.minor - 1}" 161 | if hi.patch > 0: 162 | return f"{lostr} - {hi.major}.{hi.minor}.{hi.patch - 1}" 163 | raise ValueError("invalid range") 164 | 165 | def __repr__(self): 166 | return f"{type(self).__name__}({self.lo!r}, {self.hi!r})" 167 | 168 | def __contains__(self, v): 169 | return self.lo <= v < self.hi 170 | 171 | def __and__(self, other): 172 | lo = max(self.lo, other.lo) 173 | hi = min(self.hi, other.hi) 174 | return Range(lo, hi) 175 | 176 | def __eq__(self, other): 177 | return (self.lo == other.lo and self.hi == other.hi) or ( 178 | self.is_empty() and other.is_empty() 179 | ) 180 | 181 | def is_empty(self): 182 | return not (self.lo < self.hi) 183 | -------------------------------------------------------------------------------- /src/juliapkg/deps.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import logging 4 | import os 5 | import sys 6 | from subprocess import run 7 | from typing import Union 8 | 9 | from filelock import FileLock 10 | 11 | from .compat import Compat, Version 12 | from .find_julia import find_julia, julia_version 13 | from .install_julia import log, log_script 14 | from .state import STATE 15 | 16 | logger = logging.getLogger("juliapkg") 17 | 18 | ### META 19 | 20 | META_VERSION = 5 # increment whenever the format changes 21 | 22 | 23 | def load_meta(): 24 | fn = STATE["meta"] 25 | if os.path.exists(fn): 26 | with open(fn) as fp: 27 | meta = json.load(fp) 28 | if meta.get("meta_version") == META_VERSION: 29 | return meta 30 | 31 | 32 | def save_meta(meta): 33 | assert isinstance(meta, dict) 34 | assert meta.get("meta_version") == META_VERSION 35 | fn = STATE["meta"] 36 | os.makedirs(os.path.dirname(fn), exist_ok=True) 37 | if os.path.exists(fn): 38 | with open(fn) as fp: 39 | old_meta_json = fp.read() 40 | meta_json = json.dumps(meta) 41 | if meta_json == old_meta_json: 42 | # No need to write out if nothing changed 43 | return 44 | with open(fn, "w") as fp: 45 | json.dump(meta, fp) 46 | 47 | 48 | ### RESOLVE 49 | 50 | 51 | class PkgSpec: 52 | def __init__( 53 | self, 54 | name: str, 55 | uuid: str, 56 | dev: bool = False, 57 | version: Union[str, Version, None] = None, 58 | path: Union[str, None] = None, 59 | subdir: Union[str, None] = None, 60 | url: Union[str, None] = None, 61 | rev: Union[str, None] = None, 62 | ): 63 | # Validate name (non-empty string) 64 | if not isinstance(name, str) or not name.strip(): 65 | raise ValueError("name must be a non-empty string") 66 | self.name = name 67 | 68 | # Validate UUID (non-empty string, could add more specific UUID validation) 69 | if not isinstance(uuid, str) or not uuid.strip(): 70 | raise ValueError("uuid must be a non-empty string") 71 | self.uuid = uuid 72 | 73 | # Validate dev (boolean) 74 | if not isinstance(dev, bool): 75 | raise TypeError("dev must be a boolean") 76 | self.dev = dev 77 | 78 | # Validate version (string, Version, or None) 79 | if version is not None and not isinstance(version, (str, Version)): 80 | raise TypeError("version must be a string, Version, or None") 81 | self.version = version 82 | 83 | # Validate path (string or None) 84 | if path is not None and not isinstance(path, str): 85 | raise TypeError("path must be a string or None") 86 | self.path = path 87 | 88 | # Validate subdir (string or None) 89 | if subdir is not None and not isinstance(subdir, str): 90 | raise TypeError("subdir must be a string or None") 91 | self.subdir = subdir 92 | 93 | # Validate url (string or None) 94 | if url is not None and not isinstance(url, str): 95 | raise TypeError("url must be a string or None") 96 | self.url = url 97 | 98 | # Validate rev (string or None) 99 | if rev is not None and not isinstance(rev, str): 100 | raise TypeError("rev must be a string or None") 101 | self.rev = rev 102 | 103 | def jlstr(self): 104 | args = ['name="{}"'.format(self.name), 'uuid="{}"'.format(self.uuid)] 105 | if self.path is not None: 106 | args.append('path=raw"{}"'.format(self.path)) 107 | if self.subdir is not None: 108 | args.append('subdir="{}"'.format(self.subdir)) 109 | if self.url is not None: 110 | args.append('url=raw"{}"'.format(self.url)) 111 | if self.rev is not None: 112 | args.append('rev=raw"{}"'.format(self.rev)) 113 | return "Pkg.PackageSpec({})".format(", ".join(args)) 114 | 115 | def dict(self): 116 | ans = { 117 | "name": self.name, 118 | "uuid": self.uuid, 119 | "dev": self.dev, 120 | "version": str(self.version), 121 | "path": self.path, 122 | "subdir": self.subdir, 123 | "url": self.url, 124 | "rev": self.rev, 125 | } 126 | return {k: v for (k, v) in ans.items() if v is not None} 127 | 128 | def depsdict(self): 129 | ans = {} 130 | ans["uuid"] = self.uuid 131 | if self.dev: 132 | ans["dev"] = self.dev 133 | if self.version is not None: 134 | ans["version"] = str(self.version) 135 | if self.path is not None: 136 | ans["path"] = self.path 137 | if self.subdir is not None: 138 | ans["subdir"] = self.subdir 139 | if self.url is not None: 140 | ans["url"] = self.url 141 | if self.rev is not None: 142 | ans["rev"] = self.rev 143 | return ans 144 | 145 | 146 | def _get_hash(filename): 147 | with open(filename, "rb") as f: 148 | return hashlib.sha256(f.read()).hexdigest() 149 | 150 | 151 | def can_skip_resolve(): 152 | # resolve if we haven't resolved before 153 | deps = load_meta() 154 | if deps is None: 155 | logger.debug("no meta file") 156 | return False 157 | # resolve whenever the overridden Julia executable changes 158 | if STATE["override_executable"] != deps["override_executable"]: 159 | logger.debug( 160 | "set exectuable was %r now %r", 161 | deps["override_executable"], 162 | STATE["override_executable"], 163 | ) 164 | return False 165 | # resolve whenever Julia changes 166 | exe = deps["executable"] 167 | ver = deps["version"] 168 | exever = julia_version(exe) 169 | if exever is None or ver != str(exever): 170 | logger.debug("changed version %s to %s", ver, exever) 171 | return False 172 | # resolve when going from offline to online 173 | offline = deps["offline"] 174 | if offline and not STATE["offline"]: 175 | logger.debug("was offline now online") 176 | return False 177 | # resolve whenever swapping between dev/not dev 178 | isdev = deps["dev"] 179 | if isdev != STATE["dev"]: 180 | logger.debug("changed dev %s to %s", isdev, STATE["dev"]) 181 | return False 182 | # resolve whenever any deps files change 183 | files0 = set(deps_files()) 184 | files = deps["deps_files"] 185 | filesdiff = set(files.keys()).difference(files0) 186 | if filesdiff: 187 | logger.debug("deps files added %s", filesdiff) 188 | return False 189 | filesdiff = files0.difference(files.keys()) 190 | if filesdiff: 191 | logger.debug("deps files removed %s", filesdiff) 192 | return False 193 | for filename, fileinfo in files.items(): 194 | if not os.path.isfile(filename): 195 | logger.debug("deps file no longer exists %r", filename) 196 | return False 197 | if os.path.getmtime(filename) > fileinfo["timestamp"]: 198 | if _get_hash(filename) != fileinfo["hash_sha256"]: 199 | logger.debug("deps file has changed %r", filename) 200 | return False 201 | return deps 202 | 203 | 204 | def editable_deps_files(): 205 | """Finds setuptools-style editable dependencies.""" 206 | ans = [] 207 | for finder in sys.meta_path: 208 | module_name = finder.__module__ 209 | if module_name.startswith("__editable___") and module_name.endswith("_finder"): 210 | m = sys.modules[module_name] 211 | paths = m.MAPPING.values() 212 | for path in paths: 213 | if not os.path.isdir(path): 214 | continue 215 | fn = os.path.join(path, "juliapkg.json") 216 | ans.append(fn) 217 | for subdir in os.listdir(path): 218 | fn = os.path.join(path, subdir, "juliapkg.json") 219 | ans.append(fn) 220 | 221 | return ans 222 | 223 | 224 | def deps_files(): 225 | ans = [] 226 | # the default deps file 227 | ans.append(cur_deps_file()) 228 | # look in sys.path 229 | for path in sys.path: 230 | if not path: 231 | path = os.getcwd() 232 | if not os.path.isdir(path): 233 | continue 234 | fn = os.path.join(path, "juliapkg.json") 235 | ans.append(fn) 236 | for subdir in os.listdir(path): 237 | fn = os.path.join(path, subdir, "juliapkg.json") 238 | ans.append(fn) 239 | 240 | ans += editable_deps_files() 241 | 242 | return list( 243 | set( 244 | os.path.normcase(os.path.normpath(os.path.abspath(fn))) 245 | for fn in ans 246 | if os.path.isfile(fn) 247 | ) 248 | ) 249 | 250 | 251 | def openssl_compat(version=None): 252 | if version is None: 253 | import ssl 254 | 255 | version = ssl.OPENSSL_VERSION_INFO 256 | 257 | major, minor, patch = version[:3] 258 | if major >= 3: 259 | return f"{major} - {major}.{minor}" 260 | else: 261 | return f"{major}.{minor} - {major}.{minor}.{patch}" 262 | 263 | 264 | def find_requirements(): 265 | # read all dependencies into a dict: name -> key -> file -> value 266 | # read all julia compats into a dict: file -> compat 267 | import json 268 | 269 | compats = {} 270 | all_deps = {} 271 | for fn in deps_files(): 272 | log("Found dependencies: {}".format(fn)) 273 | with open(fn) as fp: 274 | deps = json.load(fp) 275 | for name, kvs in deps.get("packages", {}).items(): 276 | dep = all_deps.setdefault(name, {}) 277 | for k, v in kvs.items(): 278 | if k == "path": 279 | # resolve paths relative to the directory containing the file 280 | v = os.path.normcase( 281 | os.path.normpath(os.path.join(os.path.dirname(fn), v)) 282 | ) 283 | dep.setdefault(k, {})[fn] = v 284 | # special handling of `verion = "<=python"` for `OpenSSL_jll 285 | if ( 286 | name == "OpenSSL_jll" 287 | and dep.get("uuid").get(fn) == "458c3c95-2e84-50aa-8efc-19380b2a3a95" 288 | and dep.get("version").get(fn) == "<=python" 289 | ): 290 | dep["version"][fn] = openssl_compat() 291 | c = deps.get("julia") 292 | if c is not None: 293 | compats[fn] = Compat.parse(c) 294 | 295 | # merges non-unique values 296 | def merge_unique(dep, kfvs, k): 297 | fvs = kfvs.pop(k, None) 298 | if fvs is not None: 299 | vs = set(fvs.values()) 300 | if len(vs) == 1: 301 | (dep[k],) = vs 302 | elif vs: 303 | raise Exception( 304 | "'{}' entries are not unique:\n{}".format( 305 | k, 306 | "\n".join( 307 | ["- {!r} at {}".format(v, f) for (f, v) in fvs.items()] 308 | ), 309 | ) 310 | ) 311 | 312 | # merges compat entries 313 | def merge_compat(dep, kfvs, k): 314 | fvs = kfvs.pop(k, None) 315 | if fvs is not None: 316 | compats = list(map(Compat.parse, fvs.values())) 317 | compat = compats[0] 318 | for c in compats[1:]: 319 | compat &= c 320 | if not compat: 321 | raise Exception( 322 | "'{}' entries have empty intersection:\n{}".format( 323 | k, 324 | "\n".join( 325 | ["- {!r} at {}".format(v, f) for (f, v) in fvs.items()] 326 | ), 327 | ) 328 | ) 329 | else: 330 | dep[k] = str(compat) 331 | 332 | # merges booleans with any 333 | def merge_any(dep, kfvs, k): 334 | fvs = kfvs.pop(k, None) 335 | if fvs is not None: 336 | dep[k] = any(fvs.values()) 337 | 338 | # merge dependencies: name -> key -> value 339 | deps = [] 340 | for name, kfvs in all_deps.items(): 341 | kw = {"name": name} 342 | merge_unique(kw, kfvs, "uuid") 343 | merge_unique(kw, kfvs, "path") 344 | merge_unique(kw, kfvs, "subdir") 345 | merge_unique(kw, kfvs, "url") 346 | merge_unique(kw, kfvs, "rev") 347 | merge_compat(kw, kfvs, "version") 348 | merge_any(kw, kfvs, "dev") 349 | deps.append(PkgSpec(**kw)) 350 | # julia compat 351 | compat = None 352 | for c in compats.values(): 353 | if compat is None: 354 | compat = c 355 | else: 356 | compat &= c 357 | if compat is not None and not compat: 358 | raise Exception( 359 | "'julia' compat entries have empty intersection:\n{}".format( 360 | "\n".join(["- {!r} at {}".format(v, f) for (f, v) in compats.items()]) 361 | ) 362 | ) 363 | return compat, deps 364 | 365 | 366 | def resolve(force=False, dry_run=False, update=False): 367 | """ 368 | Resolve the dependencies. 369 | 370 | Args: 371 | force (bool): Force resolution. 372 | dry_run (bool): Dry run. 373 | update (bool): Update the dependencies. 374 | 375 | Returns: 376 | bool: Whether the dependencies are resolved (always True unless dry_run is 377 | True). 378 | """ 379 | # update implies force 380 | if update: 381 | force = True 382 | # fast check to see if we have already resolved 383 | if (not force) and STATE["resolved"]: 384 | return True 385 | STATE["resolved"] = False 386 | # use a lock to prevent concurrent resolution 387 | project = STATE["project"] 388 | os.makedirs(project, exist_ok=True) 389 | lock_file = os.path.join(project, "lock.pid") 390 | lock = FileLock(lock_file) 391 | try: 392 | lock.acquire(timeout=3) 393 | except TimeoutError: 394 | log( 395 | f"Waiting for lock on {lock_file} to be freed. This normally means that" 396 | " another process is resolving. If you know that no other process is" 397 | " resolving, delete this file to proceed." 398 | ) 399 | lock.acquire() 400 | try: 401 | # see if we can skip resolving 402 | if not force: 403 | deps = can_skip_resolve() 404 | if deps: 405 | STATE["resolved"] = True 406 | STATE["executable"] = deps["executable"] 407 | STATE["version"] = Version.parse(deps["version"]) 408 | return True 409 | if dry_run: 410 | return False 411 | # get julia compat and required packages 412 | compat, pkgs = find_requirements() 413 | # find a compatible julia executable 414 | log(f"Locating Julia{'' if compat is None else ' ' + str(compat)}") 415 | exe, ver = find_julia( 416 | compat=compat, prefix=STATE["install"], install=True, upgrade=True 417 | ) 418 | log(f"Using Julia {ver} at {exe}") 419 | # set up the project 420 | log(f"Using Julia project at {project}") 421 | if not STATE["offline"]: 422 | # write a Project.toml specifying UUIDs and compatibility of required 423 | # packages 424 | projtoml = [] 425 | projtoml.append("[deps]") 426 | projtoml.extend(f'{pkg.name} = "{pkg.uuid}"' for pkg in pkgs) 427 | projtoml.append("[compat]") 428 | projtoml.extend( 429 | f'{pkg.name} = "{pkg.version}"' for pkg in pkgs if pkg.version 430 | ) 431 | log_script(projtoml, "Writing Project.toml:") 432 | with open(os.path.join(project, "Project.toml"), "wt") as fp: 433 | for line in projtoml: 434 | print(line, file=fp) 435 | # remove Manifest.toml 436 | manifest_path = os.path.join(project, "Manifest.toml") 437 | if os.path.exists(manifest_path): 438 | os.remove(manifest_path) 439 | # install the packages 440 | dev_pkgs = [pkg for pkg in pkgs if pkg.dev] 441 | add_pkgs = [pkg for pkg in pkgs if not pkg.dev] 442 | script = ["import Pkg", "Pkg.Registry.update()"] 443 | if dev_pkgs: 444 | script.append("Pkg.develop([") 445 | for pkg in dev_pkgs: 446 | script.append(f" {pkg.jlstr()},") 447 | script.append("])") 448 | if add_pkgs: 449 | script.append("Pkg.add([") 450 | for pkg in add_pkgs: 451 | script.append(f" {pkg.jlstr()},") 452 | script.append("])") 453 | if update: 454 | script.append("Pkg.update()") 455 | else: 456 | script.append("Pkg.resolve()") 457 | script.append("Pkg.precompile()") 458 | log_script(script, "Installing packages:") 459 | run_julia(script, executable=exe, project=project) 460 | # record that we resolved 461 | save_meta( 462 | { 463 | "meta_version": META_VERSION, 464 | "dev": STATE["dev"], 465 | "version": str(ver), 466 | "executable": exe, 467 | "deps_files": { 468 | filename: { 469 | "timestamp": os.path.getmtime(filename), 470 | "hash_sha256": _get_hash(filename), 471 | } 472 | for filename in deps_files() 473 | }, 474 | "pkgs": [pkg.dict() for pkg in pkgs], 475 | "offline": bool(STATE["offline"]), 476 | "override_executable": STATE["override_executable"], 477 | } 478 | ) 479 | STATE["resolved"] = True 480 | STATE["executable"] = exe 481 | STATE["version"] = ver 482 | return True 483 | finally: 484 | lock.release() 485 | 486 | 487 | def run_julia(script, executable=None, project=None): 488 | """ 489 | Run a Julia script with the specified executable and project. 490 | 491 | Args: 492 | executable (str): Path to the Julia executable. 493 | project (str): Path to the Julia project. 494 | script (list): List of strings representing the Julia script to run. 495 | """ 496 | if executable is None: 497 | executable = STATE["executable"] 498 | if project is None: 499 | project = STATE["project"] 500 | 501 | env = os.environ.copy() 502 | if sys.executable: 503 | # prefer PythonCall to use the current Python executable 504 | # TODO: this is a hack, it would be better for PythonCall to detect that 505 | # Julia is being called from Python 506 | env.setdefault("JULIA_PYTHONCALL_EXE", sys.executable) 507 | run( 508 | [ 509 | executable, 510 | "--project=" + project, 511 | "--startup-file=no", 512 | "-e", 513 | "\n".join(script), 514 | ], 515 | check=True, 516 | env=env, 517 | ) 518 | 519 | 520 | def executable(): 521 | resolve() 522 | return STATE["executable"] 523 | 524 | 525 | def project(): 526 | resolve() 527 | return STATE["project"] 528 | 529 | 530 | def update(dry_run=False): 531 | """ 532 | Resolve and update the dependencies. 533 | 534 | Args: 535 | dry_run (bool): Dry run. 536 | 537 | Returns: 538 | bool: Whether the dependencies were updated (always True unless dry_run is 539 | True). 540 | """ 541 | return resolve(dry_run=dry_run, update=True) 542 | 543 | 544 | def cur_deps_file(target=None): 545 | if target is None: 546 | return STATE["deps"] 547 | elif os.path.isdir(target): 548 | return os.path.abspath(os.path.join(target, "juliapkg.json")) 549 | elif os.path.isfile(target) or ( 550 | os.path.isdir(os.path.dirname(target)) and not os.path.exists(target) 551 | ): 552 | return os.path.abspath(target) 553 | else: 554 | raise ValueError( 555 | "target must be an existing directory," 556 | " or a file name in an existing directory" 557 | ) 558 | 559 | 560 | def load_cur_deps(target=None): 561 | fn = cur_deps_file(target=target) 562 | if os.path.exists(fn): 563 | with open(fn) as fp: 564 | deps = json.load(fp) 565 | else: 566 | deps = {} 567 | return deps 568 | 569 | 570 | def write_cur_deps(deps, target=None): 571 | fn = cur_deps_file(target=target) 572 | if deps: 573 | os.makedirs(os.path.dirname(fn), exist_ok=True) 574 | with open(fn, "w") as fp: 575 | json.dump(deps, fp) 576 | else: 577 | if os.path.exists(fn): 578 | os.remove(fn) 579 | 580 | 581 | def status(target=None): 582 | res = resolve(dry_run=True) 583 | print("JuliaPkg Status") 584 | fn = cur_deps_file(target=target) 585 | if os.path.exists(fn): 586 | with open(fn) as fp: 587 | deps = json.load(fp) 588 | else: 589 | deps = {} 590 | st = "" if deps else " (empty project)" 591 | print(f"{fn}{st}") 592 | if res: 593 | exe = STATE["executable"] 594 | ver = STATE["version"] 595 | else: 596 | print("Not resolved (resolve for more information)") 597 | jl = deps.get("julia") 598 | if res or jl: 599 | print("Julia", end="") 600 | if res: 601 | print(f" {ver}", end="") 602 | if jl: 603 | print(f" ({jl})", end="") 604 | if res: 605 | print(f" @ {exe}", end="") 606 | print() 607 | pkgs = deps.get("packages") 608 | if pkgs: 609 | print("Packages:") 610 | for name, info in pkgs.items(): 611 | print(f" {name}: {info}") 612 | 613 | 614 | def require_julia(compat, target=None): 615 | deps = load_cur_deps(target=target) 616 | if compat is None: 617 | if "julia" in deps: 618 | del deps["julia"] 619 | else: 620 | if isinstance(compat, str): 621 | compat = Compat.parse(compat) 622 | elif not isinstance(compat, Compat): 623 | raise TypeError 624 | deps["julia"] = str(compat) 625 | write_cur_deps(deps, target=target) 626 | STATE["resolved"] = False 627 | 628 | 629 | def add(pkg, *args, target=None, **kwargs): 630 | deps = load_cur_deps(target=target) 631 | _add(deps, pkg, *args, **kwargs) 632 | write_cur_deps(deps, target=target) 633 | STATE["resolved"] = False 634 | 635 | 636 | def _add(deps, pkg, uuid=None, **kwargs): 637 | if isinstance(pkg, PkgSpec): 638 | pkgs = deps.setdefault("packages", {}) 639 | pkgs[pkg.name] = pkg.depsdict() 640 | elif isinstance(pkg, str): 641 | if uuid is None: 642 | raise TypeError("uuid is required") 643 | pkg = PkgSpec(pkg, uuid, **kwargs) 644 | _add(deps, pkg) 645 | else: 646 | for p in pkg: 647 | _add(deps, p) 648 | 649 | 650 | def rm(pkg, target=None): 651 | deps = load_cur_deps(target=target) 652 | _rm(deps, pkg) 653 | write_cur_deps(deps, target=target) 654 | STATE["resolved"] = False 655 | 656 | 657 | def _rm(deps, pkg): 658 | if isinstance(pkg, PkgSpec): 659 | _rm(deps, pkg.name) 660 | elif isinstance(pkg, str): 661 | pkgs = deps.setdefault("packages", {}) 662 | if pkg in pkgs: 663 | del pkgs[pkg] 664 | if not pkgs: 665 | del deps["packages"] 666 | else: 667 | for p in pkg: 668 | _rm(deps, p) 669 | 670 | 671 | def offline(value=True): 672 | if value is not None: 673 | STATE["offline"] = value 674 | if value: 675 | STATE["resolved"] = False 676 | return STATE["offline"] 677 | -------------------------------------------------------------------------------- /src/juliapkg/find_julia.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import shutil 4 | from subprocess import PIPE, run 5 | 6 | from .compat import Compat, Version 7 | from .install_julia import best_julia_version, get_short_arch, install_julia, log 8 | from .state import STATE 9 | 10 | 11 | def julia_version(exe): 12 | try: 13 | words = ( 14 | run([exe, "--version"], check=True, capture_output=True, encoding="utf8") 15 | .stdout.strip() 16 | .split() 17 | ) 18 | if words[0].lower() == "julia" and words[1].lower() == "version": 19 | return Version.parse(words[2]) 20 | except Exception: 21 | pass 22 | 23 | 24 | def find_julia(compat=None, prefix=None, install=False, upgrade=False): 25 | """Find a Julia executable compatible with compat. 26 | 27 | Args: 28 | compat: A juliapkg.compat.Compat giving bounds on the allowed version of Julia. 29 | prefix: An optional prefix in which to look for or install Julia. 30 | install: If True, install Julia if it is not found. This will use JuliaUp if 31 | available, otherwise will install into the given prefix. 32 | upgrade: If True, find the latest compatible release. Implies install=True. 33 | 34 | As a special case, upgrade=True does not apply when Julia is found in the PATH, 35 | because if it is already installed then the user is already managing their own Julia 36 | versions. 37 | """ 38 | bestcompat = None 39 | if STATE["offline"]: 40 | upgrade = False 41 | install = False 42 | if upgrade: 43 | install = True 44 | # configured executable 45 | ev_exe = STATE["override_executable"] 46 | if ev_exe: 47 | ev_ver = julia_version(ev_exe) 48 | if ev_ver is None: 49 | raise Exception(f"juliapkg_exe={ev_exe} is not a Julia executable.") 50 | else: 51 | if compat is not None and ev_ver not in compat: 52 | log( 53 | f"WARNING: juliapkg_exe={ev_exe} is Julia {ev_ver} but {compat} is" 54 | " required." 55 | ) 56 | return (ev_exe, ev_ver) 57 | # first look in the prefix 58 | if prefix is not None: 59 | ext = ".exe" if os.name == "nt" else "" 60 | pr_exe = shutil.which(os.path.join(prefix, "bin", "julia" + ext)) 61 | pr_ver = julia_version(pr_exe) 62 | if pr_ver is not None: 63 | if compat is None or pr_ver in compat: 64 | if upgrade and bestcompat is None: 65 | bestcompat = Compat.parse("=" + best_julia_version(compat)[0]) 66 | if bestcompat is None or pr_ver in bestcompat: 67 | return (pr_exe, pr_ver) 68 | # see if juliaup is installed 69 | try_jl = True 70 | ju_exe = shutil.which("juliaup") 71 | if ju_exe: 72 | ju_compat = ( 73 | Compat.parse("=" + ju_best_julia_version(compat)[0]) if upgrade else compat 74 | ) 75 | ans = ju_find_julia(ju_compat, install=install) 76 | if ans: 77 | return ans 78 | try_jl = install 79 | if try_jl: 80 | # see if julia is installed 81 | jl_exe = shutil.which("julia") 82 | jl_ver = julia_version(jl_exe) 83 | if jl_ver is not None: 84 | if compat is None or jl_ver in compat: 85 | return (jl_exe, jl_ver) 86 | else: 87 | log( 88 | f"WARNING: You have Julia {jl_ver} installed but {compat} is" 89 | " required." 90 | ) 91 | log(" It is recommended that you upgrade Julia or install JuliaUp.") 92 | # install into the prefix 93 | if install and prefix is not None: 94 | if upgrade and bestcompat is None: 95 | bestcompat = Compat.parse("=" + best_julia_version(compat)[0]) 96 | ver, info = best_julia_version(bestcompat if upgrade else compat) 97 | log(f"WARNING: About to install Julia {ver} to {prefix}.") 98 | log(" If you use juliapkg in more than one environment, you are likely to") 99 | log(" have Julia installed in multiple locations. It is recommended to") 100 | log(" install JuliaUp (https://github.com/JuliaLang/juliaup) or Julia") 101 | log(" (https://julialang.org/downloads) yourself.") 102 | install_julia(info, prefix) 103 | pr_exe = shutil.which(os.path.join(prefix, "bin", "julia" + ext)) 104 | pr_ver = julia_version(pr_exe) 105 | assert pr_ver is not None 106 | assert compat is None or pr_ver in compat 107 | assert bestcompat is None or pr_ver in bestcompat 108 | return (pr_exe, pr_ver) 109 | # failed 110 | compatstr = "" if compat is None else f" {compat}" 111 | raise Exception(f"could not find Julia{compatstr}") 112 | 113 | 114 | def ju_list_julia_versions(compat=None): 115 | proc = run(["juliaup", "list"], check=True, stdout=PIPE) 116 | vers = {} 117 | arch = get_short_arch() 118 | for line in proc.stdout.decode("utf-8").splitlines(): 119 | words = line.strip().split() 120 | if len(words) == 2: 121 | c, v = words 122 | try: 123 | ver = Version.parse(v) 124 | except Exception: 125 | continue 126 | if ver.prerelease: 127 | continue 128 | if arch not in ver.build: 129 | continue 130 | ver = Version(ver.major, ver.minor, ver.patch) 131 | if compat is None or ver in compat: 132 | vers.setdefault(f"{ver.major}.{ver.minor}.{ver.patch}", []).append(c) 133 | return vers 134 | 135 | 136 | def ju_best_julia_version(compat=None): 137 | vers = ju_list_julia_versions(compat) 138 | if not vers: 139 | raise Exception( 140 | f"no version of Julia is compatible with {compat} - perhaps you need to" 141 | " update JuliaUp" 142 | ) 143 | v = sorted(vers.keys(), key=Version.parse, reverse=True)[0] 144 | return v, vers[v] 145 | 146 | 147 | def ju_find_julia(compat=None, install=False): 148 | # see if it is already installed 149 | ans = ju_find_julia_noinstall(compat) 150 | if ans: 151 | return ans 152 | # install it 153 | if install: 154 | ver, channels = ju_best_julia_version(compat) 155 | log(f"Installing Julia {ver} using JuliaUp") 156 | msgs = [] 157 | for channel in channels: 158 | proc = run(["juliaup", "add", channel], stderr=PIPE) 159 | if proc.returncode == 0: 160 | msgs = [] 161 | break 162 | else: 163 | msg = proc.stderr.decode("utf-8").strip() 164 | if msg not in msgs: 165 | msgs.append(msg) 166 | if msgs: 167 | log(f"WARNING: Failed to install Julia {ver} using JuliaUp: {msgs}") 168 | ans = ju_find_julia_noinstall(Compat.parse("=" + ver)) 169 | if ans: 170 | return ans 171 | Exception(f"JuliaUp just installed Julia {ver} but cannot find it") 172 | 173 | 174 | def ju_find_julia_noinstall(compat=None): 175 | # juliaup does not follow JULIA_DEPOT_PATH, but instead defines its 176 | # own env var for overriding ~/.julia 177 | ju_depot_path = os.getenv("JULIAUP_DEPOT_PATH") 178 | if not ju_depot_path: 179 | ju_depot_path = os.path.abspath(os.path.join(os.path.expanduser("~"), ".julia")) 180 | judir = os.path.join(ju_depot_path, "juliaup") 181 | metaname = os.path.join(judir, "juliaup.json") 182 | arch = get_short_arch() 183 | if os.path.exists(metaname): 184 | with open(metaname) as fp: 185 | meta = json.load(fp) 186 | versions = [] 187 | for verstr, info in meta.get("InstalledVersions", {}).items(): 188 | ver = Version.parse( 189 | verstr.replace("~", ".") 190 | ) # juliaup used to use VER~ARCH 191 | if ver.prerelease or arch not in ver.build: 192 | continue 193 | ver = Version(ver.major, ver.minor, ver.patch) 194 | if compat is None or ver in compat: 195 | if "Path" in info: 196 | ext = ".exe" if os.name == "nt" else "" 197 | exe = os.path.abspath( 198 | os.path.join(judir, info["Path"], "bin", "julia" + ext) 199 | ) 200 | versions.append((exe, ver)) 201 | versions.sort(key=lambda x: x[1], reverse=True) 202 | for exe, _ in versions: 203 | ver = julia_version(exe) 204 | if ver is None: 205 | raise Exception( 206 | f"{exe} (installed by juliaup) is not a valid Julia executable" 207 | ) 208 | if compat is None or ver in compat: 209 | return (exe, ver) 210 | -------------------------------------------------------------------------------- /src/juliapkg/install_julia.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | import hashlib 3 | import io 4 | import json 5 | import os 6 | import platform 7 | import shutil 8 | import subprocess 9 | import tarfile 10 | import tempfile 11 | import time 12 | import urllib.request 13 | import warnings 14 | import zipfile 15 | 16 | from .compat import Version 17 | 18 | _all_julia_versions = None 19 | _julia_versions_url = "https://julialang-s3.julialang.org/bin/versions.json" 20 | 21 | 22 | def log(*args, cont=False): 23 | prefix = " " if cont else "[juliapkg]" 24 | print(prefix, *args) 25 | 26 | 27 | def log_script(script, title=None): 28 | if title is not None: 29 | log(title) 30 | for line in script: 31 | log("|", line, cont=True) 32 | 33 | 34 | def all_julia_versions(): 35 | global _all_julia_versions 36 | if _all_julia_versions is None: 37 | url = _julia_versions_url 38 | log(f"Querying Julia versions from {url}") 39 | with urllib.request.urlopen(url) as fp: 40 | _all_julia_versions = json.load(fp) 41 | return _all_julia_versions 42 | 43 | 44 | os_aliases = { 45 | "darwin": "mac", 46 | "windows": "winnt", 47 | } 48 | 49 | 50 | def get_os(): 51 | os = platform.system().lower() 52 | return os_aliases.get(os.lower(), os) 53 | 54 | 55 | arch_aliases = { 56 | "arm64": "aarch64", 57 | "i386": "i686", 58 | "amd64": "x86_64", 59 | } 60 | 61 | 62 | def get_arch(): 63 | arch = platform.machine().lower() 64 | return arch_aliases.get(arch.lower(), arch) 65 | 66 | 67 | short_arches = { 68 | "i686": "x86", 69 | "x86_64": "x64", 70 | } 71 | 72 | 73 | def get_short_arch(): 74 | arch = get_arch() 75 | return short_arches.get(arch, arch) 76 | 77 | 78 | libc_aliases = { 79 | "glibc": "gnu", 80 | } 81 | 82 | 83 | def get_libc(): 84 | libc = platform.libc_ver()[0].lower() 85 | return libc_aliases.get(libc, libc) 86 | 87 | 88 | def compatible_julia_versions(compat=None): 89 | os = get_os() 90 | arch = get_arch() 91 | libc = get_libc() 92 | if libc == "" and os == "linux": 93 | warnings.warn("could not determine libc version - assuming glibc") 94 | libc = "gnu" 95 | if libc == "gnu" and os == "linux" and arch == "armv7l": 96 | libc = "gnueabihf" 97 | ans = {} 98 | for k, v in all_julia_versions().items(): 99 | v = v.copy() 100 | if not v["stable"]: 101 | continue 102 | files = [] 103 | for f in v["files"]: 104 | assert f["version"] == k 105 | if not any(f["url"].endswith(ext) for ext in julia_installers): 106 | continue 107 | if f["os"] != os: 108 | continue 109 | if f["arch"] != arch: 110 | continue 111 | if os == "linux" and f["triplet"].split("-")[2] != libc: 112 | continue 113 | if compat is not None: 114 | try: 115 | ver = Version.parse(f["version"]) 116 | except Exception: 117 | continue 118 | if ver not in compat: 119 | continue 120 | files.append(f) 121 | if not files: 122 | continue 123 | v["files"] = files 124 | ans[k] = v 125 | triplets = {f["triplet"] for (k, v) in ans.items() for f in v["files"]} 126 | if len(triplets) > 1: 127 | raise Exception( 128 | f"multiple matching triplets {sorted(triplets)} - this is probably a bug," 129 | " please report" 130 | ) 131 | return ans 132 | 133 | 134 | def best_julia_version(compat=None): 135 | vers = compatible_julia_versions(compat) 136 | if not vers: 137 | raise Exception(f"no version of Julia is compatible with {compat}") 138 | v = sorted(vers.keys(), key=Version.parse, reverse=True)[0] 139 | return v, vers[v] 140 | 141 | 142 | def install_julia(ver, prefix): 143 | for f in ver["files"]: 144 | url = f["url"] 145 | # find a suitable installer 146 | installer = None 147 | for ext in julia_installers: 148 | if url.endswith(ext): 149 | installer = julia_installers[ext] 150 | break 151 | if installer is None: 152 | continue 153 | # download julia 154 | buf = download_julia(f) 155 | # include the version in the prefix 156 | v = f["version"] 157 | log(f"Installing Julia {v} to {prefix}") 158 | if os.path.exists(prefix): 159 | shutil.rmtree(prefix) 160 | if os.path.dirname(prefix): 161 | os.makedirs(os.path.dirname(prefix), exist_ok=True) 162 | installer(f, buf, prefix) 163 | return 164 | raise Exception("no installable Julia version found") 165 | 166 | 167 | def download_julia(f): 168 | url = f["url"] 169 | sha256 = f["sha256"] 170 | size = f["size"] 171 | log(f"Downloading Julia from {url}") 172 | buf = io.BytesIO() 173 | freq = 5 174 | t = time.time() + freq 175 | with urllib.request.urlopen(url) as f: 176 | while True: 177 | data = f.read(1 << 16) 178 | if not data: 179 | break 180 | buf.write(data) 181 | if time.time() > t: 182 | log( 183 | f" downloaded {buf.tell() / (1 << 20):.1f} MB of" 184 | f" {size / (1 << 20):.1f} MB", 185 | cont=True, 186 | ) 187 | t = time.time() + freq 188 | log(" download complete", cont=True) 189 | log("Verifying download") 190 | buf.seek(0) 191 | m = hashlib.sha256() 192 | m.update(buf.read()) 193 | sha256actual = m.hexdigest() 194 | if sha256actual != sha256: 195 | raise Exception( 196 | f"SHA-256 hash does not match, got {sha256actual}, expecting {sha256}" 197 | ) 198 | buf.seek(0) 199 | return buf 200 | 201 | 202 | def install_julia_zip(f, buf, prefix): 203 | with tempfile.TemporaryDirectory() as tmpdir: 204 | # extract all files 205 | with zipfile.ZipFile(buf) as zf: 206 | zf.extractall(tmpdir) 207 | # copy stuff out 208 | srcdirs = [d for d in os.listdir(tmpdir) if d.startswith("julia")] 209 | if len(srcdirs) != 1: 210 | raise Exception("expecting one julia* directory") 211 | shutil.copytree(os.path.join(tmpdir, srcdirs[0]), prefix, symlinks=True) 212 | 213 | 214 | def install_julia_tar_gz(f, buf, prefix): 215 | with tempfile.TemporaryDirectory() as tmpdir: 216 | # extract all files 217 | with gzip.GzipFile(fileobj=buf) as gf: 218 | with tarfile.TarFile(fileobj=gf) as tf: 219 | tf.extractall(tmpdir) 220 | # copy stuff out 221 | srcdirs = [d for d in os.listdir(tmpdir) if d.startswith("julia")] 222 | if len(srcdirs) != 1: 223 | raise Exception("expecting one julia* directory") 224 | shutil.copytree(os.path.join(tmpdir, srcdirs[0]), prefix, symlinks=True) 225 | 226 | 227 | def install_julia_dmg(f, buf, prefix): 228 | with tempfile.TemporaryDirectory() as tmpdir: 229 | # write the dmg file out 230 | dmg = os.path.join(tmpdir, "dmg") 231 | with open(dmg, "wb") as f: 232 | f.write(buf.read()) 233 | # mount it 234 | mount = os.path.join(tmpdir, "mount") 235 | subprocess.run( 236 | ["hdiutil", "mount", "-mount", "required", "-mountpoint", mount, dmg], 237 | check=True, 238 | stdout=subprocess.PIPE, 239 | stderr=subprocess.PIPE, 240 | ) 241 | try: 242 | # copy stuff out 243 | appdirs = [ 244 | d 245 | for d in os.listdir(mount) 246 | if d.startswith("Julia") and d.endswith(".app") 247 | ] 248 | if len(appdirs) != 1: 249 | raise Exception("expecting one Julia*.app directory") 250 | srcdir = os.path.join(mount, appdirs[0], "Contents", "Resources", "julia") 251 | shutil.copytree(srcdir, prefix, symlinks=True) 252 | finally: 253 | # unmount 254 | subprocess.run( 255 | ["umount", mount], 256 | check=True, 257 | stdout=subprocess.PIPE, 258 | stderr=subprocess.PIPE, 259 | ) 260 | 261 | 262 | julia_installers = { 263 | ".tar.gz": install_julia_tar_gz, 264 | ".zip": install_julia_zip, 265 | ".dmg": install_julia_dmg, 266 | } 267 | -------------------------------------------------------------------------------- /src/juliapkg/juliapkg.json: -------------------------------------------------------------------------------- 1 | { 2 | "julia": "1" 3 | } 4 | -------------------------------------------------------------------------------- /src/juliapkg/state.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | STATE = {} 5 | 6 | 7 | def get_config(name, default=None): 8 | # -X option 9 | key = "juliapkg-" + name.lower().replace("_", "-") 10 | value = sys._xoptions.get(key) 11 | if value is not None: 12 | return value, f"-X {key}" 13 | # environment variable 14 | key = "PYTHON_JULIAPKG_" + name.upper() 15 | value = os.getenv(key) 16 | if value is not None: 17 | return value, key 18 | # fallback 19 | return default, f"" 20 | 21 | 22 | def get_config_opts(name, opts, default=None): 23 | value, key = get_config(name) 24 | if value in opts: 25 | if isinstance(opts, dict): 26 | value = opts[value] 27 | return value, key 28 | elif value is None: 29 | return default, key 30 | else: 31 | opts_str = ", ".join(x for x in opts if isinstance(x, str)) 32 | raise ValueError(f"{key} must be one of: {opts_str}") 33 | 34 | 35 | def get_config_bool(name, default=False): 36 | return get_config_opts( 37 | name, {"yes": True, True: True, "no": False, False: False}, default 38 | ) 39 | 40 | 41 | def reset_state(): 42 | global STATE 43 | STATE = {} 44 | 45 | # Are we running a dev version? 46 | STATE["dev"] = os.path.exists( 47 | os.path.join(os.path.dirname(__file__), "..", "..", "pyproject.toml") 48 | ) 49 | 50 | # Overrides 51 | STATE["override_executable"], _ = get_config("exe") 52 | 53 | # Find the Julia depot 54 | depot_path = os.getenv("JULIA_DEPOT_PATH") 55 | if depot_path: 56 | sep = ";" if os.name == "nt" else ":" 57 | STATE["depot"] = os.path.abspath(depot_path.split(sep)[0]) 58 | else: 59 | STATE["depot"] = os.path.abspath( 60 | os.path.join(os.path.expanduser("~"), ".julia") 61 | ) 62 | 63 | # Determine where to put the julia environment 64 | project, project_key = get_config("project") 65 | if project: 66 | if not os.path.isabs(project): 67 | raise Exception(f"{project_key} must be an absolute path") 68 | STATE["project"] = project 69 | else: 70 | if sys.prefix != sys.base_prefix: 71 | # definitely in a virtual environment 72 | prefix = sys.prefix 73 | else: 74 | # maybe in a conda environment 75 | prefix = os.getenv("CONDA_PREFIX") 76 | if prefix is None: 77 | # system python installation 78 | STATE["project"] = os.path.join( 79 | STATE["depot"], "environments", "pyjuliapkg" 80 | ) 81 | else: 82 | # in a virtual or conda environment 83 | STATE["project"] = os.path.abspath(os.path.join(prefix, "julia_env")) 84 | 85 | # meta file 86 | STATE["prefix"] = os.path.join(STATE["project"], "pyjuliapkg") 87 | STATE["deps"] = os.path.join(STATE["prefix"], "juliapkg.json") 88 | STATE["meta"] = os.path.join(STATE["prefix"], "meta.json") 89 | STATE["install"] = os.path.join(STATE["prefix"], "install") 90 | 91 | # offline 92 | STATE["offline"], _ = get_config_bool("offline") 93 | 94 | # resolution 95 | STATE["resolved"] = False 96 | 97 | 98 | reset_state() 99 | -------------------------------------------------------------------------------- /test/juliapkg_test_editable_setuptools/juliapkg_test_editable_setuptools/__init__.py: -------------------------------------------------------------------------------- 1 | """Test package for editable installs with juliapkg.json.""" 2 | 3 | __version__ = "0.1.0" 4 | -------------------------------------------------------------------------------- /test/juliapkg_test_editable_setuptools/juliapkg_test_editable_setuptools/juliapkg.json: -------------------------------------------------------------------------------- 1 | { 2 | "julia": "1.6", 3 | "packages": { 4 | "Example": { 5 | "uuid": "7876af07-990d-54b4-ab0e-23690620f79a" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /test/juliapkg_test_editable_setuptools/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "juliapkg_test_editable_setuptools" 7 | version = "0.1.0" 8 | description = "Test package for editable installs with juliapkg.json" 9 | authors = [{name = "Test User", email = "test@example.com"}] 10 | requires-python = ">=3.7" 11 | 12 | [tool.setuptools] 13 | packages = ["juliapkg_test_editable_setuptools"] 14 | package-data = { "juliapkg_test_editable_setuptools" = ["juliapkg.json"] } 15 | -------------------------------------------------------------------------------- /test/test_all.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import subprocess 4 | import tempfile 5 | from multiprocessing import Pool 6 | 7 | import juliapkg 8 | 9 | 10 | def test_import(): 11 | import juliapkg 12 | 13 | juliapkg.status 14 | juliapkg.add 15 | juliapkg.rm 16 | juliapkg.executable 17 | juliapkg.project 18 | juliapkg.offline 19 | juliapkg.require_julia 20 | 21 | 22 | def test_resolve(): 23 | assert juliapkg.resolve() is True 24 | 25 | 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 | 50 | def test_status(): 51 | assert juliapkg.status() is None 52 | 53 | 54 | def test_executable(): 55 | exe = juliapkg.executable() 56 | assert isinstance(exe, str) 57 | assert os.path.isfile(exe) 58 | assert "julia" in exe.lower() 59 | 60 | 61 | def test_project(): 62 | proj = juliapkg.project() 63 | assert isinstance(proj, str) 64 | assert os.path.isdir(proj) 65 | assert os.path.isfile(os.path.join(proj, "Project.toml")) 66 | 67 | 68 | def test_offline(): 69 | offline = juliapkg.offline() 70 | assert isinstance(offline, bool) 71 | 72 | 73 | def test_add_rm(): 74 | with tempfile.TemporaryDirectory() as tdir: 75 | 76 | def deps(): 77 | fn = os.path.join(tdir, "juliapkg.json") 78 | if not os.path.exists(fn): 79 | return None 80 | with open(os.path.join(tdir, "juliapkg.json")) as fp: 81 | return json.load(fp) 82 | 83 | assert deps() is None 84 | 85 | juliapkg.add( 86 | "Example1", 87 | target=tdir, 88 | uuid="0001", 89 | ) 90 | 91 | assert deps() == {"packages": {"Example1": {"uuid": "0001"}}} 92 | 93 | juliapkg.add("Example2", target=tdir, uuid="0002") 94 | 95 | assert deps() == { 96 | "packages": {"Example1": {"uuid": "0001"}, "Example2": {"uuid": "0002"}} 97 | } 98 | 99 | juliapkg.require_julia("~1.5, 1.7", target=tdir) 100 | 101 | assert deps() == { 102 | "julia": "~1.5, ^1.7", 103 | "packages": {"Example1": {"uuid": "0001"}, "Example2": {"uuid": "0002"}}, 104 | } 105 | 106 | juliapkg.require_julia(None, target=tdir) 107 | 108 | assert deps() == { 109 | "packages": {"Example1": {"uuid": "0001"}, "Example2": {"uuid": "0002"}} 110 | } 111 | 112 | juliapkg.rm("Example1", target=tdir) 113 | 114 | assert deps() == {"packages": {"Example2": {"uuid": "0002"}}} 115 | 116 | 117 | def test_editable_setuptools(): 118 | # test that editable deps files are found for setuptools packages 119 | fn = os.path.join( 120 | os.path.dirname(__file__), 121 | "juliapkg_test_editable_setuptools", 122 | "juliapkg_test_editable_setuptools", 123 | "juliapkg.json", 124 | ) 125 | assert os.path.exists(fn) 126 | assert any(os.path.samefile(fn, x) for x in juliapkg.deps.deps_files()) 127 | -------------------------------------------------------------------------------- /test/test_compat.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from juliapkg.compat import Compat, Range, Version 4 | 5 | v = Version.parse 6 | 7 | 8 | class TestRange: 9 | @pytest.mark.parametrize( 10 | "input, expected_output", 11 | [ 12 | # caret 13 | ("^1.2.3", Range(v("1.2.3"), v("2.0.0"))), 14 | ("^1.2", Range(v("1.2.0"), v("2.0.0"))), 15 | ("^1", Range(v("1.0.0"), v("2.0.0"))), 16 | ("^0.2.3", Range(v("0.2.3"), v("0.3.0"))), 17 | ("^0.0.3", Range(v("0.0.3"), v("0.0.4"))), 18 | ("^0.0", Range(v("0.0.0"), v("0.1.0"))), 19 | ("^0", Range(v("0.0.0"), v("1.0.0"))), 20 | # implied caret 21 | ("1.2.3", Range(v("1.2.3"), v("2.0.0"))), 22 | ("1.2", Range(v("1.2.0"), v("2.0.0"))), 23 | ("1", Range(v("1.0.0"), v("2.0.0"))), 24 | ("0.2.3", Range(v("0.2.3"), v("0.3.0"))), 25 | ("0.0.3", Range(v("0.0.3"), v("0.0.4"))), 26 | ("0.0", Range(v("0.0.0"), v("0.1.0"))), 27 | ("0", Range(v("0.0.0"), v("1.0.0"))), 28 | # tilde 29 | ("~1.2.3", Range(v("1.2.3"), v("1.3.0"))), 30 | ("~1.2", Range(v("1.2.0"), v("1.3.0"))), 31 | ("~1", Range(v("1.0.0"), v("2.0.0"))), 32 | ("~0.2.3", Range(v("0.2.3"), v("0.3.0"))), 33 | ("~0.0.3", Range(v("0.0.3"), v("0.0.4"))), 34 | ("~0.0", Range(v("0.0.0"), v("0.1.0"))), 35 | ("~0", Range(v("0.0.0"), v("1.0.0"))), 36 | # equality 37 | ("=1.2.3", Range(v("1.2.3"), v("1.2.4"))), 38 | # hyphen 39 | ("1.2.3 - 4.5.6", Range(v("1.2.3"), v("4.5.7"))), 40 | ("0.2.3 - 4.5.6", Range(v("0.2.3"), v("4.5.7"))), 41 | ("1.2 - 4.5.6", Range(v("1.2.0"), v("4.5.7"))), 42 | ("1 - 4.5.6", Range(v("1.0.0"), v("4.5.7"))), 43 | ("0.2 - 4.5.6", Range(v("0.2.0"), v("4.5.7"))), 44 | ("0.2 - 0.5.6", Range(v("0.2.0"), v("0.5.7"))), 45 | ("1.2.3 - 4.5", Range(v("1.2.3"), v("4.6.0"))), 46 | ("1.2.3 - 4", Range(v("1.2.3"), v("5.0.0"))), 47 | ("1.2 - 4.5", Range(v("1.2.0"), v("4.6.0"))), 48 | ("1.2 - 4", Range(v("1.2.0"), v("5.0.0"))), 49 | ("1 - 4.5", Range(v("1.0.0"), v("4.6.0"))), 50 | ("1 - 4", Range(v("1.0.0"), v("5.0.0"))), 51 | ("0.2.3 - 4.5", Range(v("0.2.3"), v("4.6.0"))), 52 | ("0.2.3 - 4", Range(v("0.2.3"), v("5.0.0"))), 53 | ("0.2 - 4.5", Range(v("0.2.0"), v("4.6.0"))), 54 | ("0.2 - 4", Range(v("0.2.0"), v("5.0.0"))), 55 | ("0.2 - 0.5", Range(v("0.2.0"), v("0.6.0"))), 56 | ("0.2 - 0", Range(v("0.2.0"), v("1.0.0"))), 57 | ], 58 | ) 59 | def test_parse(self, input, expected_output): 60 | output = Range.parse(input) 61 | assert output == expected_output 62 | 63 | @pytest.mark.parametrize( 64 | "range, expected_output", 65 | [ 66 | (Range(v("0.0.3"), v("0.0.4")), "=0.0.3"), 67 | (Range(v("1.2.3"), v("1.2.4")), "=1.2.3"), 68 | (Range(v("1.2.3"), v("2.0.0")), "^1.2.3"), 69 | (Range(v("1.2.0"), v("2.0.0")), "^1.2"), 70 | (Range(v("1.0.0"), v("2.0.0")), "^1"), 71 | (Range(v("0.2.3"), v("0.3.0")), "^0.2.3"), 72 | (Range(v("0.0.0"), v("0.1.0")), "^0.0"), 73 | (Range(v("0.0.0"), v("1.0.0")), "^0"), 74 | (Range(v("1.2.3"), v("1.3.0")), "~1.2.3"), 75 | (Range(v("1.2.0"), v("1.3.0")), "~1.2"), 76 | (Range(v("1.2.3"), v("4.5.7")), "1.2.3 - 4.5.6"), 77 | (Range(v("0.2.3"), v("4.5.7")), "0.2.3 - 4.5.6"), 78 | (Range(v("1.2.0"), v("4.5.7")), "1.2.0 - 4.5.6"), 79 | (Range(v("1.0.0"), v("4.5.7")), "1.0.0 - 4.5.6"), 80 | (Range(v("0.2.0"), v("4.5.7")), "0.2.0 - 4.5.6"), 81 | (Range(v("0.2.0"), v("0.5.7")), "0.2.0 - 0.5.6"), 82 | (Range(v("1.2.3"), v("4.6.0")), "1.2.3 - 4.5"), 83 | (Range(v("1.2.3"), v("5.0.0")), "1.2.3 - 4"), 84 | (Range(v("1.2.0"), v("4.6.0")), "1.2.0 - 4.5"), 85 | (Range(v("1.2.0"), v("5.0.0")), "1.2.0 - 4"), 86 | (Range(v("1.0.0"), v("4.6.0")), "1.0.0 - 4.5"), 87 | (Range(v("1.0.0"), v("5.0.0")), "1.0.0 - 4"), 88 | (Range(v("0.2.3"), v("4.6.0")), "0.2.3 - 4.5"), 89 | (Range(v("0.2.3"), v("5.0.0")), "0.2.3 - 4"), 90 | (Range(v("0.2.0"), v("4.6.0")), "0.2.0 - 4.5"), 91 | (Range(v("0.2.0"), v("5.0.0")), "0.2.0 - 4"), 92 | (Range(v("0.2.0"), v("0.6.0")), "0.2.0 - 0.5"), 93 | (Range(v("0.2.0"), v("1.0.0")), "0.2.0 - 0"), 94 | ], 95 | ) 96 | def test_str(self, range, expected_output): 97 | output = str(range) 98 | assert output == expected_output 99 | 100 | @pytest.mark.parametrize( 101 | "version, range, expected_output", 102 | [ 103 | (version, range, output) 104 | for range, pairs in [ 105 | ( 106 | Range(v("1.0.0"), v("2.0.0")), 107 | [ 108 | (v("0.0.0"), False), 109 | (v("0.1.0"), False), 110 | (v("0.1.2"), False), 111 | (v("1.0.0"), True), 112 | (v("1.2.0"), True), 113 | (v("1.2.3"), True), 114 | (v("2.0.0"), False), 115 | (v("2.3.0"), False), 116 | (v("2.3.4"), False), 117 | ], 118 | ), 119 | ] 120 | for version, output in pairs 121 | ], 122 | ) 123 | def test_contains(self, version, range, expected_output): 124 | output = version in range 125 | assert output == expected_output 126 | 127 | @pytest.mark.parametrize( 128 | "range, expected_output", 129 | [ 130 | (Range(v("0.0.0"), v("0.0.0")), True), 131 | (Range(v("0.0.0"), v("0.0.1")), False), 132 | (Range(v("0.0.0"), v("0.1.0")), False), 133 | (Range(v("0.0.0"), v("1.0.0")), False), 134 | (Range(v("0.0.1"), v("1.0.0")), False), 135 | (Range(v("0.1.0"), v("1.0.0")), False), 136 | (Range(v("1.0.0"), v("1.0.0")), True), 137 | (Range(v("1.2.0"), v("1.0.0")), True), 138 | (Range(v("1.2.3"), v("1.0.0")), True), 139 | (Range(v("2.0.0"), v("1.0.0")), True), 140 | ], 141 | ) 142 | def test_is_empty(self, range, expected_output): 143 | output = range.is_empty() 144 | assert output == expected_output 145 | 146 | @pytest.mark.parametrize( 147 | "range1, range2, expected_output", 148 | [ 149 | ( 150 | Range(v("0.0.0"), v("0.0.0")), 151 | Range(v("2.0.0"), v("1.0.0")), 152 | Range(v("2.0.0"), v("0.0.0")), 153 | ), 154 | ], 155 | ) 156 | def test_and(self, range1, range2, expected_output): 157 | output = range1 & range2 158 | assert output == expected_output 159 | 160 | 161 | class TestCompat: 162 | @pytest.mark.parametrize( 163 | "input, expected_output", 164 | [ 165 | ("", Compat([])), 166 | ("1.2.3", Compat([Range(v("1.2.3"), v("2.0.0"))])), 167 | ( 168 | "1, 2.3, 4.5.6", 169 | Compat( 170 | [ 171 | Range(v("1.0.0"), v("2.0.0")), 172 | Range(v("2.3.0"), v("3.0.0")), 173 | Range(v("4.5.6"), v("5.0.0")), 174 | ] 175 | ), 176 | ), 177 | ], 178 | ) 179 | def test_parse(self, input, expected_output): 180 | output = Compat.parse(input) 181 | assert output == expected_output 182 | 183 | @pytest.mark.parametrize( 184 | "compat, expected_output", 185 | [ 186 | (Compat([]), ""), 187 | (Compat([Range(v("1.2.3"), v("2.0.0"))]), "^1.2.3"), 188 | ( 189 | Compat( 190 | [ 191 | Range(v("1.0.0"), v("2.0.0")), 192 | Range(v("2.3.0"), v("3.0.0")), 193 | Range(v("4.5.6"), v("5.0.0")), 194 | ] 195 | ), 196 | "^1, ^2.3, ^4.5.6", 197 | ), 198 | ], 199 | ) 200 | def test_str(self, compat, expected_output): 201 | output = str(compat) 202 | assert output == expected_output 203 | 204 | @pytest.mark.parametrize( 205 | "version, compat, expected_output", 206 | [ 207 | (version, compat, expected_output) 208 | for (compat, pairs) in [ 209 | ( 210 | Compat( 211 | [ 212 | Range(v("1.0.0"), v("2.0.0")), 213 | Range(v("2.3.0"), v("3.0.0")), 214 | Range(v("4.5.6"), v("5.0.0")), 215 | ] 216 | ), 217 | [ 218 | (v("0.0.0"), False), 219 | (v("0.0.1"), False), 220 | (v("0.1.0"), False), 221 | (v("1.0.0"), True), 222 | (v("1.2.0"), True), 223 | (v("1.2.3"), True), 224 | (v("2.0.0"), False), 225 | (v("2.1.0"), False), 226 | (v("2.3.0"), True), 227 | (v("2.3.4"), True), 228 | (v("2.4.5"), True), 229 | (v("3.0.0"), False), 230 | ], 231 | ) 232 | ] 233 | for (version, expected_output) in pairs 234 | ], 235 | ) 236 | def test_contains(self, version, compat, expected_output): 237 | output = version in compat 238 | assert output == expected_output 239 | 240 | @pytest.mark.parametrize( 241 | "compat1, compat2, expected_output", 242 | [ 243 | ( 244 | Compat( 245 | [ 246 | Range(v("1.0.0"), v("2.0.0")), 247 | Range(v("2.3.0"), v("3.0.0")), 248 | Range(v("4.5.6"), v("5.0.0")), 249 | ] 250 | ), 251 | Compat( 252 | [ 253 | Range(v("0.1.0"), v("2.5.0")), 254 | Range(v("3.0.0"), v("5.0.0")), 255 | Range(v("6.0.0"), v("7.0.0")), 256 | ] 257 | ), 258 | Compat( 259 | [ 260 | Range(v("1.0.0"), v("2.0.0")), 261 | Range(v("2.3.0"), v("2.5.0")), 262 | Range(v("4.5.6"), v("5.0.0")), 263 | ] 264 | ), 265 | ) 266 | ], 267 | ) 268 | def test_and(self, compat1, compat2, expected_output): 269 | output = compat1 & compat2 270 | assert output == expected_output 271 | -------------------------------------------------------------------------------- /test/test_internals.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import juliapkg 4 | from juliapkg.deps import PkgSpec 5 | 6 | 7 | def test_openssl_compat(): 8 | assert juliapkg.deps.openssl_compat((1, 2, 3)) == "1.2 - 1.2.3" 9 | assert juliapkg.deps.openssl_compat((2, 3, 4)) == "2.3 - 2.3.4" 10 | assert juliapkg.deps.openssl_compat((3, 0, 0)) == "3 - 3.0" 11 | assert juliapkg.deps.openssl_compat((3, 1, 0)) == "3 - 3.1" 12 | assert juliapkg.deps.openssl_compat((3, 1, 2)) == "3 - 3.1" 13 | assert isinstance(juliapkg.deps.openssl_compat(), str) 14 | 15 | 16 | def test_pkgspec_validation(): 17 | # Test valid construction 18 | spec = PkgSpec(name="Example", uuid="123e4567-e89b-12d3-a456-426614174000") 19 | assert spec.name == "Example" 20 | assert spec.uuid == "123e4567-e89b-12d3-a456-426614174000" 21 | assert spec.dev is False 22 | assert spec.version is None 23 | assert spec.path is None 24 | assert spec.subdir is None 25 | assert spec.url is None 26 | assert spec.rev is None 27 | 28 | # Test with all parameters 29 | spec = PkgSpec( 30 | name="Example", 31 | uuid="123e4567-e89b-12d3-a456-426614174000", 32 | dev=True, 33 | version="1.0.0", 34 | path="/path/to/pkg", 35 | subdir="subdir", 36 | url="https://example.com/pkg.git", 37 | rev="main", 38 | ) 39 | assert spec.dev is True 40 | assert spec.version == "1.0.0" 41 | assert spec.path == "/path/to/pkg" 42 | assert spec.subdir == "subdir" 43 | assert spec.url == "https://example.com/pkg.git" 44 | assert spec.rev == "main" 45 | 46 | # Test invalid name 47 | with pytest.raises(ValueError, match="name must be a non-empty string"): 48 | PkgSpec(name="", uuid="0000") 49 | with pytest.raises(ValueError, match="name must be a non-empty string"): 50 | PkgSpec(name=123, uuid="0000") 51 | 52 | # Test invalid UUID 53 | with pytest.raises(ValueError, match="uuid must be a non-empty string"): 54 | PkgSpec(name="Example", uuid="") 55 | with pytest.raises(ValueError, match="uuid must be a non-empty string"): 56 | PkgSpec(name="Example", uuid=123) 57 | 58 | # Test invalid dev flag 59 | with pytest.raises(TypeError, match="dev must be a boolean"): 60 | PkgSpec(name="Example", uuid="0000", dev="not-a-boolean") 61 | 62 | # Test invalid version type 63 | with pytest.raises(TypeError, match="version must be a string, Version, or None"): 64 | PkgSpec(name="Example", uuid="0000", version=123) 65 | 66 | # Test invalid path type 67 | with pytest.raises(TypeError, match="path must be a string or None"): 68 | PkgSpec(name="Example", uuid="0000", path=123) 69 | 70 | # Test invalid subdir type 71 | with pytest.raises(TypeError, match="subdir must be a string or None"): 72 | PkgSpec(name="Example", uuid="0000", subdir=123) 73 | 74 | # Test invalid url type 75 | with pytest.raises(TypeError, match="url must be a string or None"): 76 | PkgSpec(name="Example", uuid="0000", url=123) 77 | 78 | # Test invalid rev type 79 | with pytest.raises(TypeError, match="rev must be a string or None"): 80 | PkgSpec(name="Example", uuid="0000", rev=123) 81 | --------------------------------------------------------------------------------