Skip to content

Commit 7efd676

Browse files
authored
Merge pull request #801 from simvue-io/789-invalid-python-environment-metadata-keys
🐛 Handle Conda file based dependencies in metadata
2 parents c4f7b57 + a3e5640 commit 7efd676

File tree

7 files changed

+386
-161
lines changed

7 files changed

+386
-161
lines changed

CHANGELOG.md

Lines changed: 161 additions & 147 deletions
Large diffs are not rendered by default.

poetry.lock

Lines changed: 64 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "simvue"
3-
version = "2.1.2"
3+
version = "2.1.3"
44
description = "Simulation tracking and monitoring"
55
authors = [
66
{name = "Simvue Development Team", email = "info@simvue.io"}
@@ -55,6 +55,7 @@ dependencies = [
5555
"deepmerge (>=2.0,<3.0)",
5656
"geocoder (>=1.38.1,<2.0.0)",
5757
"pydantic-extra-types (>=2.10.5,<3.0.0)",
58+
"pyyaml (>=6.0.2,<7.0.0)",
5859
]
5960

6061
[project.urls]

simvue/metadata.py

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import typing
1111
import json
1212
import toml
13+
import yaml
1314
import logging
1415
import pathlib
1516

@@ -76,9 +77,71 @@ def git_info(repository: str) -> dict[str, typing.Any]:
7677
return {}
7778

7879

80+
def _conda_dependency_parse(dependency: str) -> tuple[str, str] | None:
81+
"""Parse a dependency definition into module-version."""
82+
if dependency.startswith("::"):
83+
logger.warning(
84+
f"Skipping Conda specific channel definition '{dependency}' in Python environment metadata."
85+
)
86+
return None
87+
elif ">=" in dependency:
88+
module, version = dependency.split(">=")
89+
logger.warning(
90+
f"Ignoring '>=' constraint in Python package version, naively storing '{module}=={version}', "
91+
"for a more accurate record use 'conda env export > environment.yml'"
92+
)
93+
elif "~=" in dependency:
94+
module, version = dependency.split("~=")
95+
logger.warning(
96+
f"Ignoring '~=' constraint in Python package version, naively storing '{module}=={version}', "
97+
"for a more accurate record use 'conda env export > environment.yml'"
98+
)
99+
elif dependency.startswith("-e"):
100+
_, version = dependency.split("-e")
101+
version = version.strip()
102+
module = pathlib.Path(version).name
103+
elif dependency.startswith("file://"):
104+
_, version = dependency.split("file://")
105+
module = pathlib.Path(version).stem
106+
elif dependency.startswith("git+"):
107+
_, version = dependency.split("git+")
108+
if "#egg=" in version:
109+
repo, module = version.split("#egg=")
110+
module = repo.split("/")[-1].replace(".git", "")
111+
else:
112+
module = version.split("/")[-1].replace(".git", "")
113+
elif "==" not in dependency:
114+
logger.warning(
115+
f"Ignoring '{dependency}' in Python environment record as no version constraint specified."
116+
)
117+
return None
118+
else:
119+
module, version = dependency.split("==")
120+
121+
return module, version
122+
123+
124+
def _conda_env(environment_file: pathlib.Path) -> dict[str, str]:
125+
"""Parse/interpret a Conda environment file."""
126+
content = yaml.load(environment_file.open(), Loader=yaml.SafeLoader)
127+
python_environment: dict[str, str] = {}
128+
pip_dependencies: list[str] = []
129+
for dependency in content.get("dependencies", []):
130+
if isinstance(dependency, dict) and dependency.get("pip"):
131+
pip_dependencies = dependency["pip"]
132+
break
133+
134+
for dependency in pip_dependencies:
135+
if not (parsed := _conda_dependency_parse(dependency)):
136+
continue
137+
module, version = parsed
138+
python_environment[module.strip().replace("-", "_")] = version.strip()
139+
return python_environment
140+
141+
79142
def _python_env(repository: pathlib.Path) -> dict[str, typing.Any]:
80143
"""Retrieve a dictionary of Python dependencies if lock file is available"""
81-
python_meta: dict[str, str] = {}
144+
python_meta: dict[str, dict] = {}
82145

83146
if (pyproject_file := pathlib.Path(repository).joinpath("pyproject.toml")).exists():
84147
content = toml.load(pyproject_file)
@@ -103,22 +166,37 @@ def _python_env(repository: pathlib.Path) -> dict[str, typing.Any]:
103166
python_meta["environment"] = {
104167
package["name"]: package["version"] for package in content
105168
}
169+
# Handle Conda case, albeit naively given the user may or may not have used 'conda env'
170+
# to dump their exact dependency versions
171+
elif (
172+
environment_file := pathlib.Path(repository).joinpath("environment.yml")
173+
).exists():
174+
python_meta["environment"] = _conda_env(environment_file)
106175
else:
107176
with contextlib.suppress((KeyError, ImportError)):
108177
from pip._internal.operations.freeze import freeze
109178

110-
python_meta["environment"] = {
111-
entry[0]: entry[-1]
112-
for line in freeze(local_only=True)
113-
if (entry := line.split("=="))
114-
}
179+
# Conda supports having file names with @ as entries
180+
# in the requirements.txt file as opposed to ==
181+
python_meta["environment"] = {}
182+
183+
for line in freeze(local_only=True):
184+
if line.startswith("-e"):
185+
python_meta["environment"]["local_install"] = line.split(" ")[-1]
186+
continue
187+
if "@" in line:
188+
entry = line.split("@")
189+
python_meta["environment"][entry[0].strip()] = entry[-1].strip()
190+
elif "==" in line:
191+
entry = line.split("==")
192+
python_meta["environment"][entry[0].strip()] = entry[-1].strip()
115193

116194
return python_meta
117195

118196

119197
def _rust_env(repository: pathlib.Path) -> dict[str, typing.Any]:
120198
"""Retrieve a dictionary of Rust dependencies if lock file available"""
121-
rust_meta: dict[str, str] = {}
199+
rust_meta: dict[str, dict] = {}
122200

123201
if (cargo_file := pathlib.Path(repository).joinpath("Cargo.toml")).exists():
124202
content = toml.load(cargo_file).get("package", {})
@@ -134,15 +212,15 @@ def _rust_env(repository: pathlib.Path) -> dict[str, typing.Any]:
134212
cargo_dat = toml.load(cargo_lock)
135213
rust_meta["environment"] = {
136214
dependency["name"]: dependency["version"]
137-
for dependency in cargo_dat.get("package")
215+
for dependency in cargo_dat.get("package", [])
138216
}
139217

140218
return rust_meta
141219

142220

143221
def _julia_env(repository: pathlib.Path) -> dict[str, typing.Any]:
144222
"""Retrieve a dictionary of Julia dependencies if a project file is available"""
145-
julia_meta: dict[str, str] = {}
223+
julia_meta: dict[str, dict] = {}
146224
if (project_file := pathlib.Path(repository).joinpath("Project.toml")).exists():
147225
content = toml.load(project_file)
148226
julia_meta["project"] = {
@@ -155,7 +233,7 @@ def _julia_env(repository: pathlib.Path) -> dict[str, typing.Any]:
155233

156234

157235
def _node_js_env(repository: pathlib.Path) -> dict[str, typing.Any]:
158-
js_meta: dict[str, str] = {}
236+
js_meta: dict[str, dict] = {}
159237
if (
160238
project_file := pathlib.Path(repository).joinpath("package-lock.json")
161239
).exists():
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: advanced_env
2+
channels:
3+
- conda-forge
4+
- anaconda
5+
- defaults
6+
dependencies:
7+
# Basic Conda packages with different version specifiers
8+
- python=3.10.12
9+
- numpy>=1.23.5
10+
- pandas
11+
- scikit-learn<1.2
12+
- openjdk>=11,<12
13+
14+
# Platform-specific dependencies
15+
- libsass # Standard dependency
16+
- vsix-installer # Standard dependency
17+
- openblas # A package that may have platform-specific builds
18+
19+
# Using a sub-channel (also called a label)
20+
- ::my-package-from-subchannel
21+
22+
# A 'pip' section for installing packages from PyPI and other sources
23+
- pip
24+
- pip:
25+
# Public PyPI packages with different version specifiers
26+
- requests==2.31.0
27+
- black
28+
- jupyterlab~=4.0.0
29+
- numpy==2.32.2
30+
31+
# A local package from a path
32+
- -e ./path/to/my-local-package
33+
- file:///path/to/my-local-wheel.whl
34+
35+
# A package from a Git repository
36+
- git+https://github.com/myuser/myrepo.git#egg=myproject
37+
38+
variables:
39+
# Define environment variables
40+
MY_ENV_VAR: "some_value"

tests/functional/test_run_class.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import logging
3+
import toml
34
import os
45
import pytest
56
import requests
@@ -1315,3 +1316,28 @@ def test_reconnect_with_process() -> None:
13151316
remove_runs=True,
13161317
recursive=True
13171318
)
1319+
1320+
@pytest.mark.parametrize(
1321+
"environment", ("python_conda", "python_poetry", "python_uv", "julia", "rust", "nodejs")
1322+
)
1323+
def test_run_environment_metadata(environment: str, mocker: pytest_mock.MockerFixture) -> None:
1324+
"""Tests that the environment information is compatible with the server."""
1325+
from simvue.config.user import SimvueConfiguration
1326+
from simvue.metadata import environment as env_func
1327+
_data_dir = pathlib.Path(__file__).parents[1].joinpath("example_data")
1328+
_target_dir = _data_dir
1329+
if "python" in environment:
1330+
_target_dir = _data_dir.joinpath(environment)
1331+
_config = SimvueConfiguration.fetch()
1332+
1333+
with sv_run.Run(server_token=_config.server.token, server_url=_config.server.url) as run:
1334+
_uuid = f"{uuid.uuid4()}".split("-")[0]
1335+
run.init(
1336+
name=f"test_run_environment_metadata_{environment}",
1337+
folder=f"/simvue_unit_testing/{_uuid}",
1338+
retention_period=os.environ.get("SIMVUE_TESTING_RETENTION_PERIOD", "2 mins"),
1339+
running=False,
1340+
visibility="tenant" if os.environ.get("CI") else None,
1341+
)
1342+
run.update_metadata(env_func(_target_dir))
1343+

tests/unit/test_metadata.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def test_cargo_env() -> None:
1414
@pytest.mark.metadata
1515
@pytest.mark.local
1616
@pytest.mark.parametrize(
17-
"backend", ("poetry", "uv", None)
17+
"backend", ("poetry", "uv", "conda", None)
1818
)
1919
def test_python_env(backend: str | None) -> None:
2020
if backend == "poetry":
@@ -23,6 +23,9 @@ def test_python_env(backend: str | None) -> None:
2323
elif backend == "uv":
2424
metadata = sv_meta._python_env(pathlib.Path(__file__).parents[1].joinpath("example_data", "python_uv"))
2525
assert metadata["project"]["name"] == "example-repo"
26+
elif backend == "conda":
27+
metadata = sv_meta._python_env(pathlib.Path(__file__).parents[1].joinpath("example_data", "python_conda"))
28+
assert metadata["environment"]["requests"]
2629
else:
2730
metadata = sv_meta._python_env(pathlib.Path(__file__).parents[1].joinpath("example_data"))
2831

@@ -51,4 +54,4 @@ def test_environment() -> None:
5154
assert metadata["python"]["project"]["name"] == "example-repo"
5255
assert metadata["rust"]["project"]["name"] == "example_project"
5356
assert metadata["julia"]["project"]["name"] == "Julia Demo Project"
54-
assert metadata["javascript"]["project"]["name"] == "my-awesome-project"
57+
assert metadata["javascript"]["project"]["name"] == "my-awesome-project"

0 commit comments

Comments
 (0)