Skip to content

Commit 1a219e1

Browse files
committed
ewoks install: support pixi, conda, poetry, uv and pipenv
1 parent accce43 commit 1a219e1

45 files changed

Lines changed: 1387 additions & 160 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/ewoks/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ def command_install(
159159
cli_install_utils.parse_install_arguments(cli_args, shell=shell)
160160
for workflow, graph in zip(cli_args.workflows, cli_args.graphs):
161161
try:
162-
install_graph(graph, cli_args.yes, cli_args.python)
162+
install_graph(graph, cli_args.yes, command=cli_args.manager_command)
163163
except CalledProcessError as e:
164164
print(f"Install failed for {workflow}: {e}")
165165
except AbortException:
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,78 @@
11
"""Workflow requirements."""
2+
3+
import logging
4+
from typing import Tuple
5+
6+
from ewokscore.graph import TaskGraph
7+
8+
from .managers.utils.base import BaseRequirements
9+
from .managers.utils.detect import get_manager
10+
from .metadata import parse
11+
from .metadata.gather import last_resort
12+
13+
logger = logging.getLogger(__file__)
14+
15+
16+
def add_requirements(graph: TaskGraph, command: Tuple[str, ...] = tuple()) -> None:
17+
"""Add requirements to a workflow definition in-place."""
18+
manager = get_manager(command=command)
19+
requirements = manager.gather_requirements()
20+
graph.graph.graph["requirements"] = requirements.model_dump()
21+
22+
23+
def get_requirements(graph: TaskGraph) -> BaseRequirements:
24+
"""Extract requirements from a workflow definition."""
25+
requirements = graph.graph.graph.get("requirements", None)
26+
no_requirements = not requirements
27+
28+
if no_requirements:
29+
logger.warning(
30+
"BaseRequirements field is empty. Trying to extract requirements automatically..."
31+
)
32+
requirements = last_resort.last_resort_requirements(graph)
33+
34+
requirements = parse.parse_requirements(requirements)
35+
36+
if no_requirements:
37+
logger.info(f"Extracted the following requirements: {requirements.__info__()}")
38+
39+
return requirements
40+
41+
42+
def install_requirements(
43+
requirements: BaseRequirements, command: Tuple[str, ...] = tuple()
44+
) -> None:
45+
"""Install workflow requirements."""
46+
manager = get_manager(manager_name=requirements.manager.name, command=command)
47+
manager.install_requirements(requirements)
48+
49+
50+
if __name__ == "__main__":
51+
logging.basicConfig(level=logging.DEBUG)
52+
import time
53+
from pprint import pprint
54+
55+
t0 = time.perf_counter()
56+
57+
try:
58+
manager = get_manager(manager_name=None)
59+
requirements = manager.gather_requirements()
60+
61+
print()
62+
print("Model:")
63+
pprint(requirements.model_dump())
64+
finally:
65+
print("Freeze time:", time.perf_counter() - t0)
66+
67+
pip_freeze = requirements.manager.freeze
68+
69+
dists_freeze = manager.freeze_distributions(requirements)
70+
dists_freeze = [s for s in dists_freeze if not s.startswith("#")]
71+
72+
print()
73+
print("pip freeze has these extra's:")
74+
pprint(set(pip_freeze) - set(dists_freeze))
75+
76+
print()
77+
print("native freeze has these extra's:")
78+
pprint(set(dists_freeze) - set(pip_freeze))

src/ewoks/_requirements/managers/__init__.py

Whitespace-only changes.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import logging
2+
3+
import yaml
4+
5+
from ..metadata.gather import gather_requirements
6+
from ..models.conda import CondaRequirements
7+
from .utils.base import BaseManager
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
class CondaManager(BaseManager):
13+
NAME = "conda"
14+
15+
def _gather_requirements(self, manager_version: str) -> CondaRequirements:
16+
output = self._check_output("env", "export")
17+
environment = yaml.safe_load(output)
18+
environment.pop("name", None)
19+
environment.pop("prefix", None)
20+
21+
return gather_requirements(
22+
manager_name="conda",
23+
manager_version=manager_version,
24+
environment=environment,
25+
)
26+
27+
def install_requirements(self, requirements: CondaRequirements) -> None:
28+
text = yaml.safe_dump(requirements.environment)
29+
with self._temporary_file(text, ".yml") as tmp_path:
30+
self._check_call("env", "update", "-f", tmp_path)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import logging
2+
from typing import List
3+
4+
from ..metadata import pip_freeze
5+
from ..metadata.gather import gather_requirements
6+
from ..models.pip import PipRequirements
7+
from .utils.base import BaseManager
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
class PipManager(BaseManager):
13+
NAME = "pip"
14+
15+
def _gather_requirements(self, manager_version: str) -> PipRequirements:
16+
freeze_output = self._check_output("freeze").strip().splitlines()
17+
return gather_requirements(
18+
manager_name=self.NAME,
19+
manager_version=manager_version,
20+
freeze=freeze_output,
21+
)
22+
23+
def _install_requirements(self, requirements: PipRequirements) -> None:
24+
freeze = requirements.manager.freeze
25+
26+
if freeze:
27+
arguments = self._arguments(freeze)
28+
try:
29+
self._check_call("install", "--no-cache-dir", *arguments)
30+
return
31+
except Exception:
32+
if not requirements.distributions:
33+
raise
34+
35+
freeze = self.freeze_distributions(requirements)
36+
if freeze:
37+
arguments = self._arguments(freeze)
38+
self._check_call("install", "--no-cache-dir", *arguments)
39+
return
40+
41+
raise ValueError("No distibutions provided to install")
42+
43+
def freeze_distributions(self, requirements: PipRequirements) -> List[str]:
44+
freeze = []
45+
for dist in requirements.distributions:
46+
lines, warnings = pip_freeze.freeze_distribution(dist)
47+
for warning in warnings:
48+
logger.warning(warning)
49+
freeze.extend(lines)
50+
return freeze
51+
52+
def _arguments(self, freeze: List[str]) -> List[str]:
53+
arguments, warnings = pip_freeze.sanitize_freeze(freeze)
54+
for warning in warnings:
55+
logger.warning(warning)
56+
return arguments
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import json
2+
3+
from ..metadata.gather import gather_requirements
4+
from ..models.pipenv import PipenvRequirements
5+
from .utils.base import BaseManager
6+
7+
8+
class PipenvManager(BaseManager):
9+
NAME = "pipenv"
10+
11+
def _gather_requirements(self, manager_version: str) -> PipenvRequirements:
12+
output = self._check_output("lock", "--requirements")
13+
requirements = output.strip().splitlines()
14+
15+
return gather_requirements(
16+
manager_name=self.NAME,
17+
manager_version=manager_version,
18+
requirements=requirements,
19+
)
20+
21+
def _install_requirements(self, requirements: PipenvRequirements) -> None:
22+
lock_data = {
23+
"_meta": {"hash": {"sha256": "dummy"}}, # minimal metadata
24+
"default": {
25+
pkg.split("==")[0]: {"version": pkg.split("==")[1]}
26+
for pkg in requirements.requirements
27+
},
28+
"develop": {
29+
pkg.split("==")[0]: {"version": pkg.split("==")[1]}
30+
for pkg in getattr(requirements, "dev_requirements", [])
31+
},
32+
}
33+
text = json.dumps(lock_data, indent=2)
34+
35+
with self._temporary_file(text, ".lock") as tmp_path:
36+
self._check_call("sync", "--ignore-pipfile", "-f", tmp_path)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import os
2+
3+
from ..metadata.gather import gather_requirements
4+
from ..models.pixi import PixiRequirements
5+
from .utils.base import BaseManager
6+
7+
8+
class PixiManager(BaseManager):
9+
NAME = "pixi"
10+
11+
def _gather_requirements(self, manager_version: str) -> PixiRequirements:
12+
if os.path.exists("pixi.lock"):
13+
with open("pixi.lock", "r", encoding="utf-8") as f:
14+
lock_content = f.read()
15+
elif os.path.exists("pixi.toml"):
16+
with open("pixi.toml", "r", encoding="utf-8") as f:
17+
lock_content = f.read()
18+
else:
19+
raise RuntimeError("No pixi.lock or pixi.toml file found")
20+
21+
return gather_requirements(
22+
manager_name=self.NAME,
23+
manager_version=manager_version,
24+
lockfile=lock_content,
25+
)
26+
27+
def _install_requirements(self, requirements: PixiRequirements) -> None:
28+
with self._temporary_file(requirements.lockfile, ".lock") as tmp_path:
29+
self._check_call("install", cwd=os.path.dirname(tmp_path))
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from ..metadata.gather import gather_requirements
2+
from ..models.poetry import PoetryRequirements
3+
from .utils.base import BaseManager
4+
5+
6+
class PoetryManager(BaseManager):
7+
NAME = "poetry"
8+
9+
def _gather_requirements(self, manager_version: str) -> PoetryRequirements:
10+
output = self._check_output("export", "--without-hashes")
11+
requirements = output.strip().splitlines()
12+
13+
return gather_requirements(
14+
manager_name=self.NAME,
15+
manager_version=manager_version,
16+
requirements=requirements,
17+
)
18+
19+
def _install_requirements(self, requirements: PoetryRequirements) -> None:
20+
text = "\n".join(requirements.requirements)
21+
with self._temporary_file(text, ".txt") as tmp_path:
22+
self._check_call("add", "--lock", "--file", tmp_path)

src/ewoks/_requirements/managers/utils/__init__.py

Whitespace-only changes.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import logging
2+
import os
3+
import subprocess
4+
import tempfile
5+
from abc import abstractmethod
6+
from contextlib import contextmanager
7+
from typing import Generator
8+
from typing import List
9+
from typing import Optional
10+
11+
from ...models.base import BaseRequirements
12+
from .commands import get_manager_command
13+
14+
logger = logging.getLogger(__name__)
15+
16+
17+
class BaseManager:
18+
"""Defines the interface all package managers must implement.
19+
20+
If `MyManager` is an impementation of this interface then
21+
to get the Ewoks workflow requirements like this:
22+
23+
.. code-block:: python
24+
25+
manager = MyManager()
26+
requirements = manager.gather_requirements()
27+
28+
Ewoks workflow requirements can be installed like this:
29+
30+
.. code-block:: python
31+
32+
manager = MyManager()
33+
install_requirements.install_requirements(requirements)
34+
"""
35+
36+
NAME = NotImplemented
37+
38+
def __init__(self, *command: str) -> None:
39+
if not command:
40+
command = get_manager_command(self.NAME)
41+
self._cmd_args = command
42+
43+
def gather_requirements(self) -> Optional[BaseRequirements]:
44+
"""Return requirements generated from the current python environment."""
45+
from .supported import get_supported_managers
46+
47+
manager_version = get_supported_managers()[self.NAME].version
48+
if not manager_version:
49+
raise RuntimeError(f"{self.NAME!r} is not installed")
50+
51+
try:
52+
return self._gather_requirements(manager_version)
53+
except Exception as ex:
54+
logger.error(
55+
"%s: failed to generate requirements (%s)", type(self).__name__, ex
56+
)
57+
return None
58+
59+
def install_requirements(self, requirements: BaseRequirements) -> None:
60+
"""Install requirements into the current python environment."""
61+
try:
62+
return self._install_requirements(requirements)
63+
except Exception as ex:
64+
logger.error(
65+
"%s: failed to install requirements (%s)", type(self).__name__, ex
66+
)
67+
raise
68+
69+
@abstractmethod
70+
def _gather_requirements(self, manager_version: str) -> BaseRequirements:
71+
pass
72+
73+
@abstractmethod
74+
def _install_requirements(self, requirements: BaseRequirements) -> None:
75+
pass
76+
77+
def _check_output(self, *args) -> str:
78+
return _check_output([*self._cmd_args, *args])
79+
80+
def _check_call(self, *args, raw: bool = False) -> int:
81+
if raw:
82+
return _check_call([*args])
83+
return _check_call([*self._cmd_args, *args])
84+
85+
@contextmanager
86+
def _temporary_file(self, text: str, suffix: str) -> Generator[str, None, None]:
87+
tmp_path = None
88+
try:
89+
with tempfile.NamedTemporaryFile("w", suffix=suffix, delete=False) as tmp:
90+
tmp.write(text)
91+
tmp_path = tmp.name
92+
93+
yield tmp_path
94+
95+
finally:
96+
if tmp_path:
97+
try:
98+
os.remove(tmp_path)
99+
except OSError:
100+
logger.debug("Could not delete temporary file: %s", tmp_path)
101+
102+
103+
def _check_output(args: List[str]) -> str:
104+
try:
105+
return subprocess.check_output(args, text=True)
106+
except Exception as ex:
107+
raise RuntimeError(f"Command failed: {args}") from ex
108+
109+
110+
def _check_call(args: List[str]) -> int:
111+
try:
112+
return subprocess.check_call(args)
113+
except Exception as ex:
114+
raise RuntimeError(f"Command failed: {args}") from ex

0 commit comments

Comments
 (0)