diff --git a/conftest.py b/conftest.py index 47c7f509..d6a33ab5 100644 --- a/conftest.py +++ b/conftest.py @@ -1,8 +1,31 @@ -"""Configure tests""" - +""" +Root-level pytest configuration. + +Skips doctest collection for optional ELN backend modules whose +dependencies are not installed. ``unitpackage/test/eln/conftest.py`` +provides mock modules and markers for the unit tests in +``unitpackage/test/``. +""" +import importlib import os + import pandas as pd +# --------------------------------------------------------------------------- +# Skip doctest collection for optional ELN modules whose dependencies +# are not installed (they cannot be imported by --doctest-modules). +# --------------------------------------------------------------------------- + +collect_ignore_glob = [] + +if importlib.util.find_spec("elabapi_python") is None: + # elabftw.py imports elabapi_python at module level. + collect_ignore_glob.append("unitpackage/eln/elabftw.py") + +if importlib.util.find_spec("kadi_apy") is None: + # kadi.py imports kadi_apy at module level. + collect_ignore_glob.append("unitpackage/eln/kadi.py") + def pytest_configure(): """Configure pandas display options for doctests. diff --git a/doc/api.md b/doc/api.md index 0d85fd0d..8b395656 100644 --- a/doc/api.md +++ b/doc/api.md @@ -36,3 +36,12 @@ api/loaders/eclabloader.md api/loaders/gamryloader.md api/loaders/eclab_fields.md ``` + +```{toctree} +:caption: "ELN Integration:" +api/eln/elabftw.md +api/eln/elabftw_cli.md +api/eln/kadi.md +api/eln/kadi_cli.md +api/config.md +``` diff --git a/doc/api/config.md b/doc/api/config.md new file mode 100644 index 00000000..4b68b2cd --- /dev/null +++ b/doc/api/config.md @@ -0,0 +1,9 @@ +--- +github_url: https://github.com/echemdb/unitpackage/blob/master/unitpackage/config.py +--- + +# `unitpackage.config` +```{eval-rst} +.. automodule:: unitpackage.config + :members: +``` diff --git a/doc/api/eln/elabftw.md b/doc/api/eln/elabftw.md new file mode 100644 index 00000000..74cb8545 --- /dev/null +++ b/doc/api/eln/elabftw.md @@ -0,0 +1,9 @@ +--- +github_url: https://github.com/echemdb/unitpackage/blob/master/unitpackage/eln/elabftw.py +--- + +# `unitpackage.eln.elabftw` +```{eval-rst} +.. automodule:: unitpackage.eln.elabftw + :members: +``` diff --git a/doc/api/eln/elabftw_cli.md b/doc/api/eln/elabftw_cli.md new file mode 100644 index 00000000..8880727a --- /dev/null +++ b/doc/api/eln/elabftw_cli.md @@ -0,0 +1,9 @@ +--- +github_url: https://github.com/echemdb/unitpackage/blob/master/unitpackage/eln/elabftw_cli.py +--- + +# `unitpackage.eln.elabftw_cli` +```{eval-rst} +.. automodule:: unitpackage.eln.elabftw_cli + :members: +``` diff --git a/doc/api/eln/kadi.md b/doc/api/eln/kadi.md new file mode 100644 index 00000000..dd7243d3 --- /dev/null +++ b/doc/api/eln/kadi.md @@ -0,0 +1,9 @@ +--- +github_url: https://github.com/echemdb/unitpackage/blob/master/unitpackage/eln/kadi.py +--- + +# `unitpackage.eln.kadi` +```{eval-rst} +.. automodule:: unitpackage.eln.kadi + :members: +``` diff --git a/doc/api/eln/kadi_cli.md b/doc/api/eln/kadi_cli.md new file mode 100644 index 00000000..1f6cba33 --- /dev/null +++ b/doc/api/eln/kadi_cli.md @@ -0,0 +1,9 @@ +--- +github_url: https://github.com/echemdb/unitpackage/blob/master/unitpackage/eln/kadi_cli.py +--- + +# `unitpackage.eln.kadi_cli` +```{eval-rst} +.. automodule:: unitpackage.eln.kadi_cli + :members: +``` diff --git a/doc/index.md b/doc/index.md index 4f7b51b1..81369114 100644 --- a/doc/index.md +++ b/doc/index.md @@ -159,5 +159,6 @@ usage/echemdb_usage.md usage/load_and_save.md usage/create_unitpackage.md usage/loaders.md +usage/eln.md api.md ``` diff --git a/doc/usage/elabftw.md b/doc/usage/elabftw.md new file mode 100644 index 00000000..dbf52607 --- /dev/null +++ b/doc/usage/elabftw.md @@ -0,0 +1,322 @@ +--- +jupytext: + formats: ipynb,md:myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.16.6 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# eLabFTW Integration + +[eLabFTW](https://www.elabftw.net/) is an open-source electronic lab notebook (ELN) that stores experiments and resources online. +The `unitpackage.eln.elabftw` module provides a client to exchange unitpackage entries with an eLabFTW instance: entries can be fetched from eLabFTW as Python objects and uploaded back after analysis. + +## Entity Mapping + +eLabFTW organises data as *items* (resources) and *experiments*. +At the moment the unitpackage integration works with **items** exclusively — eLabFTW *experiments* are not yet supported — and each eLabFTW item corresponds to one unitpackage `Entry`. +When an entry is uploaded, its tabular data is attached as a CSV file and the complete datapackage descriptor — including field units and metadata — is stored in the item's metadata field under the key `unitpackage`. +Tags on the item can be used to group and filter entries. +Only items that carry this `unitpackage` metadata key are recognised by the client; items created outside of unitpackage are ignored. + +## Requirements + +The `elabapi-python` package is required. Install it alongside unitpackage with: + +```sh +pip install "unitpackage[elabftw]" +``` + +You also need an eLabFTW **API key**, which you can generate in your eLabFTW user settings under *API Keys*. +See the [eLabFTW API documentation](https://doc.elabftw.net/docs/usage/api/) for instructions. + +## Configuration + +The client resolves credentials in the following priority order: + +1. Explicit arguments passed to the constructor +2. Environment variables (`ELABFTW_HOST`, `ELABFTW_API_KEY`) +3. Configuration file (`~/.config/unitpackage/config.toml` on Linux/macOS, `%APPDATA%\unitpackage\config.toml` on Windows) + +### Environment Variables + +```sh +export ELABFTW_HOST=https://eln.example.org +export ELABFTW_API_KEY=your-api-key +``` + +Then create a client with: + +```python +from unitpackage.eln.elabftw import ElabFTWClient + +client = ElabFTWClient.from_env() +``` + +### Configuration File + +The configuration file `~/.config/unitpackage/config.toml` stores named connection profiles: + +```toml +[elabftw] +default_profile = "production" + +[elabftw.production] +host = "https://eln.example.org" +api_key = "abc123" +verify_ssl = true + +[elabftw.staging] +host = "https://staging-eln.example.org" +api_key = "def456" +verify_ssl = false +``` + +Profiles are managed with the CLI: + +```sh +# Add a new profile (prompts for host and API key) +unitpackage elabftw config add production + +# List all profiles +unitpackage elabftw config list + +# Show details of a profile +unitpackage elabftw config show production + +# Set a different default +unitpackage elabftw config set-default staging + +# Remove a profile +unitpackage elabftw config remove staging +``` + +Create a client from the default profile: + +```python +from unitpackage.eln.elabftw import ElabFTWClient + +client = ElabFTWClient.from_config() + +# Or specify a profile by name +client = ElabFTWClient.from_config("staging") +``` + +## Python API + +### Creating a Client + +```python +from unitpackage.eln.elabftw import ElabFTWClient + +# From explicit credentials +client = ElabFTWClient( + host="https://eln.example.org", + api_key="your-api-key", +) + +# From environment variables +client = ElabFTWClient.from_env() + +# From config file (uses default_profile or the only profile) +client = ElabFTWClient.from_config() +``` + +### Server Information + +```python +info = client.info() +print(info["elabftw_version"]) +``` + +### Fetching a Single Entry + +`fetch_entry` retrieves an eLabFTW *item* by its numeric ID and returns a unitpackage `Entry`. +Only items whose metadata contains the `unitpackage` key (i.e. items previously uploaded via unitpackage) are supported. + +```python +entry = client.fetch_entry(42) + +# Access the tabular data as a pandas DataFrame +entry.df.head() + +# Access metadata +entry.metadata +``` + +After fetching, the entry behaves exactly like any other unitpackage entry: + +```python +# Rescale columns +rescaled = entry.rescale({"E": "mV"}) + +# Save to disk as a datapackage +entry.save(outdir="my_data/") +``` + +### Fetching Multiple Entries + +`fetch_entries` retrieves all items that carry the `unitpackage` metadata key. +The optional `tags` parameter filters by eLabFTW tags (AND logic: all specified tags must be present). + +```python +# Fetch all unitpackage-managed items +entries = client.fetch_entries() + +# Fetch only items tagged 'electrochemistry' +entries = client.fetch_entries(tags=["electrochemistry"]) + +# Fetch items with multiple tags +entries = client.fetch_entries(tags=["electrochemistry", "platinum"]) + +print(f"Found {len(entries)} entries") +``` + +### Uploading an Entry + +`upload_entry` creates a new eLabFTW item, uploads the CSV data as an attachment, and stores the complete datapackage descriptor in the item's metadata. +This enables a lossless round-trip: any entry uploaded this way can be fetched back with `fetch_entry`. + +```python +import pandas as pd +from unitpackage.entry import Entry + +# Create or load an entry +df = pd.DataFrame({"t": [0, 1, 2], "E": [0.1, 0.2, 0.3]}) +entry = Entry.from_df(df=df, basename="cv_on_pt") +entry = entry.update_fields([ + {"name": "t", "unit": "s"}, + {"name": "E", "unit": "V"}, +]) + +# Upload to eLabFTW +entity_id = client.upload_entry(entry, title="CV on Pt(111)", tags=["electrochemistry"]) +print(f"Uploaded as item {entity_id}") +``` + +The uploaded item can be fetched back at any time: + +```python +entry_restored = client.fetch_entry(entity_id) +entry_restored.df +``` + +## CLI + +The `unitpackage elabftw` command group provides CLI access to all client operations. +Credentials can be supplied via `--host`/`--api-key` options, environment variables, or the configuration file. + +### Server Information + +```sh +unitpackage elabftw --host https://eln.example.org --api-key KEY info +``` + +Or with a configured profile: + +```sh +unitpackage elabftw info +``` + +### Fetching a Single Entry + +Fetch item 42 and save the datapackage to the current directory: + +```sh +unitpackage elabftw fetch 42 +``` + +Save to a specific output directory: + +```sh +unitpackage elabftw fetch 42 --outdir my_data/ +``` + +### Exporting Multiple Entries + +Export all unitpackage-managed items tagged `electrochemistry`: + +```sh +unitpackage elabftw export --tag electrochemistry --outdir exported/ +``` + +Multiple tags can be combined (AND logic): + +```sh +unitpackage elabftw export --tag electrochemistry --tag platinum --outdir exported/ +``` + +### Uploading a Datapackage + +Upload a local datapackage JSON file: + +```sh +unitpackage elabftw upload cv_on_pt.json --title "CV on Pt(111)" --tag electrochemistry +``` + +### Managing Connection Profiles + +```sh +# Interactive setup (prompts for host and API key) +unitpackage elabftw config add mylab + +# List all configured profiles +unitpackage elabftw config list + +# Show a profile (API key is masked by default) +unitpackage elabftw config show mylab + +# Show the API key in plain text +unitpackage elabftw config show mylab --show-key + +# Change the default profile +unitpackage elabftw config set-default mylab + +# Remove a profile +unitpackage elabftw config remove mylab --yes +``` + +## Round-Trip Workflow + +A typical round-trip workflow looks like this: + +```{code-cell} ipython3 +:tags: [hide-input] + +# Demonstrate the in-memory part of a round-trip without a live ELN +import pandas as pd +from unitpackage.entry import Entry +from unitpackage.eln import build_datapackage_descriptor, apply_datapackage_descriptor +import json + +# 1. Create an entry +df = pd.DataFrame({"t": [0.0, 1.0, 2.0], "E": [0.1, 0.2, 0.15]}) +entry = Entry.from_df(df=df, basename="cv_demo") +entry = entry.update_fields([ + {"name": "t", "unit": "s"}, + {"name": "E", "unit": "V"}, +]) + +# 2. Build the descriptor (what would be stored in eLabFTW metadata) +descriptor = build_datapackage_descriptor(entry) +print("Descriptor keys:", list(descriptor.keys())) +print("Resource name:", descriptor["resources"][0]["name"]) +print("Fields:", [f["name"] for f in descriptor["resources"][0]["schema"]["fields"]]) +``` + +```{code-cell} ipython3 +:tags: [hide-input] + +# 3. Simulate what fetch_entry does: restore from stored descriptor +entry_restored = Entry.from_df(df=df, basename="cv_demo") +entry_restored = apply_datapackage_descriptor(entry_restored, json.dumps(descriptor)) + +# Field units are preserved +entry_restored.field_unit("t"), entry_restored.field_unit("E") +``` diff --git a/doc/usage/eln.md b/doc/usage/eln.md new file mode 100644 index 00000000..7dee636d --- /dev/null +++ b/doc/usage/eln.md @@ -0,0 +1,165 @@ +--- +jupytext: + formats: ipynb,md:myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.16.6 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# Electronic Lab Notebook (ELN) Integration + +unitpackage can exchange entries with **Electronic Lab Notebooks (ELNs)** and research data management platforms. +Each integration provides a client class with a consistent interface for fetching, uploading, and managing unitpackage entries stored in the respective system. + +Supported backends: + +- [eLabFTW](elabftw.md) — open-source ELN; entries are stored as *items* +- [Kadi4Mat](kadi.md) — open-source research data management platform; entries are stored as *records* + +## How It Works + +Each ELN backend maps a unitpackage `Entry` to a native entity (item or record) in the target system. +When an entry is uploaded: + +1. The tabular data is attached as a **CSV file**. +2. The complete datapackage descriptor — including field units and metadata — is stored as structured metadata under the key `unitpackage`. + +Only entities that carry this `unitpackage` metadata key are recognised by the client; entities created outside of unitpackage are ignored. +Tags on the entity can be used to group and filter entries. + +This design enables a **lossless round-trip**: any entry uploaded via unitpackage can be fetched back with all units and metadata intact. + +## Installation + +Each backend requires an optional dependency. +Install the one you need: + +```sh +# eLabFTW +pip install "unitpackage[elabftw]" + +# Kadi4Mat +pip install "unitpackage[kadi]" +``` + +## Configuration + +All backends share the same configuration file at +`~/.config/unitpackage/config.toml` (Linux/macOS) or +`%APPDATA%\unitpackage\config.toml` (Windows). + +Each backend has its own section, and each section can hold multiple named **connection profiles**. +The example below uses the `elabftw` section; replace it with `kadi` for the Kadi4Mat backend: + +```toml +[elabftw] +default_profile = "production" + +[elabftw.production] +host = "https://eln.example.org" +api_key = "abc123" # elabftw: api_key, kadi: pat +verify_ssl = true +``` + +Profiles are managed with the CLI: + +```sh +unitpackage config add production +unitpackage config list +``` + +Credentials can also be supplied via **environment variables** or passed directly to the client constructor. +The priority order is: constructor arguments > environment variables > configuration file. + +| Backend | Environment variables | Auth type | +|----------|------------------------------------|---------------------| +| eLabFTW | `ELABFTW_HOST`, `ELABFTW_API_KEY` | API key | +| Kadi4Mat | `KADI_HOST`, `KADI_PAT` | Personal access token | + +## Common Python API + +All clients share the same three core operations. +In the examples below, replace `` with `ElabFTWClient` (from `unitpackage.eln.elabftw`) +or `KadiClient` (from `unitpackage.eln.kadi`). + +### Creating a Client + +```python +client = .from_config() # from config file +client = .from_env() # from environment variables +``` + +### Fetching Entries + +```python +# Single entry by numeric ID +entry = client.fetch_entry(42) + +# All unitpackage-managed entries +entries = client.fetch_entries() + +# Filter by tags (AND logic) +entries = client.fetch_entries(tags=["electrochemistry", "platinum"]) +``` + +The returned `Entry` objects behave exactly like any other unitpackage entry: + +```python +entry.df.head() # tabular data as a DataFrame +entry.field_unit("E") # unit of a column +entry.rescale({"E": "mV"}) # rescale to different units +entry.save(outdir="my_data/") # save to disk +``` + +### Uploading an Entry + +```python +import pandas as pd +from unitpackage.entry import Entry + +df = pd.DataFrame({"t": [0, 1, 2], "E": [0.1, 0.2, 0.3]}) +entry = Entry.from_df(df=df, basename="cv_on_pt") +entry = entry.update_fields([ + {"name": "t", "unit": "s"}, + {"name": "E", "unit": "V"}, +]) + +entity_id = client.upload_entry(entry, title="CV on Pt(111)", tags=["electrochemistry"]) +``` + +## Common CLI + +Both backends expose the same subcommands under `unitpackage ` (`elabftw` or `kadi`): + +```sh +# Check server connectivity +unitpackage info + +# Fetch a single entry by ID and save to disk +unitpackage fetch 42 --outdir my_data/ + +# Export all tagged entries +unitpackage export --tag electrochemistry --outdir exported/ + +# Upload a local datapackage JSON +unitpackage upload cv_on_pt.json --title "CV on Pt(111)" --tag electrochemistry +``` + +## Backend-Specific Documentation + +For authentication details, advanced configuration options, and backend-specific behaviour, see: + +- [eLabFTW Integration](elabftw.md) +- [Kadi4Mat Integration](kadi.md) + +```{toctree} +:hidden: +elabftw.md +kadi.md +``` diff --git a/doc/usage/kadi.md b/doc/usage/kadi.md new file mode 100644 index 00000000..639498c5 --- /dev/null +++ b/doc/usage/kadi.md @@ -0,0 +1,308 @@ +--- +jupytext: + formats: ipynb,md:myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.16.6 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# Kadi4Mat Integration + +[Kadi4Mat](https://kadi4mat.iam.kit.edu/) is an open-source research data management platform that stores research data as *records*. +The `unitpackage.eln.kadi` module provides a client to exchange unitpackage entries with a Kadi4Mat instance: entries can be fetched from Kadi4Mat as Python objects and uploaded back after analysis. + +## Entity Mapping + +Kadi4Mat organises data as *records*, each of which can carry file attachments and structured metadata (*extras*). +Each Kadi4Mat record corresponds to one unitpackage `Entry`. +When an entry is uploaded, its tabular data is attached as a CSV file and the complete datapackage descriptor — including field units and metadata — is stored as a nested Kadi4Mat metadatum under the key `unitpackage`. +Tags on the record can be used to group and filter entries. +Only records that carry this `unitpackage` metadatum are recognised by the client; records created outside of unitpackage are ignored. + +## Prerequisites + +The `kadi-apy` package is required. Install it alongside unitpackage with: + +```sh +pip install "unitpackage[kadi]" +``` + +You also need a Kadi4Mat **Personal Access Token (PAT)**, which you can create in your Kadi4Mat user settings under *Settings > Access tokens*. +See the [Kadi4Mat documentation](https://kadi4mat.readthedocs.io/) for instructions. + +## Configuration + +The client resolves credentials in the following priority order: + +1. Explicit arguments passed to the constructor +2. Environment variables (`KADI_HOST`, `KADI_PAT`, optionally `KADI_BASE_PATH`) +3. Configuration file (`~/.config/unitpackage/config.toml`) + +### Environment Variables + +```sh +export KADI_HOST=https://kadi4mat.example.org +export KADI_PAT=your-personal-access-token +# Optional: set if the instance runs on a subpath (e.g. https://example.org/kadi) +export KADI_BASE_PATH=kadi +``` + +Then create a client with: + +```python +from unitpackage.eln.kadi import KadiClient + +client = KadiClient.from_env() +``` + +### Configuration File + +The configuration file `~/.config/unitpackage/config.toml` stores named connection profiles: + +```toml +[kadi] +default_profile = "production" + +[kadi.production] +host = "https://kadi4mat.example.org" +pat = "abc123" +verify_ssl = true + +[kadi.staging] +host = "https://kadi4mat-staging.example.org" +pat = "def456" +verify_ssl = false +base_path = "kadi" # optional, for instances served from a subpath +``` + +Profiles are managed with the CLI: + +```sh +# Add a new profile (prompts for host and PAT) +unitpackage kadi config add production + +# List all profiles +unitpackage kadi config list + +# Show details of a profile +unitpackage kadi config show production + +# Show the PAT in plain text +unitpackage kadi config show production --show-key + +# Set a different default +unitpackage kadi config set-default staging + +# Remove a profile +unitpackage kadi config remove staging --yes +``` + +Create a client from the default profile: + +```python +from unitpackage.eln.kadi import KadiClient + +client = KadiClient.from_config() + +# Or specify a profile by name +client = KadiClient.from_config("staging") +``` + +## Python API + +### Creating a Client + +```python +from unitpackage.eln.kadi import KadiClient + +# From explicit credentials +client = KadiClient( + host="https://kadi4mat.example.org", + pat="your-personal-access-token", + # base_path="kadi", # uncomment if the instance runs on a subpath +) + +# From environment variables +client = KadiClient.from_env() + +# From config file (uses default_profile or the only profile) +client = KadiClient.from_config() +``` + +### Server Information + +```python +info = client.info() +print(info["version"]) +``` + +### Fetching a Single Entry + +`fetch_entry` retrieves a Kadi4Mat *record* by its numeric ID and returns a unitpackage `Entry`. +Only records whose extras contain the `unitpackage` metadatum (i.e. records previously uploaded via unitpackage) are supported. + +```python +entry = client.fetch_entry(42) + +# Access the tabular data as a pandas DataFrame +entry.df.head() + +# Access metadata +entry.metadata +``` + +After fetching, the entry behaves exactly like any other unitpackage entry: + +```python +# Rescale columns +rescaled = entry.rescale({"E": "mV"}) + +# Save to disk as a datapackage +entry.save(outdir="my_data/") +``` + +### Fetching Multiple Entries + +`fetch_entries` retrieves all records that carry a `unitpackage` metadatum. +The optional `tags` parameter filters by Kadi4Mat tags (AND logic: all specified tags must be present). + +```python +# Fetch all unitpackage-managed records +entries = client.fetch_entries() + +# Fetch only records tagged 'electrochemistry' +entries = client.fetch_entries(tags=["electrochemistry"]) + +# Fetch records with multiple tags +entries = client.fetch_entries(tags=["electrochemistry", "platinum"]) + +print(f"Found {len(entries)} entries") +``` + +### Uploading an Entry + +`upload_entry` creates a new Kadi4Mat record, uploads the CSV data as a file attachment, and stores the complete datapackage descriptor as a nested metadatum. +This enables a lossless round-trip: any entry uploaded this way can be fetched back with `fetch_entry`. + +```python +import pandas as pd +from unitpackage.entry import Entry + +# Create or load an entry +df = pd.DataFrame({"t": [0, 1, 2], "E": [0.1, 0.2, 0.3]}) +entry = Entry.from_df(df=df, basename="cv_on_pt") +entry = entry.update_fields([ + {"name": "t", "unit": "s"}, + {"name": "E", "unit": "V"}, +]) + +# Upload to Kadi4Mat +record_id = client.upload_entry(entry, title="CV on Pt(111)", tags=["electrochemistry"]) +print(f"Uploaded as record {record_id}") +``` + +The uploaded record can be fetched back at any time: + +```python +entry_restored = client.fetch_entry(record_id) +entry_restored.df +``` + +## CLI + +The `unitpackage kadi` command group provides CLI access to all client operations. +Credentials can be supplied via `--host`/`--pat` options or environment variables. +If neither is provided, the first profile in the configuration file is used. + +### Server Information + +```sh +unitpackage kadi --host https://kadi4mat.example.org --pat TOKEN info +``` + +Or with environment variables set: + +```sh +unitpackage kadi info +``` + +### Fetching a Single Entry + +Fetch record 42 and save the datapackage to the current directory: + +```sh +unitpackage kadi fetch 42 +``` + +Save to a specific output directory: + +```sh +unitpackage kadi fetch 42 --outdir my_data/ +``` + +### Exporting Multiple Entries + +Export all unitpackage-managed records tagged `electrochemistry`: + +```sh +unitpackage kadi export --tag electrochemistry --outdir exported/ +``` + +Multiple tags can be combined (AND logic): + +```sh +unitpackage kadi export --tag electrochemistry --tag platinum --outdir exported/ +``` + +### Uploading a Datapackage + +Upload a local datapackage JSON file: + +```sh +unitpackage kadi upload cv_on_pt.json --title "CV on Pt(111)" --tag electrochemistry +``` + +## Round-Trip Workflow + +A typical round-trip workflow looks like this: + +```{code-cell} ipython3 +:tags: [hide-input] + +# Demonstrate the in-memory part of a round-trip without a live ELN +import pandas as pd +from unitpackage.entry import Entry +from unitpackage.eln import build_datapackage_descriptor, apply_datapackage_descriptor +import json + +# 1. Create an entry +df = pd.DataFrame({"t": [0.0, 1.0, 2.0], "E": [0.1, 0.2, 0.15]}) +entry = Entry.from_df(df=df, basename="cv_demo") +entry = entry.update_fields([ + {"name": "t", "unit": "s"}, + {"name": "E", "unit": "V"}, +]) + +# 2. Build the descriptor (what would be stored as a Kadi4Mat metadatum) +descriptor = build_datapackage_descriptor(entry) +print("Descriptor keys:", list(descriptor.keys())) +print("Resource name:", descriptor["resources"][0]["name"]) +print("Fields:", [f["name"] for f in descriptor["resources"][0]["schema"]["fields"]]) +``` + +```{code-cell} ipython3 +:tags: [hide-input] + +# 3. Simulate what fetch_entry does: restore from stored descriptor +entry_restored = Entry.from_df(df=df, basename="cv_demo") +entry_restored = apply_datapackage_descriptor(entry_restored, json.dumps(descriptor)) + +# Field units are preserved +entry_restored.field_unit("t"), entry_restored.field_unit("E") +``` diff --git a/pixi.lock b/pixi.lock index 15e1c156..06e316e4 100644 --- a/pixi.lock +++ b/pixi.lock @@ -183,7 +183,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -265,6 +265,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.5.2-pyh7db6752_0.conda @@ -330,7 +331,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.24.2-pyh7db6752_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl linux-aarch64: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/argon2-cffi-bindings-25.1.0-py314h51f160d_2.conda @@ -502,7 +502,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -584,6 +584,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.5.2-pyh7db6752_0.conda @@ -649,7 +650,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.24.2-pyh7db6752_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -683,7 +683,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -765,6 +765,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.5.2-pyh7db6752_0.conda @@ -965,7 +966,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/zlib-ng-2.3.3-h8bce59a_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -999,7 +999,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -1081,6 +1081,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.5.2-pyh7db6752_0.conda @@ -1281,7 +1282,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-ng-2.3.3-hed4e4f5_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -1314,7 +1314,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyh6dadd2b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyh6dadd2b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -1395,6 +1395,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.5.2-pyh7db6752_0.conda @@ -1595,7 +1596,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/zlib-ng-2.3.3-h0261ad2_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl dev: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -1801,7 +1801,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -1902,6 +1902,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -1984,7 +1985,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.24.2-pyh7db6752_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/15/a4/18b35000b00d9fb72003e0eacf66b3af828a0bb897c21a287a33d2025138/jh2-5.0.13-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/57/f6/58b48f41024d64afd33162f91f0c757a3d3212b47512a8a53dd3078b604e/qh3-1.9.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl linux-aarch64: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/argon2-cffi-bindings-25.1.0-py310h5b55623_2.conda @@ -2123,7 +2134,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/conda-index-0.11.0-pyhd8ed1ab_0.conda @@ -2190,6 +2201,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.2.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.22.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda @@ -2207,6 +2219,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -2281,8 +2294,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.47.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/70/04/7464f2d5051e08d04fb1288026a556001001e1a9f14f4d7b53e5891b503d/qh3-1.9.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/71/3f/a2d5458586c024e0076ae8182dcbb71450bd9d4dd65ffdaa659313461bd4/jh2-5.0.13-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/b1/07603bfeeb891a2596d5c2a68f7d2f70f7d11c841ebe391412c69c2857b0/lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -2319,7 +2341,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -2420,6 +2442,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -2662,7 +2685,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/zstandard-0.25.0-py314hd1e8ddb_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/75/f3/8c151f371f7962902e6f1f494b50abc1b8be27305a9d12cb88cf049c5a80/jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/93/8cd573bf872dfdba6ca0cace0367f1c6450f1fc399b28e8377f9735e8217/qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -2699,7 +2732,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -2800,6 +2833,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -3042,7 +3076,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py314h9d33bd4_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/75/f3/8c151f371f7962902e6f1f494b50abc1b8be27305a9d12cb88cf049c5a80/jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/93/8cd573bf872dfdba6ca0cace0367f1c6450f1fc399b28e8377f9735e8217/qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -3078,7 +3122,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyh6dadd2b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyh6dadd2b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -3178,6 +3222,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -3420,7 +3465,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/zstandard-0.25.0-py314hc5dbbe4_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5b/3c/107f16a84c8becef5281040fdb50681f06e4615bb4a754f211abe7d61a6f/jh2-5.0.13-cp37-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/76/56/a96b1c770e22158762823ed399bb2e2041af132c316decd43a5971a63830/qh3-1.9.2-cp37-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl doc: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -3602,7 +3657,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -3689,6 +3744,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-5.24.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.5.2-pyh7db6752_0.conda @@ -3742,6 +3798,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stringcase-1.2.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tabulate-0.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tenacity-9.1.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyhc90fa1f_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/text-unidecode-1.3-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda @@ -3767,8 +3824,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.24.2-pyh7db6752_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e5/ae/580600f441f6fc05218bd6c9d5794f4aef072a7d9093b291f1c50a9db8bc/plotly-5.24.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/15/a4/18b35000b00d9fb72003e0eacf66b3af828a0bb897c21a287a33d2025138/jh2-5.0.13-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/57/f6/58b48f41024d64afd33162f91f0c757a3d3212b47512a8a53dd3078b604e/qh3-1.9.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl linux-aarch64: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/argon2-cffi-bindings-25.1.0-py314h51f160d_2.conda @@ -3944,7 +4010,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -4031,6 +4097,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-5.24.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.5.2-pyh7db6752_0.conda @@ -4084,6 +4151,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stringcase-1.2.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tabulate-0.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tenacity-9.1.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyhc90fa1f_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/text-unidecode-1.3-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda @@ -4109,8 +4177,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.24.2-pyh7db6752_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e5/ae/580600f441f6fc05218bd6c9d5794f4aef072a7d9093b291f1c50a9db8bc/plotly-5.24.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/70/04/7464f2d5051e08d04fb1288026a556001001e1a9f14f4d7b53e5891b503d/qh3-1.9.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/71/3f/a2d5458586c024e0076ae8182dcbb71450bd9d4dd65ffdaa659313461bd4/jh2-5.0.13-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -4145,7 +4222,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -4232,6 +4309,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-5.24.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.5.2-pyh7db6752_0.conda @@ -4285,6 +4363,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stringcase-1.2.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tabulate-0.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tenacity-9.1.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyhc90fa1f_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/text-unidecode-1.3-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda @@ -4448,8 +4527,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/zlib-ng-2.3.3-h8bce59a_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e5/ae/580600f441f6fc05218bd6c9d5794f4aef072a7d9093b291f1c50a9db8bc/plotly-5.24.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/75/f3/8c151f371f7962902e6f1f494b50abc1b8be27305a9d12cb88cf049c5a80/jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/93/8cd573bf872dfdba6ca0cace0367f1c6450f1fc399b28e8377f9735e8217/qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -4484,7 +4572,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -4571,6 +4659,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-5.24.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.5.2-pyh7db6752_0.conda @@ -4624,6 +4713,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stringcase-1.2.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tabulate-0.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tenacity-9.1.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyhc90fa1f_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/text-unidecode-1.3-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda @@ -4787,8 +4877,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-ng-2.3.3-hed4e4f5_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e5/ae/580600f441f6fc05218bd6c9d5794f4aef072a7d9093b291f1c50a9db8bc/plotly-5.24.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/75/f3/8c151f371f7962902e6f1f494b50abc1b8be27305a9d12cb88cf049c5a80/jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/93/8cd573bf872dfdba6ca0cace0367f1c6450f1fc399b28e8377f9735e8217/qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -4822,7 +4921,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyh6dadd2b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyh6dadd2b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -4908,6 +5007,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-5.24.1-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/propcache-0.5.2-pyh7db6752_0.conda @@ -4960,6 +5060,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/stringcase-1.2.0-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tabulate-0.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tenacity-9.1.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyh6dadd2b_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/text-unidecode-1.3-pyhd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda @@ -5124,8 +5225,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/zlib-ng-2.3.3-h0261ad2_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e5/ae/580600f441f6fc05218bd6c9d5794f4aef072a7d9093b291f1c50a9db8bc/plotly-5.24.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5b/3c/107f16a84c8becef5281040fdb50681f06e4615bb4a754f211abe7d61a6f/jh2-5.0.13-cp37-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/76/56/a96b1c770e22158762823ed399bb2e2041af132c316decd43a5971a63830/qh3-1.9.2-cp37-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl python-310: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -5231,6 +5341,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda @@ -5281,6 +5392,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.2.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.22.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda @@ -5296,6 +5408,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -5359,9 +5472,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.47.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/15/a4/18b35000b00d9fb72003e0eacf66b3af828a0bb897c21a287a33d2025138/jh2-5.0.13-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/57/f6/58b48f41024d64afd33162f91f0c757a3d3212b47512a8a53dd3078b604e/qh3-1.9.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/7a/16/cb391ee4b90186fa16d9ebcbe3ea96c71b8da3b0686386c8dcbcc3c67d44/lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl linux-aarch64: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/argon2-cffi-bindings-25.1.0-py310h5b55623_2.conda @@ -5462,6 +5583,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda @@ -5512,6 +5634,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.2.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.22.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda @@ -5527,6 +5650,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -5590,9 +5714,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.47.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/70/04/7464f2d5051e08d04fb1288026a556001001e1a9f14f4d7b53e5891b503d/qh3-1.9.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/71/3f/a2d5458586c024e0076ae8182dcbb71450bd9d4dd65ffdaa659313461bd4/jh2-5.0.13-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/b1/07603bfeeb891a2596d5c2a68f7d2f70f7d11c841ebe391412c69c2857b0/lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda @@ -5614,6 +5746,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda @@ -5664,6 +5797,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.2.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.22.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda @@ -5679,6 +5813,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -5817,9 +5952,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/zlib-ng-2.3.3-h8bce59a_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/75/f3/8c151f371f7962902e6f1f494b50abc1b8be27305a9d12cb88cf049c5a80/jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/78/20/f7095ed9fc2c025f9cfe71cc6ec9f1feb05624edc1812423b5f1aecf3d4b/lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/93/8cd573bf872dfdba6ca0cace0367f1c6450f1fc399b28e8377f9735e8217/qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda @@ -5841,6 +5984,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda @@ -5891,6 +6035,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.2.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.22.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda @@ -5906,6 +6051,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -6044,9 +6190,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-ng-2.3.3-hed4e4f5_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/12/da/dbe4dfc01ac226fb0504fad035f4d69f3202f3502e20e68537631daddd96/lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/75/f3/8c151f371f7962902e6f1f494b50abc1b8be27305a9d12cb88cf049c5a80/jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/93/8cd573bf872dfdba6ca0cace0367f1c6450f1fc399b28e8377f9735e8217/qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.7.0-pyhd8ed1ab_1.conda @@ -6067,6 +6221,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyh6dadd2b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda @@ -6117,6 +6272,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.2.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/narwhals-2.22.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda @@ -6131,6 +6287,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -6276,9 +6433,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/zlib-ng-2.3.3-h0261ad2_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5b/3c/107f16a84c8becef5281040fdb50681f06e4615bb4a754f211abe7d61a6f/jh2-5.0.13-cp37-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/76/56/a96b1c770e22158762823ed399bb2e2041af132c316decd43a5971a63830/qh3-1.9.2-cp37-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/9e/97/33691c66a4d7ec1a5a98e7c909a5b83ee45c7f7ba4cf92b1c4cf26e98079/lxml-6.1.1-cp310-cp310-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl python-311: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -6460,7 +6625,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -6540,6 +6705,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -6610,7 +6776,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/15/a4/18b35000b00d9fb72003e0eacf66b3af828a0bb897c21a287a33d2025138/jh2-5.0.13-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/57/f6/58b48f41024d64afd33162f91f0c757a3d3212b47512a8a53dd3078b604e/qh3-1.9.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl linux-aarch64: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/aiohttp-3.14.1-py311hf076524_0.conda @@ -6786,7 +6962,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -6866,6 +7042,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -6936,7 +7113,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/70/04/7464f2d5051e08d04fb1288026a556001001e1a9f14f4d7b53e5891b503d/qh3-1.9.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/71/3f/a2d5458586c024e0076ae8182dcbb71450bd9d4dd65ffdaa659313461bd4/jh2-5.0.13-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.2-pyhd8ed1ab_0.conda @@ -6966,7 +7153,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -7046,6 +7233,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -7257,7 +7445,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/zlib-ng-2.3.3-h8bce59a_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/75/f3/8c151f371f7962902e6f1f494b50abc1b8be27305a9d12cb88cf049c5a80/jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/93/8cd573bf872dfdba6ca0cace0367f1c6450f1fc399b28e8377f9735e8217/qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.2-pyhd8ed1ab_0.conda @@ -7287,7 +7485,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -7367,6 +7565,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -7578,7 +7777,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-ng-2.3.3-hed4e4f5_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl + - pypi: https://files.pythonhosted.org/packages/75/f3/8c151f371f7962902e6f1f494b50abc1b8be27305a9d12cb88cf049c5a80/jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/93/8cd573bf872dfdba6ca0cace0367f1c6450f1fc399b28e8377f9735e8217/qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiohappyeyeballs-2.6.2-pyhd8ed1ab_0.conda @@ -7607,7 +7816,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyh6dadd2b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyh6dadd2b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -7686,6 +7895,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -7897,7 +8107,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/zlib-ng-2.3.3-h0261ad2_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5b/3c/107f16a84c8becef5281040fdb50681f06e4615bb4a754f211abe7d61a6f/jh2-5.0.13-cp37-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/76/56/a96b1c770e22158762823ed399bb2e2041af132c316decd43a5971a63830/qh3-1.9.2-cp37-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl python-312: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -8080,7 +8300,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -8161,6 +8381,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -8232,7 +8453,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/15/a4/18b35000b00d9fb72003e0eacf66b3af828a0bb897c21a287a33d2025138/jh2-5.0.13-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/57/f6/58b48f41024d64afd33162f91f0c757a3d3212b47512a8a53dd3078b604e/qh3-1.9.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl linux-aarch64: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/aiohttp-3.14.1-py312he7e3343_0.conda @@ -8409,7 +8640,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -8490,6 +8721,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -8561,7 +8793,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/70/04/7464f2d5051e08d04fb1288026a556001001e1a9f14f4d7b53e5891b503d/qh3-1.9.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/71/3f/a2d5458586c024e0076ae8182dcbb71450bd9d4dd65ffdaa659313461bd4/jh2-5.0.13-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -8592,7 +8834,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -8673,6 +8915,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -8885,7 +9128,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/zlib-ng-2.3.3-h8bce59a_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/75/f3/8c151f371f7962902e6f1f494b50abc1b8be27305a9d12cb88cf049c5a80/jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/93/8cd573bf872dfdba6ca0cace0367f1c6450f1fc399b28e8377f9735e8217/qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -8916,7 +9169,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -8997,6 +9250,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -9209,7 +9463,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-ng-2.3.3-hed4e4f5_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl + - pypi: https://files.pythonhosted.org/packages/75/f3/8c151f371f7962902e6f1f494b50abc1b8be27305a9d12cb88cf049c5a80/jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/93/8cd573bf872dfdba6ca0cace0367f1c6450f1fc399b28e8377f9735e8217/qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -9239,7 +9503,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyh6dadd2b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyh6dadd2b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -9319,6 +9583,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -9531,7 +9796,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/zlib-ng-2.3.3-h0261ad2_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5b/3c/107f16a84c8becef5281040fdb50681f06e4615bb4a754f211abe7d61a6f/jh2-5.0.13-cp37-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/76/56/a96b1c770e22158762823ed399bb2e2041af132c316decd43a5971a63830/qh3-1.9.2-cp37-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl python-313: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -9712,7 +9987,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -9793,6 +10068,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -9863,7 +10139,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/15/a4/18b35000b00d9fb72003e0eacf66b3af828a0bb897c21a287a33d2025138/jh2-5.0.13-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/57/f6/58b48f41024d64afd33162f91f0c757a3d3212b47512a8a53dd3078b604e/qh3-1.9.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl linux-aarch64: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/aiohttp-3.14.1-py313ha60b548_0.conda @@ -10038,7 +10324,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -10119,6 +10405,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -10189,7 +10476,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/widgetsnbextension-4.0.15-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/70/04/7464f2d5051e08d04fb1288026a556001001e1a9f14f4d7b53e5891b503d/qh3-1.9.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/71/3f/a2d5458586c024e0076ae8182dcbb71450bd9d4dd65ffdaa659313461bd4/jh2-5.0.13-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -10220,7 +10517,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -10301,6 +10598,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -10512,7 +10810,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/zlib-ng-2.3.3-h8bce59a_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/75/f3/8c151f371f7962902e6f1f494b50abc1b8be27305a9d12cb88cf049c5a80/jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/93/8cd573bf872dfdba6ca0cace0367f1c6450f1fc399b28e8377f9735e8217/qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -10543,7 +10851,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -10624,6 +10932,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -10835,7 +11144,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-ng-2.3.3-hed4e4f5_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/75/f3/8c151f371f7962902e6f1f494b50abc1b8be27305a9d12cb88cf049c5a80/jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/93/8cd573bf872dfdba6ca0cace0367f1c6450f1fc399b28e8377f9735e8217/qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -10865,7 +11184,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyh6dadd2b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyh6dadd2b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -10945,6 +11264,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -11156,7 +11476,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/zlib-ng-2.3.3-h0261ad2_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5b/3c/107f16a84c8becef5281040fdb50681f06e4615bb4a754f211abe7d61a6f/jh2-5.0.13-cp37-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/76/56/a96b1c770e22158762823ed399bb2e2041af132c316decd43a5971a63830/qh3-1.9.2-cp37-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl python-314: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -11334,7 +11664,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -11418,6 +11748,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -11490,7 +11821,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.24.2-pyh7db6752_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/15/a4/18b35000b00d9fb72003e0eacf66b3af828a0bb897c21a287a33d2025138/jh2-5.0.13-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/57/f6/58b48f41024d64afd33162f91f0c757a3d3212b47512a8a53dd3078b604e/qh3-1.9.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl linux-aarch64: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/argon2-cffi-bindings-25.1.0-py314h51f160d_2.conda @@ -11662,7 +12003,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -11746,6 +12087,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -11818,7 +12160,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/yarl-1.24.2-pyh7db6752_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/70/04/7464f2d5051e08d04fb1288026a556001001e1a9f14f4d7b53e5891b503d/qh3-1.9.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/71/3f/a2d5458586c024e0076ae8182dcbb71450bd9d4dd65ffdaa659313461bd4/jh2-5.0.13-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -11852,7 +12204,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -11936,6 +12288,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -12143,7 +12496,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/zlib-ng-2.3.3-h8bce59a_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/75/f3/8c151f371f7962902e6f1f494b50abc1b8be27305a9d12cb88cf049c5a80/jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/93/8cd573bf872dfdba6ca0cace0367f1c6450f1fc399b28e8377f9735e8217/qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -12177,7 +12540,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -12261,6 +12624,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -12468,7 +12832,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-ng-2.3.3-hed4e4f5_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/75/f3/8c151f371f7962902e6f1f494b50abc1b8be27305a9d12cb88cf049c5a80/jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/e4/93/8cd573bf872dfdba6ca0cace0367f1c6450f1fc399b28e8377f9735e8217/qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -12501,7 +12875,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyh6dadd2b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyh6dadd2b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -12584,6 +12958,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda @@ -12791,7 +13166,17 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/zlib-ng-2.3.3-h0261ad2_1.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - pypi: . - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/5b/3c/107f16a84c8becef5281040fdb50681f06e4615bb4a754f211abe7d61a6f/jh2-5.0.13-cp37-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/76/56/a96b1c770e22158762823ed399bb2e2041af132c316decd43a5971a63830/qh3-1.9.2-cp37-abi3-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl release: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -12990,7 +13375,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -13083,6 +13468,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt_toolkit-3.0.52-hd8ed1ab_0.conda @@ -13159,7 +13545,6 @@ environments: - pypi: . - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl linux-aarch64: - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-20_gnu.conda - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/argon2-cffi-bindings-25.1.0-py314h51f160d_2.conda @@ -13352,7 +13737,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -13445,6 +13830,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt_toolkit-3.0.52-hd8ed1ab_0.conda @@ -13521,7 +13907,6 @@ environments: - pypi: . - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl osx-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -13557,7 +13942,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -13649,6 +14034,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt_toolkit-3.0.52-hd8ed1ab_0.conda @@ -13866,7 +14252,6 @@ environments: - pypi: . - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl osx-arm64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -13902,7 +14287,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -13994,6 +14379,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt_toolkit-3.0.52-hd8ed1ab_0.conda @@ -14211,7 +14597,6 @@ environments: - pypi: . - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl win-64: - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda - conda: https://conda.anaconda.org/conda-forge/noarch/aiobotocore-3.7.0-pyhcf101f3_0.conda @@ -14246,7 +14631,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/chardet-7.4.3-pyhcf101f3_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyh6dadd2b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyh6dadd2b_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda @@ -14337,6 +14722,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/pixi-kernel-0.7.1-pyhbbac1ac_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.25.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/prompt_toolkit-3.0.52-hd8ed1ab_0.conda @@ -14555,7 +14941,6 @@ environments: - pypi: . - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl packages: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda build_number: 20 @@ -25001,9 +25386,9 @@ packages: - pkg:pypi/charset-normalizer?source=hash-mapping size: 58872 timestamp: 1775127203018 -- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyh6dadd2b_0.conda - sha256: 5b8e8d8876ace41735f51ca43c43cdc9e1b4fbbae0f415d6b8441fec826d8c47 - md5: f73f35eedcd8e89d6c4407df15101233 +- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyh6dadd2b_0.conda + sha256: acdcff82819e9c20719f8e6b6f69d72109ee056445a9995417dafe15a50d07fa + md5: 8f03c7a39b01ebc57b647f7b48bbeed9 depends: - __win - colorama @@ -25013,11 +25398,11 @@ packages: license_family: BSD purls: - pkg:pypi/click?source=hash-mapping - size: 104080 - timestamp: 1779900586237 -- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda - sha256: c253a41cdf898b651a0786cbb76c6d5fc101d0dbbe719f93a124bc4fde5cdd6a - md5: 554304a07e581a85891b15e39ea9f268 + size: 98871 + timestamp: 1777219929886 +- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda + sha256: 37a5d8b10ea3516e2c42f870c9c351b9f7b31ff48c66d83490039f417e1e5228 + md5: 2266262ce8a425ecb6523d765f79b303 depends: - __unix - python @@ -25025,9 +25410,9 @@ packages: license: BSD-3-Clause license_family: BSD purls: - - pkg:pypi/click?source=compressed-mapping - size: 104999 - timestamp: 1779900548735 + - pkg:pypi/click?source=hash-mapping + size: 100048 + timestamp: 1777219902525 - conda: https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda sha256: 4c287c2721d8a34c94928be8fe0e9a85754e90189dd4384a31b1806856b50a67 md5: 61b8078a0905b12529abc622406cb62c @@ -26871,6 +27256,36 @@ packages: - pkg:pypi/platformdirs?source=compressed-mapping size: 26308 timestamp: 1779972894916 +- conda: https://conda.anaconda.org/conda-forge/noarch/plotly-5.24.1-pyhd8ed1ab_1.conda + sha256: d1bbf2d80105bfc8a7ed9817888f4a1686ed393d6435572921add09cc9347c1c + md5: 71ac632876630091c81c50a05ec5e030 + depends: + - packaging + - python >=3.9 + - tenacity >=6.2.0 + constrains: + - ipywidgets >=7.6 + license: MIT + license_family: MIT + purls: + - pkg:pypi/plotly?source=hash-mapping + size: 8022748 + timestamp: 1733733328161 +- conda: https://conda.anaconda.org/conda-forge/noarch/plotly-6.8.0-pyhd8ed1ab_0.conda + sha256: 4fb6cf23ca322b45f7dafb095bf42192f9ee85b18184fc4a1f82ae6a962dd1b0 + md5: 499b2e5cc7cf18761cfd20d6fb837f48 + depends: + - narwhals >=1.15.1 + - packaging + - python >=3.10 + constrains: + - ipywidgets >=7.6 + license: MIT + license_family: MIT + purls: + - pkg:pypi/plotly?source=hash-mapping + size: 4119420 + timestamp: 1780554856380 - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda sha256: e14aafa63efa0528ca99ba568eaf506eb55a0371d12e6250aaaa61718d2eb62e md5: d7585b6550ad04c8c5e21097ada2888e @@ -27953,6 +28368,18 @@ packages: - pkg:pypi/tabulate?source=hash-mapping size: 43964 timestamp: 1772732795746 +- conda: https://conda.anaconda.org/conda-forge/noarch/tenacity-9.1.4-pyhcf101f3_0.conda + sha256: 32e75900d6a094ffe4290a8c9f1fa15744d9da8ff617aba4acaa0f057a065c34 + md5: 043f0599dc8aa023369deacdb5ac24eb + depends: + - python >=3.10 + - python + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/tenacity?source=hash-mapping + size: 31404 + timestamp: 1770510172846 - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyh6dadd2b_1.conda sha256: b375e8df0d5710717c31e7c8e93c025c37fa3504aea325c7a55509f64e5d4340 md5: e43ca10d61e55d0a8ec5d8c62474ec9e @@ -42850,13 +43277,31 @@ packages: name: unitpackage requires_dist: - astropy>=5,<8 - - click>=8,<9 + - click>=8,<8.4 - frictionless>=5.8.3,<6 - matplotlib>=3.5.0,<4 - pandas>=1.3,<4 - plotly>=5,<7 - pybtex>=0.25,<0.27 + - tomli>=1.0 ; python_full_version < '3.11' + - elabapi-python>=5.5.0 ; extra == 'elabftw' + - kadi-apy>=0.50 ; extra == 'kadi' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl + name: lxml + version: 6.1.1 + sha256: ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl + name: jsonref + version: 1.1.0 + sha256: 590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9 + requires_python: '>=3.7' - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl name: build version: 1.5.0 @@ -42872,133 +43317,394 @@ packages: - virtualenv>=20.17 ; python_full_version >= '3.10' and python_full_version < '3.14' and extra == 'virtualenv' - virtualenv>=20.31 ; python_full_version >= '3.14' and extra == 'virtualenv' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl - name: narwhals - version: 2.22.1 - sha256: 60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53 +- pypi: https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl + name: rdflib + version: 7.6.0 + sha256: 30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd requires_dist: - - cudf-cu12>=24.10.0 ; sys_platform == 'linux' and extra == 'cudf' - - dask[dataframe]>=2024.8 ; extra == 'dask' - - duckdb>=1.1 ; extra == 'duckdb' - - ibis-framework>=6.0.0 ; extra == 'ibis' - - rich>=12.4.4 ; extra == 'ibis' - - packaging>=21.3 ; extra == 'ibis' - - pyarrow-hotfix>=0.7 ; extra == 'ibis' - - modin>=0.22.0 ; extra == 'modin' - - pandas>=1.3.4 ; extra == 'pandas' - - polars>=0.20.4 ; extra == 'polars' - - pyarrow>=13.0.0 ; extra == 'pyarrow' - - pyspark>=3.5.0 ; extra == 'pyspark' - - pyspark[connect]>=3.5.0 ; extra == 'pyspark-connect' - - narwhals[duckdb] ; extra == 'sql' - - sqlparse>=0.5.5 ; extra == 'sql' - - sqlframe>=3.22.0,!=3.39.3 ; extra == 'sqlframe' - requires_python: '>=3.10' + - berkeleydb>=18.1.0,<19.0.0 ; extra == 'berkeleydb' + - html5rdf>=1.2,<2 ; extra == 'html' + - httpx>=0.28.1,<0.29.0 ; extra == 'graphdb' or extra == 'rdf4j' + - isodate>=0.7.2,<1.0.0 ; python_full_version < '3.11' + - lxml>=4.3,<6.0 ; extra == 'lxml' + - networkx>=2,<4 ; extra == 'networkx' + - orjson>=3.9.14,<4 ; extra == 'orjson' + - pyparsing>=2.1.0,<4 + requires_python: '>=3.8.1' +- pypi: https://files.pythonhosted.org/packages/12/da/dbe4dfc01ac226fb0504fad035f4d69f3202f3502e20e68537631daddd96/lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl + name: lxml + version: 6.1.1 + sha256: 09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl + name: lxml + version: 6.1.1 + sha256: 19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/15/a4/18b35000b00d9fb72003e0eacf66b3af828a0bb897c21a287a33d2025138/jh2-5.0.13-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: jh2 + version: 5.0.13 + sha256: 85d3d338284fbfbe173243c3cc62e510c6fa0d6d023e67da2292bd7578c734e3 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl + name: lxml + version: 6.1.1 + sha256: 3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl + name: lxml + version: 6.1.1 + sha256: 26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl + name: lxml + version: 6.1.1 + sha256: 25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl + name: lxml + version: 6.1.1 + sha256: 96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/30/7f/4c60c51576223b2613a16a452884018f7a1626f6102952f1cc285eb78b41/xmlhelpy-0.15.0-py3-none-any.whl + name: xmlhelpy + version: 0.15.0 + sha256: 01a6a86cffd9224d67201c7cab4a21ae3f3f5b0495f69ad237314a130f87023f + requires_dist: + - click>=8.0.0,<9.0.0 + - lxml>=5.0.0,<7.0.0 + - black==25.1.0 ; extra == 'dev' + - build==1.2.2.post1 ; extra == 'dev' + - isort==6.0.1 ; extra == 'dev' + - pre-commit==4.0.1 ; extra == 'dev' + - pylint==3.3.6 ; extra == 'dev' + - pytest==8.3.5 ; extra == 'dev' + - twine==6.1.0 ; extra == 'dev' + requires_python: '>=3.9,<3.15' +- pypi: https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl + name: lxml + version: 6.1.1 + sha256: 9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/54/9e/472991fc66d940d3ba3268fc8c2866a44b6870a9711ab74fcf1f29e817e1/wassima-2.1.1-py3-none-any.whl + name: wassima + version: 2.1.1 + sha256: ab0f12c091ff697f111eea5f925aafd83736909a1c9c764a8bbf874b2e4d4a42 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/57/f6/58b48f41024d64afd33162f91f0c757a3d3212b47512a8a53dd3078b604e/qh3-1.9.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl + name: qh3 + version: 1.9.2 + sha256: 2b18b9d2f892bb95f7f3d3428bb4166f974c9a2e26fcf91b33dda9b4285e7928 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/5b/3c/107f16a84c8becef5281040fdb50681f06e4615bb4a754f211abe7d61a6f/jh2-5.0.13-cp37-abi3-win_amd64.whl + name: jh2 + version: 5.0.13 + sha256: 5d61bc0a62b4eee11039bf29d4bdbb26efcff760dfb9d8e0739740028aa52b0f + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/5e/0e/e50386b53f5ae135147e5762a16490e521a85814dc9db9a4d091ff22821d/urllib3_future-2.21.902-py3-none-any.whl + name: urllib3-future + version: 2.21.902 + sha256: 0e7f57858b9faf12bf84f30ff86ca9fffeb271f8bd92fd519b765a89c46f4962 + requires_dist: + - h11>=0.11.0,<1.0.0 + - jh2>=5.0.3,<6.0.0 + - qh3>=1.5.4,<2.0.0 ; (python_full_version <= '3.7.10' and platform_machine == 'AMD64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version <= '3.7.10' and platform_machine == 'AMD64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version <= '3.7.10' and platform_machine == 'AMD64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version <= '3.7.10' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version <= '3.7.10' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version <= '3.7.10' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version <= '3.7.10' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version <= '3.7.10' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version <= '3.7.10' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version <= '3.7.10' and platform_machine == 'arm64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version <= '3.7.10' and platform_machine == 'arm64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version <= '3.7.10' and platform_machine == 'arm64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version <= '3.7.10' and platform_machine == 'armv7l' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version <= '3.7.10' and platform_machine == 'armv7l' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version <= '3.7.10' and platform_machine == 'armv7l' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version <= '3.7.10' and platform_machine == 'i686' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version <= '3.7.10' and platform_machine == 'i686' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version <= '3.7.10' and platform_machine == 'i686' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version <= '3.7.10' and platform_machine == 'ppc64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version <= '3.7.10' and platform_machine == 'ppc64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version <= '3.7.10' and platform_machine == 'ppc64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version <= '3.7.10' and platform_machine == 'ppc64le' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version <= '3.7.10' and platform_machine == 'ppc64le' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version <= '3.7.10' and platform_machine == 'ppc64le' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version <= '3.7.10' and platform_machine == 'riscv64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version <= '3.7.10' and platform_machine == 'riscv64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version <= '3.7.10' and platform_machine == 'riscv64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version <= '3.7.10' and platform_machine == 'riscv64gc' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version <= '3.7.10' and platform_machine == 'riscv64gc' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version <= '3.7.10' and platform_machine == 'riscv64gc' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version <= '3.7.10' and platform_machine == 's390x' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version <= '3.7.10' and platform_machine == 's390x' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version <= '3.7.10' and platform_machine == 's390x' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version <= '3.7.10' and platform_machine == 'x86' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version <= '3.7.10' and platform_machine == 'x86' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version <= '3.7.10' and platform_machine == 'x86' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version <= '3.7.10' and platform_machine == 'x86_64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version <= '3.7.10' and platform_machine == 'x86_64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version <= '3.7.10' and platform_machine == 'x86_64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version > '3.7.10' and platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version > '3.7.10' and platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version > '3.7.10' and platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'AMD64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'AMD64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'AMD64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version > '3.7.10' and platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version > '3.7.10' and platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version > '3.7.10' and platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version > '3.7.10' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version > '3.7.10' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version > '3.7.10' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version > '3.7.10' and platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version > '3.7.10' and platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version > '3.7.10' and platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'arm64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'arm64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'arm64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version > '3.7.10' and platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version > '3.7.10' and platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version > '3.7.10' and platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'armv7l' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'armv7l' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'armv7l' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version > '3.7.10' and platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version > '3.7.10' and platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version > '3.7.10' and platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'i686' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'i686' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'i686' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version > '3.7.10' and platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version > '3.7.10' and platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version > '3.7.10' and platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'ppc64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'ppc64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'ppc64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version > '3.7.10' and platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version > '3.7.10' and platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version > '3.7.10' and platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'ppc64le' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'ppc64le' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'ppc64le' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version > '3.7.10' and platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version > '3.7.10' and platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version > '3.7.10' and platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'riscv64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'riscv64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'riscv64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version > '3.7.10' and platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version > '3.7.10' and platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version > '3.7.10' and platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'riscv64gc' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'riscv64gc' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'riscv64gc' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version > '3.7.10' and platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version > '3.7.10' and platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version > '3.7.10' and platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 's390x' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 's390x' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 's390x' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version > '3.7.10' and platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version > '3.7.10' and platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version > '3.7.10' and platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'x86' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'x86' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'x86' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version > '3.7.10' and platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version > '3.7.10' and platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version > '3.7.10' and platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'x86_64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'x86_64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'x86_64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version >= '3.12' and platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version >= '3.12' and platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version >= '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version >= '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version >= '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version >= '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version >= '3.12' and platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version >= '3.12' and platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version >= '3.12' and platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version >= '3.12' and platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version >= '3.12' and platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version >= '3.12' and platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version >= '3.12' and platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version >= '3.12' and platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version >= '3.12' and platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version >= '3.12' and platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version >= '3.12' and platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version >= '3.12' and platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version >= '3.12' and platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version >= '3.12' and platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version >= '3.12' and platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version >= '3.12' and platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version >= '3.12' and platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version >= '3.12' and platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (python_full_version >= '3.12' and platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (python_full_version >= '3.12' and platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') + - brotli>=1.0.9 ; platform_python_implementation == 'CPython' and extra == 'brotli' + - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'brotli' + - qh3>=1.5.4,<2.0.0 ; extra == 'qh3' + - rtls ; platform_python_implementation == 'CPython' and extra == 'rtls' + - python-socks>=2,<=2.8.1 ; extra == 'socks' + - brotli>=1.0.9 ; platform_python_implementation == 'CPython' and extra == 'utls' + - utls ; platform_python_implementation == 'CPython' and extra == 'utls' + - zstandard>=0.18.0 ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and extra == 'utls' + - wsproto>=1.2,<2 ; extra == 'ws' + - zstandard>=0.18.0 ; python_full_version < '3.14' and extra == 'zstd' + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl + name: lxml + version: 6.1.1 + sha256: abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl + name: lxml + version: 6.1.1 + sha256: 53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl + name: lxml + version: 6.1.1 + sha256: 18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl + name: lxml + version: 6.1.1 + sha256: 104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/70/04/7464f2d5051e08d04fb1288026a556001001e1a9f14f4d7b53e5891b503d/qh3-1.9.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + name: qh3 + version: 1.9.2 + sha256: f7e632d420d88e30e4e3fc9f5457c362e964e93e91f9c90d148b68b0d4b0dc06 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/71/3f/a2d5458586c024e0076ae8182dcbb71450bd9d4dd65ffdaa659313461bd4/jh2-5.0.13-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + name: jh2 + version: 5.0.13 + sha256: f810dd02b4a9647eacbe4eab617111c0e3ffc68e923e3ff172948db601d2ba1e + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/75/f3/8c151f371f7962902e6f1f494b50abc1b8be27305a9d12cb88cf049c5a80/jh2-5.0.13-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + name: jh2 + version: 5.0.13 + sha256: 3f54095cde010ff4e52f23dda92b31d3d3160c64f5f23fc6e181f8c7938afd33 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/76/56/a96b1c770e22158762823ed399bb2e2041af132c316decd43a5971a63830/qh3-1.9.2-cp37-abi3-win_amd64.whl + name: qh3 + version: 1.9.2 + sha256: 643e8aa05519dddaf20159d1c13c15473de7649622a1c438968ce0f0da973951 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/78/20/f7095ed9fc2c025f9cfe71cc6ec9f1feb05624edc1812423b5f1aecf3d4b/lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl + name: lxml + version: 6.1.1 + sha256: f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/7a/16/cb391ee4b90186fa16d9ebcbe3ea96c71b8da3b0686386c8dcbcc3c67d44/lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl + name: lxml + version: 6.1.1 + sha256: 98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/80/27/498b676bf5e4824d84c6bae37bf948c164bcad0484b30851a935f165756b/niquests-3.19.1-py3-none-any.whl + name: niquests + version: 3.19.1 + sha256: ed04a8e2813f0b12f2ec82982c500fd115499b88d1abfa689fc9116ecdc68ede + requires_dist: + - charset-normalizer>=2,<4 + - urllib3-future>=2.13.903,<3 + - wassima>=1.0.1,<3 ; sys_platform != 'emscripten' + - urllib3-future[brotli] ; extra == 'brotli' + - orjson>=3,<4 ; extra == 'full' + - urllib3-future[brotli,rtls,socks,ws,zstd] ; extra == 'full' + - urllib3-future[qh3] ; extra == 'http3' + - urllib3-future[qh3] ; extra == 'ocsp' + - urllib3-future[rtls] ; extra == 'rtls' + - urllib3-future[socks] ; extra == 'socks' + - orjson>=3,<4 ; extra == 'speedups' + - urllib3-future[brotli,zstd] ; extra == 'speedups' + - urllib3-future[utls] ; extra == 'utls' + - urllib3-future[ws] ; extra == 'ws' + - urllib3-future[zstd] ; extra == 'zstd' + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl + name: lxml + version: 6.1.1 + sha256: c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/9e/97/33691c66a4d7ec1a5a98e7c909a5b83ee45c7f7ba4cf92b1c4cf26e98079/lxml-6.1.1-cp310-cp310-win_amd64.whl + name: lxml + version: 6.1.1 + sha256: 3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl + name: lxml + version: 6.1.1 + sha256: 7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl + name: lxml + version: 6.1.1 + sha256: 68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl + name: lxml + version: 6.1.1 + sha256: 1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl + name: lxml + version: 6.1.1 + sha256: b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl + name: lxml + version: 6.1.1 + sha256: ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl name: pyproject-hooks version: 1.2.0 sha256: 9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl - name: click - version: 8.4.1 - sha256: 482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2 +- pypi: https://files.pythonhosted.org/packages/d0/b1/07603bfeeb891a2596d5c2a68f7d2f70f7d11c841ebe391412c69c2857b0/lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl + name: lxml + version: 6.1.1 + sha256: 1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30 requires_dist: - - colorama ; sys_platform == 'win32' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl - name: tenacity - version: 9.1.4 - sha256: 6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55 + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/d6/97/bfdc70127a17c148547ced7dd8ce769deb64f044119329938e2a0a47ff7e/kadi_apy-0.50.0-py3-none-any.whl + name: kadi-apy + version: 0.50.0 + sha256: 1e162d6ad9a694a324962843fa2f17deb6310387689624de538f72bef45b6a73 requires_dist: - - reno ; extra == 'doc' - - sphinx ; extra == 'doc' - - pytest ; extra == 'test' - - tornado>=4.5 ; extra == 'test' - - typeguard ; extra == 'test' - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/e5/ae/580600f441f6fc05218bd6c9d5794f4aef072a7d9093b291f1c50a9db8bc/plotly-5.24.1-py3-none-any.whl - name: plotly - version: 5.24.1 - sha256: f67073a1e637eb0dc3e46324d9d51e2fe76e9727c892dde64ddf1e1b51f29089 + - click>=8.2.0,<8.4.0 + - jsonref>=1.1.0,<2.0.0 + - python-dateutil>=2.8.2,<3.0.0 + - rdflib>=7.0.0,<8.0.0 + - niquests>=3.13.0,<4.0.0 + - xmlhelpy>=0.14.0,<0.16.0 + - autoflake==2.3.3 ; extra == 'dev' + - black==26.3.0 ; extra == 'dev' + - build==1.2.2.post1 ; extra == 'dev' + - isort==6.0.1 ; extra == 'dev' + - myst-parser==3.0.1 ; extra == 'dev' + - pre-commit==4.5.1 ; extra == 'dev' + - pylint==3.3.7 ; extra == 'dev' + - sphinx==7.4.7 ; extra == 'dev' + - sphinx-click==6.0.0 ; extra == 'dev' + - sphinx-rtd-theme==3.0.2 ; extra == 'dev' + - twine==6.1.0 ; extra == 'dev' + requires_python: '>=3.9,<3.15' +- pypi: https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl + name: lxml + version: 6.1.1 + sha256: ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00 requires_dist: - - tenacity>=6.2.0 - - packaging + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' requires_python: '>=3.8' -- pypi: https://files.pythonhosted.org/packages/f9/14/abe5ce876ab5b66ee3c691bf537fcd43d037aea55d447aacf74630a8f31e/plotly-6.8.0-py3-none-any.whl - name: plotly - version: 6.8.0 - sha256: 13c5c4a0f70b74cab1913eda0de49b826df5931708eb6f9c3010040614700ec8 +- pypi: https://files.pythonhosted.org/packages/e4/93/8cd573bf872dfdba6ca0cace0367f1c6450f1fc399b28e8377f9735e8217/qh3-1.9.2-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl + name: qh3 + version: 1.9.2 + sha256: aabb7c86f49c668ab5bfb297c5da18755a3ca9cf362f8ce73b86f55f43f662fc + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl + name: lxml + version: 6.1.1 + sha256: a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660 requires_dist: - - narwhals>=1.15.1 - - packaging - - anywidget ; extra == 'dev' - - build ; extra == 'dev' - - colorcet ; extra == 'dev' - - fiona<=1.9.6 ; python_full_version < '3.9' and extra == 'dev' - - geopandas ; extra == 'dev' - - inflect ; extra == 'dev' - - jupyterlab ; extra == 'dev' - - kaleido>=1.3.0 ; extra == 'dev' - - numpy>=1.22 ; extra == 'dev' - - orjson ; extra == 'dev' - - pandas ; extra == 'dev' - - pdfrw ; extra == 'dev' - - pillow ; extra == 'dev' - - plotly-geo ; extra == 'dev' - - polars[timezone] ; extra == 'dev' - - pyarrow ; extra == 'dev' - - pyshp ; extra == 'dev' - - pytest ; extra == 'dev' - - pytz ; extra == 'dev' - - requests ; extra == 'dev' - - ruff==0.11.12 ; extra == 'dev' - - scikit-image ; extra == 'dev' - - scipy ; extra == 'dev' - - shapely ; extra == 'dev' - - statsmodels ; extra == 'dev' - - vaex ; python_full_version < '3.10' and extra == 'dev' - - xarray ; extra == 'dev' - - build ; extra == 'dev-build' - - jupyterlab ; extra == 'dev-build' - - pytest ; extra == 'dev-build' - - requests ; extra == 'dev-build' - - ruff==0.11.12 ; extra == 'dev-build' - - pytest ; extra == 'dev-core' - - requests ; extra == 'dev-core' - - ruff==0.11.12 ; extra == 'dev-core' - - anywidget ; extra == 'dev-optional' - - build ; extra == 'dev-optional' - - colorcet ; extra == 'dev-optional' - - fiona<=1.9.6 ; python_full_version < '3.9' and extra == 'dev-optional' - - geopandas ; extra == 'dev-optional' - - inflect ; extra == 'dev-optional' - - jupyterlab ; extra == 'dev-optional' - - kaleido>=1.3.0 ; extra == 'dev-optional' - - numpy>=1.22 ; extra == 'dev-optional' - - orjson ; extra == 'dev-optional' - - pandas ; extra == 'dev-optional' - - pdfrw ; extra == 'dev-optional' - - pillow ; extra == 'dev-optional' - - plotly-geo ; extra == 'dev-optional' - - polars[timezone] ; extra == 'dev-optional' - - pyarrow ; extra == 'dev-optional' - - pyshp ; extra == 'dev-optional' - - pytest ; extra == 'dev-optional' - - pytz ; extra == 'dev-optional' - - requests ; extra == 'dev-optional' - - ruff==0.11.12 ; extra == 'dev-optional' - - scikit-image ; extra == 'dev-optional' - - scipy ; extra == 'dev-optional' - - shapely ; extra == 'dev-optional' - - statsmodels ; extra == 'dev-optional' - - vaex ; python_full_version < '3.10' and extra == 'dev-optional' - - xarray ; extra == 'dev-optional' - - numpy>=1,<2 ; extra == 'dev-pandas1' - - pandas>=1,<2 ; extra == 'dev-pandas1' - - setuptools<82 ; extra == 'dev-pandas1' - - pandas>=2,<3 ; extra == 'dev-pandas2' - - pandas>=3 ; python_full_version >= '3.11' and extra == 'dev-pandas3' - - numpy>=1.22 ; extra == 'express' - - kaleido>=1.3.0 ; extra == 'kaleido' + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' + requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl + name: lxml + version: 6.1.1 + sha256: a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099 + requires_dist: + - cssselect>=0.7 ; extra == 'cssselect' + - html5lib ; extra == 'html5' + - beautifulsoup4 ; extra == 'htmlsoup' + - lxml-html-clean ; extra == 'html-clean' requires_python: '>=3.8' +- pypi: https://files.pythonhosted.org/packages/f3/a7/640d6c000c0b51300536e90dfcf9e936b2cbecb259cc82d1ae7b8fc6c7c5/elabapi_python-5.5.11-py3-none-any.whl + name: elabapi-python + version: 5.5.11 + sha256: c1edbfc90ca6185f91bb8ebbdfd04a0574c4686f6dd80737e0579946186e90c6 + requires_dist: + - urllib3>=1.15 + - six>=1.10 + - certifi + - python-dateutil diff --git a/pyproject.toml b/pyproject.toml index 7e9ed608..1f012efc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,14 +11,19 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "astropy>=5,<8", - "click>=8,<9", + "click>=8,<8.4", "frictionless>=5.8.3,<6", "matplotlib>=3.5.0,<4", "pandas>=1.3,<4", "plotly>=5,<7", "pybtex>=0.25,<0.27", + "tomli>=1.0; python_version < '3.11'", ] +[project.optional-dependencies] +elabftw = ["elabapi-python>=5.5.0"] +kadi = ["kadi-apy>=0.50"] + [project.scripts] unitpackage = "unitpackage.entrypoint:cli" @@ -27,6 +32,7 @@ packages = [ "unitpackage", "unitpackage.database", "unitpackage.electrochemistry", + "unitpackage.eln", "unitpackage.loaders", "unitpackage.test", ] @@ -47,9 +53,11 @@ release = ["release"] [tool.pixi.dependencies] astropy = "*" +click = "<8.4" # kadi-apy requirement frictionless = "*" matplotlib-base = "*" pandas = "*" +plotly = "*" pybtex = "*" python = "*" pixi-kernel = "*" @@ -57,6 +65,7 @@ ipykernel = "*" pip = "*" jupyterlab = "*" jupytext = "*" +urllib3 = "*" [tool.pixi.tasks] unitpackage = "unitpackage" @@ -86,8 +95,17 @@ pytest-doctestplus = "*" pytest-remotedata = "*" pytest-env = "*" +[tool.pixi.feature.test.pypi-dependencies] +elabapi-python = ">=5.5.0" +kadi-apy = ">=0.50" + + [tool.pixi.feature.test.tasks] doctest = "pytest -n auto --doctest-modules unitpackage" +test-elabftw = "pytest -m elabftw unitpackage/test/eln/" +record-elabftw-fixtures = "python unitpackage/test/eln/record_fixtures.py elabftw" +test-kadi = "pytest -m kadi unitpackage/test/eln/" +record-kadi-fixtures = "python unitpackage/test/eln/record_fixtures.py kadi" [tool.pixi.feature.lint.dependencies] black = "*" @@ -95,7 +113,7 @@ isort = "*" pylint = "*" [tool.pixi.feature.dev.tasks] -test = "pixi run doctest" +test = { depends-on = ["doctest", "test-elabftw", "test-kadi"] } [tool.pixi.feature.lint.tasks] pylint = "pylint unitpackage" @@ -111,12 +129,13 @@ myst-nb = "*" sphinx = "*" sphinx-design = "*" sphinx_rtd_theme = "*" - -[tool.pixi.feature.doc.pypi-options.dependency-overrides] plotly = ">=5,<6" # workaround for https://github.com/echemdb/unitpackage/issues/100 +[tool.pixi.feature.doc.pypi-dependencies] +unitpackage = { path = ".", editable = true, extras = ["elabftw", "kadi"] } + [tool.pixi.feature.doc.tasks] -doc = "cd doc && make html SPHINXOPTS='-j auto -W -n --keep-going'" +doc = "cd doc && make html SPHINXOPTS='-W -n --keep-going'" linkcheck = "cd doc && make linkcheck" [tool.pixi.feature.dev.dependencies] diff --git a/test/eln-fixtures/elabftw/5.5.14/download_csv.json b/test/eln-fixtures/elabftw/5.5.14/download_csv.json new file mode 100644 index 00000000..e4b96393 --- /dev/null +++ b/test/eln-fixtures/elabftw/5.5.14/download_csv.json @@ -0,0 +1,7 @@ +{ + "operation": "download_csv", + "method": "UploadsApi.read_upload", + "response": { + "data_base64": "dCxFLGoKMC4wLC0wLjEwMyw0My4wMQowLjAxLC0wLjEwMiw1MS40MQowLjAyLC0wLjEwMSw0Mi44OQo=" + } +} \ No newline at end of file diff --git a/test/eln-fixtures/elabftw/5.5.14/fetch_entries.json b/test/eln-fixtures/elabftw/5.5.14/fetch_entries.json new file mode 100644 index 00000000..18fc6936 --- /dev/null +++ b/test/eln-fixtures/elabftw/5.5.14/fetch_entries.json @@ -0,0 +1,7 @@ +{ + "operation": "fetch_entries", + "method": "client.fetch_entries", + "response": { + "count": 1 + } +} \ No newline at end of file diff --git a/test/eln-fixtures/elabftw/5.5.14/get_entity.json b/test/eln-fixtures/elabftw/5.5.14/get_entity.json new file mode 100644 index 00000000..50f49e05 --- /dev/null +++ b/test/eln-fixtures/elabftw/5.5.14/get_entity.json @@ -0,0 +1,17 @@ +{ + "operation": "get_entity", + "method": "ItemsApi.get_item", + "response": { + "headers": {}, + "status": 200, + "version": 11, + "reason": "OK", + "decode_content": true, + "chunked": true, + "enforce_content_length": true, + "auto_close": true, + "msg": null, + "chunk_left": null, + "length_remaining": null + } +} \ No newline at end of file diff --git a/test/eln-fixtures/elabftw/5.5.14/info.json b/test/eln-fixtures/elabftw/5.5.14/info.json new file mode 100644 index 00000000..576511f2 --- /dev/null +++ b/test/eln-fixtures/elabftw/5.5.14/info.json @@ -0,0 +1,20 @@ +{ + "operation": "info", + "method": "client.info", + "response": { + "elabftw_version": "5.5.14", + "elabftw_version_int": 50514, + "ts_balance": 0, + "ts_limit": 0, + "uploads_filesize_sum": 12691951257, + "uploads_filesize_sum_formatted": "11.82 GiB", + "all_users_count": 221, + "active_users_count": 220, + "items_count": 504, + "teams_count": 27, + "compounds_count": 25, + "experiments_count": 1436, + "experiments_timestamped_count": 4, + "entities_timestamped_count_last_30_days": 1 + } +} \ No newline at end of file diff --git a/test/eln-fixtures/elabftw/5.5.14/list_download_csv.json b/test/eln-fixtures/elabftw/5.5.14/list_download_csv.json new file mode 100644 index 00000000..e4b96393 --- /dev/null +++ b/test/eln-fixtures/elabftw/5.5.14/list_download_csv.json @@ -0,0 +1,7 @@ +{ + "operation": "download_csv", + "method": "UploadsApi.read_upload", + "response": { + "data_base64": "dCxFLGoKMC4wLC0wLjEwMyw0My4wMQowLjAxLC0wLjEwMiw1MS40MQowLjAyLC0wLjEwMSw0Mi44OQo=" + } +} \ No newline at end of file diff --git a/test/eln-fixtures/elabftw/5.5.14/list_get_entity.json b/test/eln-fixtures/elabftw/5.5.14/list_get_entity.json new file mode 100644 index 00000000..50f49e05 --- /dev/null +++ b/test/eln-fixtures/elabftw/5.5.14/list_get_entity.json @@ -0,0 +1,17 @@ +{ + "operation": "get_entity", + "method": "ItemsApi.get_item", + "response": { + "headers": {}, + "status": 200, + "version": 11, + "reason": "OK", + "decode_content": true, + "chunked": true, + "enforce_content_length": true, + "auto_close": true, + "msg": null, + "chunk_left": null, + "length_remaining": null + } +} \ No newline at end of file diff --git a/test/eln-fixtures/elabftw/5.5.14/list_list_entities.json b/test/eln-fixtures/elabftw/5.5.14/list_list_entities.json new file mode 100644 index 00000000..49cb74de --- /dev/null +++ b/test/eln-fixtures/elabftw/5.5.14/list_list_entities.json @@ -0,0 +1,17 @@ +{ + "operation": "list_entities", + "method": "ItemsApi.read_items", + "response": { + "headers": {}, + "status": 200, + "version": 11, + "reason": "OK", + "decode_content": true, + "chunked": true, + "enforce_content_length": true, + "auto_close": true, + "msg": null, + "chunk_left": null, + "length_remaining": null + } +} \ No newline at end of file diff --git a/test/eln-fixtures/elabftw/5.5.14/list_list_uploads.json b/test/eln-fixtures/elabftw/5.5.14/list_list_uploads.json new file mode 100644 index 00000000..88ae3c2b --- /dev/null +++ b/test/eln-fixtures/elabftw/5.5.14/list_list_uploads.json @@ -0,0 +1,23 @@ +{ + "operation": "list_uploads", + "method": "UploadsApi.read_uploads", + "response": [ + { + "id": 16678, + "real_name": "fixture_test.csv", + "long_name": "85/85dfe5cb-c805-47b8-9614-095c19946c85.csv", + "comment": "Data uploaded from unitpackage", + "item_id": 505, + "userid": 1, + "type": "items", + "created_at": "2026-06-14 19:25:37", + "hash": "e2660b35fbdf08ca162947aa9fd72fd1d1c6f8e1bd4c6cdb1c52f3e4d87b35e7", + "hash_algorithm": "sha256", + "storage": 1, + "filesize": 59, + "state": 1, + "immutable": 0, + "fullname": "Test User" + } + ] +} \ No newline at end of file diff --git a/test/eln-fixtures/elabftw/5.5.14/list_uploads.json b/test/eln-fixtures/elabftw/5.5.14/list_uploads.json new file mode 100644 index 00000000..88ae3c2b --- /dev/null +++ b/test/eln-fixtures/elabftw/5.5.14/list_uploads.json @@ -0,0 +1,23 @@ +{ + "operation": "list_uploads", + "method": "UploadsApi.read_uploads", + "response": [ + { + "id": 16678, + "real_name": "fixture_test.csv", + "long_name": "85/85dfe5cb-c805-47b8-9614-095c19946c85.csv", + "comment": "Data uploaded from unitpackage", + "item_id": 505, + "userid": 1, + "type": "items", + "created_at": "2026-06-14 19:25:37", + "hash": "e2660b35fbdf08ca162947aa9fd72fd1d1c6f8e1bd4c6cdb1c52f3e4d87b35e7", + "hash_algorithm": "sha256", + "storage": 1, + "filesize": 59, + "state": 1, + "immutable": 0, + "fullname": "Test User" + } + ] +} \ No newline at end of file diff --git a/test/eln-fixtures/elabftw/5.5.14/manifest.json b/test/eln-fixtures/elabftw/5.5.14/manifest.json new file mode 100644 index 00000000..bf0e4573 --- /dev/null +++ b/test/eln-fixtures/elabftw/5.5.14/manifest.json @@ -0,0 +1,21 @@ +{ + "backend": "elabftw", + "version": "5.5.14", + "recorded_at": "2026-06-14T17:25:41.016258+00:00", + "unitpackage_version": "unknown", + "entity_id": 505, + "cleanup_performed": true, + "test_data": { + "csv_columns": [ + "t", + "E", + "j" + ], + "field_units": { + "t": "s", + "E": "V", + "j": "A / m2" + }, + "title": "unitpackage_fixture_test_e03d8b4d" + } +} \ No newline at end of file diff --git a/test/eln-fixtures/elabftw/5.5.14/patch_metadata.json b/test/eln-fixtures/elabftw/5.5.14/patch_metadata.json new file mode 100644 index 00000000..b882491e --- /dev/null +++ b/test/eln-fixtures/elabftw/5.5.14/patch_metadata.json @@ -0,0 +1,154 @@ +{ + "operation": "patch_metadata", + "method": "ItemsApi.patch_item", + "request": { + "entity_id": 505, + "metadata": { + "unitpackage": { + "resources": [ + { + "name": "fixture_test", + "type": "table", + "path": "fixture_test.csv", + "scheme": "file", + "format": "csv", + "mediatype": "text/csv", + "schema": { + "fields": [ + { + "name": "t", + "type": "number", + "unit": "s" + }, + { + "name": "E", + "type": "number", + "unit": "V" + }, + { + "name": "j", + "type": "number", + "unit": "A / m2" + } + ] + }, + "metadata": { + "echemdb": { + "source": { + "citationKey": "fixture_test_2026" + } + } + } + } + ] + } + } + }, + "response": { + "is_bookable": 0, + "canbook_base": 30.0, + "canbook": "{\"teams\": [], \"users\": [], \"teamgroups\": []}", + "book_max_minutes": 0, + "book_max_slots": 0, + "book_can_overlap": 1, + "book_users_can_in_past": 0, + "book_is_cancellable": 1, + "book_cancel_minutes": 0, + "booking_window_days": 0, + "access_key": null, + "body_html": null, + "category_color": null, + "category_title": null, + "changelog": [ + { + "created_at": "2026-06-14 19:25:37", + "target": "metadata", + "content": "{\"unitpackage\": {\"resources\": [{\"name\": \"fixture_test\", \"type\": \"table\", \"path\": \"fixture_test.csv\", \"scheme\": \"file\", \"format\": \"csv\", \"mediatype\": \"text/csv\", \"schema\": {\"fields\": [{\"name\": \"t\", \"type\": \"number\", \"unit\": \"s\"}, {\"name\": \"E\", \"type\": \"number\", \"unit\": \"V\"}, {\"name\": \"j\", \"type\": \"number\", \"unit\": \"A / m2\"}]}, \"metadata\": {\"echemdb\": {\"source\": {\"citationKey\": \"fixture_test_2026\"}}}}]}}", + "fullname": "Test User", + "userid": 1 + }, + { + "created_at": "2026-06-14 19:25:36", + "target": "created", + "content": "Resource was created", + "fullname": "Test User", + "userid": 1 + } + ], + "comments": [], + "compounds_links": [], + "created_at": "2026-06-14 19:25:36", + "created_from_id": null, + "created_from_type": null, + "created_from_type_human": null, + "elabid": "20260614-231788f85c7c5af567dda065064b0c6b9cbc604f", + "exclusive_edit_mode": null, + "experiments_links": [], + "firstname": "Test", + "fullname": "Test User", + "hide_main_text": 0, + "id": 505, + "items_links": [], + "lastchangeby": 1, + "lastname": "User", + "locked": 0, + "lockedby": null, + "locked_at": null, + "modified_at": "2026-06-14 19:25:37", + "next_step": null, + "orcid": null, + "page": "database", + "recent_comment": null, + "related_experiments_links": [], + "related_items_links": [], + "sharelink": "https://elabftw.example.org/database.php?mode=view&id=505", + "status_color": null, + "status_title": null, + "steps": [], + "tags": "unitpackage-test", + "tags_id": "1131", + "team": null, + "timestamped": 0, + "timestampedby": null, + "timestamped_at": null, + "type": "items", + "uploads": [ + { + "id": 16678, + "real_name": "fixture_test.csv", + "long_name": "85/85dfe5cb-c805-47b8-9614-095c19946c85.csv", + "comment": "Data uploaded from unitpackage", + "item_id": 505, + "userid": 1, + "type": "items", + "created_at": "2026-06-14 19:25:37", + "hash": "e2660b35fbdf08ca162947aa9fd72fd1d1c6f8e1bd4c6cdb1c52f3e4d87b35e7", + "hash_algorithm": "sha256", + "storage": 1, + "filesize": 59, + "state": 1, + "immutable": 0, + "fullname": "Test User" + } + ], + "body": null, + "canread_base": 30.0, + "canread": "{\"teams\": [], \"users\": [], \"teamgroups\": []}", + "canwrite_base": 20.0, + "canwrite": "{\"teams\": [], \"users\": [], \"teamgroups\": []}", + "category": null, + "_date": "2026-06-14", + "content_type": 1, + "custom_id": null, + "metadata": { + "elabftw": null, + "extra_fields": null + }, + "rating": 0, + "state": 1, + "status": null, + "title": "unitpackage_fixture_test_e03d8b4d", + "template": -1, + "userid": 1 + } +} \ No newline at end of file diff --git a/test/eln-fixtures/elabftw/5.5.14/post_entity.json b/test/eln-fixtures/elabftw/5.5.14/post_entity.json new file mode 100644 index 00000000..cef7d81e --- /dev/null +++ b/test/eln-fixtures/elabftw/5.5.14/post_entity.json @@ -0,0 +1,17 @@ +{ + "operation": "post_entity", + "method": "ItemsApi.post_item_with_http_info", + "request": { + "body": { + "title": "unitpackage_fixture_test_e03d8b4d", + "tags": [ + "unitpackage-test" + ] + } + }, + "response": { + "status_code": 201, + "location": "https://elabftw.example.org/api/v2/items/505", + "entity_id": 505 + } +} \ No newline at end of file diff --git a/test/eln-fixtures/elabftw/5.5.14/post_upload_csv.json b/test/eln-fixtures/elabftw/5.5.14/post_upload_csv.json new file mode 100644 index 00000000..29a89226 --- /dev/null +++ b/test/eln-fixtures/elabftw/5.5.14/post_upload_csv.json @@ -0,0 +1,10 @@ +{ + "operation": "post_upload_csv", + "method": "UploadsApi.post_upload", + "request": { + "entity_type": "items", + "entity_id": 505, + "comment": "Data uploaded from unitpackage" + }, + "response": null +} \ No newline at end of file diff --git a/test/eln-fixtures/kadi/1.10.0/add_metadatum_datapackage.json b/test/eln-fixtures/kadi/1.10.0/add_metadatum_datapackage.json new file mode 100644 index 00000000..dced5502 --- /dev/null +++ b/test/eln-fixtures/kadi/1.10.0/add_metadatum_datapackage.json @@ -0,0 +1,404 @@ +{ + "operation": "add_metadatum_datapackage", + "method": "record.add_metadatum", + "request": { + "metadatum": { + "type": "dict", + "value": [ + { + "type": "list", + "value": [ + { + "type": "dict", + "value": [ + { + "type": "str", + "value": "fixture_test", + "key": "name" + }, + { + "type": "str", + "value": "table", + "key": "type" + }, + { + "type": "str", + "value": "fixture_test.csv", + "key": "path" + }, + { + "type": "str", + "value": "file", + "key": "scheme" + }, + { + "type": "str", + "value": "csv", + "key": "format" + }, + { + "type": "str", + "value": "text/csv", + "key": "mediatype" + }, + { + "type": "dict", + "value": [ + { + "type": "list", + "value": [ + { + "type": "dict", + "value": [ + { + "type": "str", + "value": "t", + "key": "name" + }, + { + "type": "str", + "value": "number", + "key": "type" + }, + { + "type": "str", + "value": "s", + "key": "unit" + } + ] + }, + { + "type": "dict", + "value": [ + { + "type": "str", + "value": "E", + "key": "name" + }, + { + "type": "str", + "value": "number", + "key": "type" + }, + { + "type": "str", + "value": "V", + "key": "unit" + } + ] + }, + { + "type": "dict", + "value": [ + { + "type": "str", + "value": "j", + "key": "name" + }, + { + "type": "str", + "value": "number", + "key": "type" + }, + { + "type": "str", + "value": "A / m2", + "key": "unit" + } + ] + } + ], + "key": "fields" + } + ], + "key": "schema" + }, + { + "type": "dict", + "value": [ + { + "type": "dict", + "value": [ + { + "type": "dict", + "value": [ + { + "type": "str", + "value": "fixture_test_2026", + "key": "citationKey" + } + ], + "key": "source" + } + ], + "key": "echemdb" + } + ], + "key": "metadata" + } + ] + } + ], + "key": "resources" + } + ], + "key": "unitpackage" + } + }, + "response": { + "status_code": 200, + "headers": {}, + "trailers": {}, + "raw": { + "headers": {}, + "status": 200, + "version": 20, + "reason": "OK", + "decode_content": false, + "chunked": false, + "enforce_content_length": true, + "auto_close": true, + "msg": null, + "chunk_left": null, + "length_remaining": 0 + }, + "url": "https://kadi.example.org/api/v1/records/16", + "encoding": "utf-8", + "history": [], + "reason": "OK", + "cookies": {}, + "elapsed": "0:00:00.141399", + "request": { + "method": "PATCH", + "url": "https://kadi.example.org/api/v1/records/16", + "headers": {}, + "body": "eyJleHRyYXMiOiBbeyJ0eXBlIjogImRpY3QiLCAidmFsdWUiOiBbeyJ0eXBlIjogImxpc3QiLCAidmFsdWUiOiBbeyJ0eXBlIjogImRpY3QiLCAidmFsdWUiOiBbeyJ0eXBlIjogInN0ciIsICJ2YWx1ZSI6ICJmaXh0dXJlX3Rlc3QiLCAia2V5IjogIm5hbWUifSwgeyJ0eXBlIjogInN0ciIsICJ2YWx1ZSI6ICJ0YWJsZSIsICJrZXkiOiAidHlwZSJ9LCB7InR5cGUiOiAic3RyIiwgInZhbHVlIjogImZpeHR1cmVfdGVzdC5jc3YiLCAia2V5IjogInBhdGgifSwgeyJ0eXBlIjogInN0ciIsICJ2YWx1ZSI6ICJmaWxlIiwgImtleSI6ICJzY2hlbWUifSwgeyJ0eXBlIjogInN0ciIsICJ2YWx1ZSI6ICJjc3YiLCAia2V5IjogImZvcm1hdCJ9LCB7InR5cGUiOiAic3RyIiwgInZhbHVlIjogInRleHQvY3N2IiwgImtleSI6ICJtZWRpYXR5cGUifSwgeyJ0eXBlIjogImRpY3QiLCAidmFsdWUiOiBbeyJ0eXBlIjogImxpc3QiLCAidmFsdWUiOiBbeyJ0eXBlIjogImRpY3QiLCAidmFsdWUiOiBbeyJ0eXBlIjogInN0ciIsICJ2YWx1ZSI6ICJ0IiwgImtleSI6ICJuYW1lIn0sIHsidHlwZSI6ICJzdHIiLCAidmFsdWUiOiAibnVtYmVyIiwgImtleSI6ICJ0eXBlIn0sIHsidHlwZSI6ICJzdHIiLCAidmFsdWUiOiAicyIsICJrZXkiOiAidW5pdCJ9XX0sIHsidHlwZSI6ICJkaWN0IiwgInZhbHVlIjogW3sidHlwZSI6ICJzdHIiLCAidmFsdWUiOiAiRSIsICJrZXkiOiAibmFtZSJ9LCB7InR5cGUiOiAic3RyIiwgInZhbHVlIjogIm51bWJlciIsICJrZXkiOiAidHlwZSJ9LCB7InR5cGUiOiAic3RyIiwgInZhbHVlIjogIlYiLCAia2V5IjogInVuaXQifV19LCB7InR5cGUiOiAiZGljdCIsICJ2YWx1ZSI6IFt7InR5cGUiOiAic3RyIiwgInZhbHVlIjogImoiLCAia2V5IjogIm5hbWUifSwgeyJ0eXBlIjogInN0ciIsICJ2YWx1ZSI6ICJudW1iZXIiLCAia2V5IjogInR5cGUifSwgeyJ0eXBlIjogInN0ciIsICJ2YWx1ZSI6ICJBIC8gbTIiLCAia2V5IjogInVuaXQifV19XSwgImtleSI6ICJmaWVsZHMifV0sICJrZXkiOiAic2NoZW1hIn0sIHsidHlwZSI6ICJkaWN0IiwgInZhbHVlIjogW3sidHlwZSI6ICJkaWN0IiwgInZhbHVlIjogW3sidHlwZSI6ICJkaWN0IiwgInZhbHVlIjogW3sidHlwZSI6ICJzdHIiLCAidmFsdWUiOiAiZml4dHVyZV90ZXN0XzIwMjYiLCAia2V5IjogImNpdGF0aW9uS2V5In1dLCAia2V5IjogInNvdXJjZSJ9XSwgImtleSI6ICJlY2hlbWRiIn1dLCAia2V5IjogIm1ldGFkYXRhIn1dfV0sICJrZXkiOiAicmVzb3VyY2VzIn1dLCAia2V5IjogInVuaXRwYWNrYWdlIn1dfQ==", + "hooks": { + "pre_request": [], + "pre_send": [], + "on_upload": [], + "early_response": [], + "response": [] + }, + "conn_info": { + "established_latency": "0:00:00", + "http_version": "HTTP/2.0", + "certificate_der": "MIIDiTCCAxCgAwIBAgISBoU7UM/SnbXcQnh9XBJDpjfUMAoGCCqGSM49BAMDMDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJFODAeFw0yNjA1MDkxNzA5NTdaFw0yNjA4MDcxNzA5NTZaMBoxGDAWBgNVBAMTD2thZGkucGZhdXplbi5kZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKfcHxakzufXS7CBjrwIAYI6/FSbYyTI84NpJwuTDXk5VBzjZPoRx632jB1WGCQE9xVNIBGYC/3gomCRCB3+GiujggIcMIICGDAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUypw1MeLC5l+ZJkxqbqM2jqvsLwUwHwYDVR0jBBgwFoAUjw0TovYuftFQbDMYOF1ZjiNykcowMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAChhZodHRwOi8vZTguaS5sZW5jci5vcmcvMBoGA1UdEQQTMBGCD2thZGkucGZhdXplbi5kZTATBgNVHSAEDDAKMAgGBmeBDAECATAtBgNVHR8EJjAkMCKgIKAehhxodHRwOi8vZTguYy5sZW5jci5vcmcvODUuY3JsMIIBDQYKKwYBBAHWeQIEAgSB/gSB+wD5AHYAyKPEf8ezrbk1awE/anoSbeM6TkOlxkb5l605dZkdz5oAAAGeDe0t7AAABAMARzBFAiA7Bo49yPaXt+80hBGKH9+HMPGAiZXnINuEBw+GX/NDaAIhAL8DEi3vULvHdnXHLVD1gYth5gzZKIVOoNTbyFsQpIPXAH8ARq+GPTs+5Z+ld96oJF02sNntIqIj9GF3QSKUUu6VUF8AAAGeDe0uqgAIAAAFAAYk2i0EAwBIMEYCIQD0ZCvCWVq01NuivW6SSH8eEtthHgFXwWiTsinngDsTHwIhANi/qmX3/VPAFY2xjUcvuYfqwpBZL9yTMpU0QOz75+FjMAoGCCqGSM49BAMDA2cAMGQCMErx+3C3cHK6JdYwrTvI6DfnRiLz4EIgLFwA7364fnyRCdgzarDqvLxQYKaxSxwTYQIwIt1WnblmC9NTxZ0BxB4u4gSaUEe27ttDln+DwZks91rnI6QI8Pbvza5Ra2+keWLZ", + "certificate_dict": { + "subject": [ + [ + [ + "commonName", + "kadi.pfauzen.de" + ] + ] + ], + "issuer": [ + [ + [ + "countryName", + "US" + ] + ], + [ + [ + "organizationName", + "Let's Encrypt" + ] + ], + [ + [ + "commonName", + "E8" + ] + ] + ], + "version": 3, + "serialNumber": "06853B50CFD29DB5DC42787D5C1243A637D4", + "notBefore": "May 9 17:09:57 2026 GMT", + "notAfter": "Aug 7 17:09:56 2026 GMT", + "subjectAltName": [ + [ + "DNS", + "kadi.pfauzen.de" + ] + ], + "caIssuers": [ + "http://e8.i.lencr.org/" + ], + "crlDistributionPoints": [ + "http://e8.c.lencr.org/85.crl" + ] + }, + "issuer_certificate_der": "MIIEVjCCAj6gAwIBAgIQY5WTY8JOcIJxWRi/w9ftVjANBgkqhkiG9w0BAQsFADBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNDAzMTMwMDAwMDBaFw0yNzAzMTIyMzU5NTlaMDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJFODB2MBAGByqGSM49AgEGBSuBBAAiA2IABNFl8l7cS7QMApzSsvru6WyrOq44ofTUOTIzxULUzDMMNMchIJBwXOhiLxxxs0LXeb5GDcHbR6EToMffgSZjO9SNHfY9gjMy9vQr5/WWOrQTZxh7az6NSNnq3u2ubT6HTKOB+DCB9TAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFI8NE6L2Ln7RUGwzGDhdWY4jcpHKMB8GA1UdIwQYMBaAFHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzATBgNVHSAEDDAKMAgGBmeBDAECATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veDEuYy5sZW5jci5vcmcvMA0GCSqGSIb3DQEBCwUAA4ICAQBnE0hGINKsCYWi0Xx1ygxD5qihEjZ0RI3tTZz1wuATH3ZwYPIp97kWEayanD1j0cDhIYzy4CkDo2jB8D5t0a6zZWzlr98dAQFNh8uKJkIHdLShy+nUyeZxc5bNeMp1Lu0gSzE4McqfmNMvIpeiwWSYO9w82Ob8otvXcO2JUYi3svHIWRm3+707DUbL51XMcY2iZdlCq4Wa9nbuk3WTU4gr6LY8MzVAaDQG2+4U3eJ6qUF10bBnR1uuVyDYs9RhrwucRVnfuDj29CMLTsplM5f5wSV5hUpmUwp/vV7M4w4aGunt74koX71n4EdagCsL/Yk5+mAQU0+tue0JOfAV/R6t1k+Xk9s2HMQFeoxppfzAVC04FdG9M+AC2JWxmFSt6BCuh3CEey3fE52Qrj9YM75rtvIjsm/1Hl+u//Wqxnu1ZQ4jpa+VpuZiGOlWrqSP9eogdOhCGisnyewWJwRQOqK16wiGyZeRxs/Bekw65vwSIaVkBruPiTfMOo0Zh4gVa8/qJgMbJbyrwwG97z/PRgmLKCDl8z3dtA0Z7qq7fta0Gl24uyuB05dqI5J1LvAzKuWdIjT1tP8qCoxSE/xpix8hX2dt3h+/jujUgFPFZ0EVZ0xSyBNRF3MboGZnYXFUxpNjTWPKpagDHJQmqrAcDmWJnMsFY3jSu1igv3OefnWjSQ==", + "issuer_certificate_dict": { + "subject": [ + [ + [ + "countryName", + "US" + ] + ], + [ + [ + "organizationName", + "Let's Encrypt" + ] + ], + [ + [ + "commonName", + "E8" + ] + ] + ], + "issuer": [ + [ + [ + "countryName", + "US" + ] + ], + [ + [ + "organizationName", + "Internet Security Research Group" + ] + ], + [ + [ + "commonName", + "ISRG Root X1" + ] + ] + ], + "version": 3, + "serialNumber": "63959363C24E7082715918BFC3D7ED56", + "notBefore": "Mar 13 00:00:00 2024 GMT", + "notAfter": "Mar 12 23:59:59 2027 GMT", + "caIssuers": [ + "http://x1.i.lencr.org/" + ], + "crlDistributionPoints": [ + "http://x1.c.lencr.org/" + ] + }, + "destination_address": [ + "134.60.156.30", + 443 + ], + "cipher": "TLS_AES_256_GCM_SHA384", + "tls_version": 772, + "tls_handshake_latency": "0:00:00", + "resolution_latency": "0:00:00", + "tls_ech_accepted": false, + "request_sent_latency": "0:00:00.000168" + }, + "ocsp_verified": true, + "upload_progress": { + "total": 1291, + "content_length": 1291, + "is_completed": true, + "any_error": false + } + }, + "download_progress": null, + "connection": { + "max_retries": { + "total": 0, + "connect": null, + "read": false, + "status": null, + "other": null, + "redirect": null, + "status_forcelist": "set()", + "allowed_methods": "frozenset({'TRACE', 'DELETE', 'PUT', 'OPTIONS', 'GET', 'HEAD'})", + "backoff_factor": 0, + "backoff_max": 120, + "raise_on_redirect": true, + "raise_on_status": true, + "history": [], + "respect_retry_after_header": true, + "remove_headers_on_redirect": "frozenset({'cookie', 'authorization', 'proxy-authorization'})", + "backoff_jitter": 0.0, + "retry_after_max": 21600 + }, + "config": {}, + "proxy_manager": {}, + "poolmanager": { + "headers": {}, + "connection_pool_kw": { + "maxsize": 10, + "block": false, + "disabled_svn": "set()", + "source_address": null, + "socket_family": 0, + "happy_eyeballs": false, + "keepalive_delay": 600.0, + "keepalive_idle_window": 60.0 + }, + "block": false, + "pools": { + "maxsize": 10, + "concurrency": true, + "strict_maxsize": true, + "parent": null + }, + "pool_classes_by_scheme": { + "http": { + "scheme": "http", + "ConnectionCls": { + "scheme": "http", + "default_port": 80, + "host": "", + "set_tunnel": {}, + "connect": {}, + "is_closed": "", + "is_connected": "", + "has_connected_to_proxy": "", + "proxy_is_forwarding": "", + "proxy_is_tunneling": "", + "close": {}, + "putrequest": {}, + "putheader": {}, + "request": {}, + "getresponse": {} + }, + "is_idle": "", + "is_saturated": "", + "get_response": {}, + "close": {}, + "is_same_host": {}, + "urlopen": {} + }, + "https": { + "scheme": "https", + "ConnectionCls": { + "scheme": "https", + "default_port": 443, + "cert_reqs": null, + "ca_certs": null, + "ca_cert_dir": null, + "ca_cert_data": null, + "ssl_version": null, + "ssl_minimum_version": null, + "ssl_maximum_version": null, + "assert_fingerprint": null, + "cert_file": null, + "key_file": null, + "key_password": null, + "cert_data": null, + "key_data": null, + "ciphers": null, + "connect": {} + } + } + }, + "key_fn_by_scheme": { + "http": {}, + "https": {} + } + } + } + } +} \ No newline at end of file diff --git a/test/eln-fixtures/kadi/1.10.0/create_record.json b/test/eln-fixtures/kadi/1.10.0/create_record.json new file mode 100644 index 00000000..490a56e5 --- /dev/null +++ b/test/eln-fixtures/kadi/1.10.0/create_record.json @@ -0,0 +1,71 @@ +{ + "operation": "create_record", + "method": "KadiManager.record", + "request": { + "identifier": "unitpackage_fixture_test_ac6ed411", + "title": "unitpackage_fixture_test_ac6ed411", + "create": true + }, + "response": { + "_actions": { + "add_group_role": "https://kadi.example.org/api/v1/records/16/roles/groups", + "add_user_role": "https://kadi.example.org/api/v1/records/16/roles/users", + "delete": "https://kadi.example.org/api/v1/records/16", + "edit": "https://kadi.example.org/api/v1/records/16", + "link_collection": "https://kadi.example.org/api/v1/records/16/collections", + "link_record": "https://kadi.example.org/api/v1/records/16/records", + "new_upload": "https://kadi.example.org/api/v1/records/16/uploads" + }, + "_links": { + "collections": "https://kadi.example.org/api/v1/records/16/collections", + "download_files": "https://kadi.example.org/api/v1/records/16/files/download", + "file_revisions": "https://kadi.example.org/api/v1/records/16/files/revisions", + "files": "https://kadi.example.org/api/v1/records/16/files", + "group_roles": "https://kadi.example.org/api/v1/records/16/roles/groups", + "records_from": "https://kadi.example.org/api/v1/records/16/records?direction=in", + "records_to": "https://kadi.example.org/api/v1/records/16/records?direction=out", + "revisions": "https://kadi.example.org/api/v1/records/16/revisions", + "self": "https://kadi.example.org/api/v1/records/16", + "uploads": "https://kadi.example.org/api/v1/records/16/uploads", + "user_roles": "https://kadi.example.org/api/v1/records/16/roles/users", + "view": "https://kadi.example.org/records/16" + }, + "created_at": "2026-06-14T17:25:31.584325+00:00", + "creator": { + "_links": { + "collections": "https://kadi.example.org/api/v1/users/1/collections", + "groups": "https://kadi.example.org/api/v1/users/1/groups", + "identities": "https://kadi.example.org/api/v1/users/1/identities", + "records": "https://kadi.example.org/api/v1/users/1/records", + "self": "https://kadi.example.org/api/v1/users/1", + "templates": "https://kadi.example.org/api/v1/users/1/templates", + "view": "https://kadi.example.org/users/1" + }, + "created_at": "2025-03-22T16:42:38.225687+00:00", + "displayname": "testuser", + "id": 1, + "identity": { + "email": "test@example.org", + "email_confirmed": false, + "type": "local", + "username": "testuser" + }, + "is_sysadmin": true, + "orcid": null, + "state": "active", + "system_role": "member" + }, + "description": "", + "extras": [], + "id": 16, + "identifier": "unitpackage_fixture_test_ac6ed411", + "last_modified": "2026-06-14T17:25:31.722148+00:00", + "license": null, + "plain_description": "", + "state": "active", + "tags": [], + "title": "unitpackage_fixture_test_ac6ed411", + "type": null, + "visibility": "private" + } +} \ No newline at end of file diff --git a/test/eln-fixtures/kadi/1.10.0/download_csv.json b/test/eln-fixtures/kadi/1.10.0/download_csv.json new file mode 100644 index 00000000..04ec2afa --- /dev/null +++ b/test/eln-fixtures/kadi/1.10.0/download_csv.json @@ -0,0 +1,12 @@ +{ + "operation": "download_csv", + "method": "record.download_file", + "response": { + "args": [], + "kwargs": { + "file_id": "4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988", + "file_path": "/tmp/tmpacdaiu6c/fixture_test.csv" + }, + "data_base64": "dCxFLGoKMC4wLC0wLjEwMyw0My4wMQowLjAxLC0wLjEwMiw1MS40MQowLjAyLC0wLjEwMSw0Mi44OQo=" + } +} \ No newline at end of file diff --git a/test/eln-fixtures/kadi/1.10.0/fetch_entries.json b/test/eln-fixtures/kadi/1.10.0/fetch_entries.json new file mode 100644 index 00000000..18fc6936 --- /dev/null +++ b/test/eln-fixtures/kadi/1.10.0/fetch_entries.json @@ -0,0 +1,7 @@ +{ + "operation": "fetch_entries", + "method": "client.fetch_entries", + "response": { + "count": 1 + } +} \ No newline at end of file diff --git a/test/eln-fixtures/kadi/1.10.0/get_entity.json b/test/eln-fixtures/kadi/1.10.0/get_entity.json new file mode 100644 index 00000000..0ec5f06e --- /dev/null +++ b/test/eln-fixtures/kadi/1.10.0/get_entity.json @@ -0,0 +1,211 @@ +{ + "operation": "get_entity", + "method": "KadiManager.record", + "response": { + "_actions": { + "add_group_role": "https://kadi.example.org/api/v1/records/16/roles/groups", + "add_user_role": "https://kadi.example.org/api/v1/records/16/roles/users", + "delete": "https://kadi.example.org/api/v1/records/16", + "edit": "https://kadi.example.org/api/v1/records/16", + "link_collection": "https://kadi.example.org/api/v1/records/16/collections", + "link_record": "https://kadi.example.org/api/v1/records/16/records", + "new_upload": "https://kadi.example.org/api/v1/records/16/uploads" + }, + "_links": { + "collections": "https://kadi.example.org/api/v1/records/16/collections", + "download_files": "https://kadi.example.org/api/v1/records/16/files/download", + "file_revisions": "https://kadi.example.org/api/v1/records/16/files/revisions", + "files": "https://kadi.example.org/api/v1/records/16/files", + "group_roles": "https://kadi.example.org/api/v1/records/16/roles/groups", + "records_from": "https://kadi.example.org/api/v1/records/16/records?direction=in", + "records_to": "https://kadi.example.org/api/v1/records/16/records?direction=out", + "revisions": "https://kadi.example.org/api/v1/records/16/revisions", + "self": "https://kadi.example.org/api/v1/records/16", + "uploads": "https://kadi.example.org/api/v1/records/16/uploads", + "user_roles": "https://kadi.example.org/api/v1/records/16/roles/users", + "view": "https://kadi.example.org/records/16" + }, + "created_at": "2026-06-14T17:25:31.584325+00:00", + "creator": { + "_links": { + "collections": "https://kadi.example.org/api/v1/users/1/collections", + "groups": "https://kadi.example.org/api/v1/users/1/groups", + "identities": "https://kadi.example.org/api/v1/users/1/identities", + "records": "https://kadi.example.org/api/v1/users/1/records", + "self": "https://kadi.example.org/api/v1/users/1", + "templates": "https://kadi.example.org/api/v1/users/1/templates", + "view": "https://kadi.example.org/users/1" + }, + "created_at": "2025-03-22T16:42:38.225687+00:00", + "displayname": "testuser", + "id": 1, + "identity": { + "email": "test@example.org", + "email_confirmed": false, + "type": "local", + "username": "testuser" + }, + "is_sysadmin": true, + "orcid": null, + "state": "active", + "system_role": "member" + }, + "description": "", + "extras": [ + { + "key": "unitpackage", + "type": "dict", + "value": [ + { + "key": "resources", + "type": "list", + "value": [ + { + "type": "dict", + "value": [ + { + "key": "name", + "type": "str", + "value": "fixture_test" + }, + { + "key": "type", + "type": "str", + "value": "table" + }, + { + "key": "path", + "type": "str", + "value": "fixture_test.csv" + }, + { + "key": "scheme", + "type": "str", + "value": "file" + }, + { + "key": "format", + "type": "str", + "value": "csv" + }, + { + "key": "mediatype", + "type": "str", + "value": "text/csv" + }, + { + "key": "schema", + "type": "dict", + "value": [ + { + "key": "fields", + "type": "list", + "value": [ + { + "type": "dict", + "value": [ + { + "key": "name", + "type": "str", + "value": "t" + }, + { + "key": "type", + "type": "str", + "value": "number" + }, + { + "key": "unit", + "type": "str", + "value": "s" + } + ] + }, + { + "type": "dict", + "value": [ + { + "key": "name", + "type": "str", + "value": "E" + }, + { + "key": "type", + "type": "str", + "value": "number" + }, + { + "key": "unit", + "type": "str", + "value": "V" + } + ] + }, + { + "type": "dict", + "value": [ + { + "key": "name", + "type": "str", + "value": "j" + }, + { + "key": "type", + "type": "str", + "value": "number" + }, + { + "key": "unit", + "type": "str", + "value": "A / m2" + } + ] + } + ] + } + ] + }, + { + "key": "metadata", + "type": "dict", + "value": [ + { + "key": "echemdb", + "type": "dict", + "value": [ + { + "key": "source", + "type": "dict", + "value": [ + { + "key": "citationKey", + "type": "str", + "value": "fixture_test_2026" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "id": 16, + "identifier": "unitpackage_fixture_test_ac6ed411", + "last_modified": "2026-06-14T17:25:33.270828+00:00", + "license": null, + "plain_description": "", + "state": "active", + "tags": [ + "unitpackage-test" + ], + "title": "unitpackage_fixture_test_ac6ed411", + "type": null, + "visibility": "private" + } +} \ No newline at end of file diff --git a/test/eln-fixtures/kadi/1.10.0/info.json b/test/eln-fixtures/kadi/1.10.0/info.json new file mode 100644 index 00000000..4bf500d1 --- /dev/null +++ b/test/eln-fixtures/kadi/1.10.0/info.json @@ -0,0 +1,7 @@ +{ + "operation": "info", + "method": "client.info", + "response": { + "version": "1.10.0" + } +} \ No newline at end of file diff --git a/test/eln-fixtures/kadi/1.10.0/list_download_csv.json b/test/eln-fixtures/kadi/1.10.0/list_download_csv.json new file mode 100644 index 00000000..ef5546af --- /dev/null +++ b/test/eln-fixtures/kadi/1.10.0/list_download_csv.json @@ -0,0 +1,12 @@ +{ + "operation": "download_csv", + "method": "record.download_file", + "response": { + "args": [], + "kwargs": { + "file_id": "4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988", + "file_path": "/tmp/tmpa_fakiae/fixture_test.csv" + }, + "data_base64": "dCxFLGoKMC4wLC0wLjEwMyw0My4wMQowLjAxLC0wLjEwMiw1MS40MQowLjAyLC0wLjEwMSw0Mi44OQo=" + } +} \ No newline at end of file diff --git a/test/eln-fixtures/kadi/1.10.0/list_get_entity.json b/test/eln-fixtures/kadi/1.10.0/list_get_entity.json new file mode 100644 index 00000000..0ec5f06e --- /dev/null +++ b/test/eln-fixtures/kadi/1.10.0/list_get_entity.json @@ -0,0 +1,211 @@ +{ + "operation": "get_entity", + "method": "KadiManager.record", + "response": { + "_actions": { + "add_group_role": "https://kadi.example.org/api/v1/records/16/roles/groups", + "add_user_role": "https://kadi.example.org/api/v1/records/16/roles/users", + "delete": "https://kadi.example.org/api/v1/records/16", + "edit": "https://kadi.example.org/api/v1/records/16", + "link_collection": "https://kadi.example.org/api/v1/records/16/collections", + "link_record": "https://kadi.example.org/api/v1/records/16/records", + "new_upload": "https://kadi.example.org/api/v1/records/16/uploads" + }, + "_links": { + "collections": "https://kadi.example.org/api/v1/records/16/collections", + "download_files": "https://kadi.example.org/api/v1/records/16/files/download", + "file_revisions": "https://kadi.example.org/api/v1/records/16/files/revisions", + "files": "https://kadi.example.org/api/v1/records/16/files", + "group_roles": "https://kadi.example.org/api/v1/records/16/roles/groups", + "records_from": "https://kadi.example.org/api/v1/records/16/records?direction=in", + "records_to": "https://kadi.example.org/api/v1/records/16/records?direction=out", + "revisions": "https://kadi.example.org/api/v1/records/16/revisions", + "self": "https://kadi.example.org/api/v1/records/16", + "uploads": "https://kadi.example.org/api/v1/records/16/uploads", + "user_roles": "https://kadi.example.org/api/v1/records/16/roles/users", + "view": "https://kadi.example.org/records/16" + }, + "created_at": "2026-06-14T17:25:31.584325+00:00", + "creator": { + "_links": { + "collections": "https://kadi.example.org/api/v1/users/1/collections", + "groups": "https://kadi.example.org/api/v1/users/1/groups", + "identities": "https://kadi.example.org/api/v1/users/1/identities", + "records": "https://kadi.example.org/api/v1/users/1/records", + "self": "https://kadi.example.org/api/v1/users/1", + "templates": "https://kadi.example.org/api/v1/users/1/templates", + "view": "https://kadi.example.org/users/1" + }, + "created_at": "2025-03-22T16:42:38.225687+00:00", + "displayname": "testuser", + "id": 1, + "identity": { + "email": "test@example.org", + "email_confirmed": false, + "type": "local", + "username": "testuser" + }, + "is_sysadmin": true, + "orcid": null, + "state": "active", + "system_role": "member" + }, + "description": "", + "extras": [ + { + "key": "unitpackage", + "type": "dict", + "value": [ + { + "key": "resources", + "type": "list", + "value": [ + { + "type": "dict", + "value": [ + { + "key": "name", + "type": "str", + "value": "fixture_test" + }, + { + "key": "type", + "type": "str", + "value": "table" + }, + { + "key": "path", + "type": "str", + "value": "fixture_test.csv" + }, + { + "key": "scheme", + "type": "str", + "value": "file" + }, + { + "key": "format", + "type": "str", + "value": "csv" + }, + { + "key": "mediatype", + "type": "str", + "value": "text/csv" + }, + { + "key": "schema", + "type": "dict", + "value": [ + { + "key": "fields", + "type": "list", + "value": [ + { + "type": "dict", + "value": [ + { + "key": "name", + "type": "str", + "value": "t" + }, + { + "key": "type", + "type": "str", + "value": "number" + }, + { + "key": "unit", + "type": "str", + "value": "s" + } + ] + }, + { + "type": "dict", + "value": [ + { + "key": "name", + "type": "str", + "value": "E" + }, + { + "key": "type", + "type": "str", + "value": "number" + }, + { + "key": "unit", + "type": "str", + "value": "V" + } + ] + }, + { + "type": "dict", + "value": [ + { + "key": "name", + "type": "str", + "value": "j" + }, + { + "key": "type", + "type": "str", + "value": "number" + }, + { + "key": "unit", + "type": "str", + "value": "A / m2" + } + ] + } + ] + } + ] + }, + { + "key": "metadata", + "type": "dict", + "value": [ + { + "key": "echemdb", + "type": "dict", + "value": [ + { + "key": "source", + "type": "dict", + "value": [ + { + "key": "citationKey", + "type": "str", + "value": "fixture_test_2026" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "id": 16, + "identifier": "unitpackage_fixture_test_ac6ed411", + "last_modified": "2026-06-14T17:25:33.270828+00:00", + "license": null, + "plain_description": "", + "state": "active", + "tags": [ + "unitpackage-test" + ], + "title": "unitpackage_fixture_test_ac6ed411", + "type": null, + "visibility": "private" + } +} \ No newline at end of file diff --git a/test/eln-fixtures/kadi/1.10.0/list_list_entities.json b/test/eln-fixtures/kadi/1.10.0/list_list_entities.json new file mode 100644 index 00000000..1d7d9e99 --- /dev/null +++ b/test/eln-fixtures/kadi/1.10.0/list_list_entities.json @@ -0,0 +1,227 @@ +{ + "operation": "list_entities", + "method": "search.search_resources", + "response": { + "_actions": { + "new_record": "https://kadi.example.org/api/v1/records" + }, + "_pagination": { + "_links": { + "self": "https://kadi.example.org/api/v1/records?page=1&per_page=100&query=&extras=%5B%5D&sort=_score&visibility=&explicit_permissions=False&child_collections=False&tag_operator=or&instance=" + }, + "page": 1, + "per_page": 100, + "total_items": 1, + "total_pages": 1 + }, + "items": [ + { + "_actions": { + "add_group_role": "https://kadi.example.org/api/v1/records/16/roles/groups", + "add_user_role": "https://kadi.example.org/api/v1/records/16/roles/users", + "delete": "https://kadi.example.org/api/v1/records/16", + "edit": "https://kadi.example.org/api/v1/records/16", + "link_collection": "https://kadi.example.org/api/v1/records/16/collections", + "link_record": "https://kadi.example.org/api/v1/records/16/records", + "new_upload": "https://kadi.example.org/api/v1/records/16/uploads" + }, + "_links": { + "collections": "https://kadi.example.org/api/v1/records/16/collections", + "download_files": "https://kadi.example.org/api/v1/records/16/files/download", + "file_revisions": "https://kadi.example.org/api/v1/records/16/files/revisions", + "files": "https://kadi.example.org/api/v1/records/16/files", + "group_roles": "https://kadi.example.org/api/v1/records/16/roles/groups", + "records_from": "https://kadi.example.org/api/v1/records/16/records?direction=in", + "records_to": "https://kadi.example.org/api/v1/records/16/records?direction=out", + "revisions": "https://kadi.example.org/api/v1/records/16/revisions", + "self": "https://kadi.example.org/api/v1/records/16", + "uploads": "https://kadi.example.org/api/v1/records/16/uploads", + "user_roles": "https://kadi.example.org/api/v1/records/16/roles/users", + "view": "https://kadi.example.org/records/16" + }, + "created_at": "2026-06-14T17:25:31.584325+00:00", + "creator": { + "_links": { + "collections": "https://kadi.example.org/api/v1/users/1/collections", + "groups": "https://kadi.example.org/api/v1/users/1/groups", + "identities": "https://kadi.example.org/api/v1/users/1/identities", + "records": "https://kadi.example.org/api/v1/users/1/records", + "self": "https://kadi.example.org/api/v1/users/1", + "templates": "https://kadi.example.org/api/v1/users/1/templates", + "view": "https://kadi.example.org/users/1" + }, + "created_at": "2025-03-22T16:42:38.225687+00:00", + "displayname": "testuser", + "id": 1, + "identity": { + "email": "test@example.org", + "email_confirmed": false, + "type": "local", + "username": "testuser" + }, + "is_sysadmin": true, + "orcid": null, + "state": "active", + "system_role": "member" + }, + "description": "", + "extras": [ + { + "key": "unitpackage", + "type": "dict", + "value": [ + { + "key": "resources", + "type": "list", + "value": [ + { + "type": "dict", + "value": [ + { + "key": "name", + "type": "str", + "value": "fixture_test" + }, + { + "key": "type", + "type": "str", + "value": "table" + }, + { + "key": "path", + "type": "str", + "value": "fixture_test.csv" + }, + { + "key": "scheme", + "type": "str", + "value": "file" + }, + { + "key": "format", + "type": "str", + "value": "csv" + }, + { + "key": "mediatype", + "type": "str", + "value": "text/csv" + }, + { + "key": "schema", + "type": "dict", + "value": [ + { + "key": "fields", + "type": "list", + "value": [ + { + "type": "dict", + "value": [ + { + "key": "name", + "type": "str", + "value": "t" + }, + { + "key": "type", + "type": "str", + "value": "number" + }, + { + "key": "unit", + "type": "str", + "value": "s" + } + ] + }, + { + "type": "dict", + "value": [ + { + "key": "name", + "type": "str", + "value": "E" + }, + { + "key": "type", + "type": "str", + "value": "number" + }, + { + "key": "unit", + "type": "str", + "value": "V" + } + ] + }, + { + "type": "dict", + "value": [ + { + "key": "name", + "type": "str", + "value": "j" + }, + { + "key": "type", + "type": "str", + "value": "number" + }, + { + "key": "unit", + "type": "str", + "value": "A / m2" + } + ] + } + ] + } + ] + }, + { + "key": "metadata", + "type": "dict", + "value": [ + { + "key": "echemdb", + "type": "dict", + "value": [ + { + "key": "source", + "type": "dict", + "value": [ + { + "key": "citationKey", + "type": "str", + "value": "fixture_test_2026" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], + "id": 16, + "identifier": "unitpackage_fixture_test_ac6ed411", + "last_modified": "2026-06-14T17:25:33.270828+00:00", + "license": null, + "plain_description": "", + "state": "active", + "tags": [ + "unitpackage-test" + ], + "title": "unitpackage_fixture_test_ac6ed411", + "type": null, + "visibility": "private" + } + ] + } +} \ No newline at end of file diff --git a/test/eln-fixtures/kadi/1.10.0/list_list_uploads.json b/test/eln-fixtures/kadi/1.10.0/list_list_uploads.json new file mode 100644 index 00000000..c257b65c --- /dev/null +++ b/test/eln-fixtures/kadi/1.10.0/list_list_uploads.json @@ -0,0 +1,65 @@ +{ + "operation": "list_uploads", + "method": "record.get_filelist", + "response": { + "_pagination": { + "_links": { + "self": "https://kadi.example.org/api/v1/records/16/files?page=1&per_page=10&filter=&sort=-last_modified" + }, + "page": 1, + "per_page": 10, + "total_items": 1, + "total_pages": 1 + }, + "items": [ + { + "_actions": { + "delete": "https://kadi.example.org/api/v1/records/16/files/4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988", + "edit_data": "https://kadi.example.org/api/v1/records/16/files/4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988", + "edit_metadata": "https://kadi.example.org/api/v1/records/16/files/4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988" + }, + "_links": { + "download": "https://kadi.example.org/api/v1/records/16/files/4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988/download", + "record": "https://kadi.example.org/api/v1/records/16", + "self": "https://kadi.example.org/api/v1/records/16/files/4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988", + "view": "https://kadi.example.org/records/16/files/4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988" + }, + "checksum": "cb7194421998a8808747d8f2adb2fd5e", + "created_at": "2026-06-14T17:25:32.856071+00:00", + "creator": { + "_links": { + "collections": "https://kadi.example.org/api/v1/users/1/collections", + "groups": "https://kadi.example.org/api/v1/users/1/groups", + "identities": "https://kadi.example.org/api/v1/users/1/identities", + "records": "https://kadi.example.org/api/v1/users/1/records", + "self": "https://kadi.example.org/api/v1/users/1", + "templates": "https://kadi.example.org/api/v1/users/1/templates", + "view": "https://kadi.example.org/users/1" + }, + "created_at": "2025-03-22T16:42:38.225687+00:00", + "displayname": "testuser", + "id": 1, + "identity": { + "email": "test@example.org", + "email_confirmed": false, + "type": "local", + "username": "testuser" + }, + "is_sysadmin": true, + "orcid": null, + "state": "active", + "system_role": "member" + }, + "description": "", + "id": "4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988", + "last_modified": "2026-06-14T17:25:32.919731+00:00", + "magic_mimetype": "text/csv", + "mimetype": "text/csv", + "name": "fixture_test.csv", + "size": 59, + "state": "active", + "storage_type": "local" + } + ] + } +} \ No newline at end of file diff --git a/test/eln-fixtures/kadi/1.10.0/list_uploads.json b/test/eln-fixtures/kadi/1.10.0/list_uploads.json new file mode 100644 index 00000000..c257b65c --- /dev/null +++ b/test/eln-fixtures/kadi/1.10.0/list_uploads.json @@ -0,0 +1,65 @@ +{ + "operation": "list_uploads", + "method": "record.get_filelist", + "response": { + "_pagination": { + "_links": { + "self": "https://kadi.example.org/api/v1/records/16/files?page=1&per_page=10&filter=&sort=-last_modified" + }, + "page": 1, + "per_page": 10, + "total_items": 1, + "total_pages": 1 + }, + "items": [ + { + "_actions": { + "delete": "https://kadi.example.org/api/v1/records/16/files/4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988", + "edit_data": "https://kadi.example.org/api/v1/records/16/files/4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988", + "edit_metadata": "https://kadi.example.org/api/v1/records/16/files/4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988" + }, + "_links": { + "download": "https://kadi.example.org/api/v1/records/16/files/4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988/download", + "record": "https://kadi.example.org/api/v1/records/16", + "self": "https://kadi.example.org/api/v1/records/16/files/4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988", + "view": "https://kadi.example.org/records/16/files/4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988" + }, + "checksum": "cb7194421998a8808747d8f2adb2fd5e", + "created_at": "2026-06-14T17:25:32.856071+00:00", + "creator": { + "_links": { + "collections": "https://kadi.example.org/api/v1/users/1/collections", + "groups": "https://kadi.example.org/api/v1/users/1/groups", + "identities": "https://kadi.example.org/api/v1/users/1/identities", + "records": "https://kadi.example.org/api/v1/users/1/records", + "self": "https://kadi.example.org/api/v1/users/1", + "templates": "https://kadi.example.org/api/v1/users/1/templates", + "view": "https://kadi.example.org/users/1" + }, + "created_at": "2025-03-22T16:42:38.225687+00:00", + "displayname": "testuser", + "id": 1, + "identity": { + "email": "test@example.org", + "email_confirmed": false, + "type": "local", + "username": "testuser" + }, + "is_sysadmin": true, + "orcid": null, + "state": "active", + "system_role": "member" + }, + "description": "", + "id": "4eac53c9-3b53-4fb1-96b5-7d9cbeeb1988", + "last_modified": "2026-06-14T17:25:32.919731+00:00", + "magic_mimetype": "text/csv", + "mimetype": "text/csv", + "name": "fixture_test.csv", + "size": 59, + "state": "active", + "storage_type": "local" + } + ] + } +} \ No newline at end of file diff --git a/test/eln-fixtures/kadi/1.10.0/manifest.json b/test/eln-fixtures/kadi/1.10.0/manifest.json new file mode 100644 index 00000000..6f9aa954 --- /dev/null +++ b/test/eln-fixtures/kadi/1.10.0/manifest.json @@ -0,0 +1,21 @@ +{ + "backend": "kadi", + "version": "1.10.0", + "recorded_at": "2026-06-14T17:25:34.154873+00:00", + "unitpackage_version": "unknown", + "entity_id": 16, + "cleanup_performed": true, + "test_data": { + "csv_columns": [ + "t", + "E", + "j" + ], + "field_units": { + "t": "s", + "E": "V", + "j": "A / m2" + }, + "title": "unitpackage_fixture_test_ac6ed411" + } +} \ No newline at end of file diff --git a/test/eln-fixtures/kadi/1.10.0/upload_file_csv.json b/test/eln-fixtures/kadi/1.10.0/upload_file_csv.json new file mode 100644 index 00000000..ef16557e --- /dev/null +++ b/test/eln-fixtures/kadi/1.10.0/upload_file_csv.json @@ -0,0 +1,263 @@ +{ + "operation": "upload_file_csv", + "method": "record.upload_file", + "request": { + "file_path": "fixture_test.csv" + }, + "response": { + "status_code": 201, + "headers": {}, + "trailers": {}, + "raw": { + "headers": {}, + "status": 201, + "version": 20, + "reason": "Created", + "decode_content": false, + "chunked": false, + "enforce_content_length": true, + "auto_close": true, + "msg": null, + "chunk_left": null, + "length_remaining": 0 + }, + "url": "https://kadi.example.org/api/v1/records/16/uploads/f5ae7807-bd3f-4f7f-98b7-71d670a45936", + "encoding": "utf-8", + "history": [], + "reason": "Created", + "cookies": {}, + "elapsed": "0:00:00.428971", + "request": { + "method": "PUT", + "url": "https://kadi.example.org/api/v1/records/16/uploads/f5ae7807-bd3f-4f7f-98b7-71d670a45936", + "headers": {}, + "body": {}, + "hooks": { + "pre_request": [], + "pre_send": [], + "on_upload": [], + "early_response": [], + "response": [] + }, + "conn_info": { + "established_latency": "0:00:00", + "http_version": "HTTP/2.0", + "certificate_der": "MIIDiTCCAxCgAwIBAgISBoU7UM/SnbXcQnh9XBJDpjfUMAoGCCqGSM49BAMDMDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJFODAeFw0yNjA1MDkxNzA5NTdaFw0yNjA4MDcxNzA5NTZaMBoxGDAWBgNVBAMTD2thZGkucGZhdXplbi5kZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABKfcHxakzufXS7CBjrwIAYI6/FSbYyTI84NpJwuTDXk5VBzjZPoRx632jB1WGCQE9xVNIBGYC/3gomCRCB3+GiujggIcMIICGDAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUypw1MeLC5l+ZJkxqbqM2jqvsLwUwHwYDVR0jBBgwFoAUjw0TovYuftFQbDMYOF1ZjiNykcowMgYIKwYBBQUHAQEEJjAkMCIGCCsGAQUFBzAChhZodHRwOi8vZTguaS5sZW5jci5vcmcvMBoGA1UdEQQTMBGCD2thZGkucGZhdXplbi5kZTATBgNVHSAEDDAKMAgGBmeBDAECATAtBgNVHR8EJjAkMCKgIKAehhxodHRwOi8vZTguYy5sZW5jci5vcmcvODUuY3JsMIIBDQYKKwYBBAHWeQIEAgSB/gSB+wD5AHYAyKPEf8ezrbk1awE/anoSbeM6TkOlxkb5l605dZkdz5oAAAGeDe0t7AAABAMARzBFAiA7Bo49yPaXt+80hBGKH9+HMPGAiZXnINuEBw+GX/NDaAIhAL8DEi3vULvHdnXHLVD1gYth5gzZKIVOoNTbyFsQpIPXAH8ARq+GPTs+5Z+ld96oJF02sNntIqIj9GF3QSKUUu6VUF8AAAGeDe0uqgAIAAAFAAYk2i0EAwBIMEYCIQD0ZCvCWVq01NuivW6SSH8eEtthHgFXwWiTsinngDsTHwIhANi/qmX3/VPAFY2xjUcvuYfqwpBZL9yTMpU0QOz75+FjMAoGCCqGSM49BAMDA2cAMGQCMErx+3C3cHK6JdYwrTvI6DfnRiLz4EIgLFwA7364fnyRCdgzarDqvLxQYKaxSxwTYQIwIt1WnblmC9NTxZ0BxB4u4gSaUEe27ttDln+DwZks91rnI6QI8Pbvza5Ra2+keWLZ", + "certificate_dict": { + "subject": [ + [ + [ + "commonName", + "kadi.pfauzen.de" + ] + ] + ], + "issuer": [ + [ + [ + "countryName", + "US" + ] + ], + [ + [ + "organizationName", + "Let's Encrypt" + ] + ], + [ + [ + "commonName", + "E8" + ] + ] + ], + "version": 3, + "serialNumber": "06853B50CFD29DB5DC42787D5C1243A637D4", + "notBefore": "May 9 17:09:57 2026 GMT", + "notAfter": "Aug 7 17:09:56 2026 GMT", + "subjectAltName": [ + [ + "DNS", + "kadi.pfauzen.de" + ] + ], + "caIssuers": [ + "http://e8.i.lencr.org/" + ], + "crlDistributionPoints": [ + "http://e8.c.lencr.org/85.crl" + ] + }, + "issuer_certificate_der": "MIIEVjCCAj6gAwIBAgIQY5WTY8JOcIJxWRi/w9ftVjANBgkqhkiG9w0BAQsFADBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTAeFw0yNDAzMTMwMDAwMDBaFw0yNzAzMTIyMzU5NTlaMDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQDEwJFODB2MBAGByqGSM49AgEGBSuBBAAiA2IABNFl8l7cS7QMApzSsvru6WyrOq44ofTUOTIzxULUzDMMNMchIJBwXOhiLxxxs0LXeb5GDcHbR6EToMffgSZjO9SNHfY9gjMy9vQr5/WWOrQTZxh7az6NSNnq3u2ubT6HTKOB+DCB9TAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFI8NE6L2Ln7RUGwzGDhdWY4jcpHKMB8GA1UdIwQYMBaAFHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcwAoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzATBgNVHSAEDDAKMAgGBmeBDAECATAnBgNVHR8EIDAeMBygGqAYhhZodHRwOi8veDEuYy5sZW5jci5vcmcvMA0GCSqGSIb3DQEBCwUAA4ICAQBnE0hGINKsCYWi0Xx1ygxD5qihEjZ0RI3tTZz1wuATH3ZwYPIp97kWEayanD1j0cDhIYzy4CkDo2jB8D5t0a6zZWzlr98dAQFNh8uKJkIHdLShy+nUyeZxc5bNeMp1Lu0gSzE4McqfmNMvIpeiwWSYO9w82Ob8otvXcO2JUYi3svHIWRm3+707DUbL51XMcY2iZdlCq4Wa9nbuk3WTU4gr6LY8MzVAaDQG2+4U3eJ6qUF10bBnR1uuVyDYs9RhrwucRVnfuDj29CMLTsplM5f5wSV5hUpmUwp/vV7M4w4aGunt74koX71n4EdagCsL/Yk5+mAQU0+tue0JOfAV/R6t1k+Xk9s2HMQFeoxppfzAVC04FdG9M+AC2JWxmFSt6BCuh3CEey3fE52Qrj9YM75rtvIjsm/1Hl+u//Wqxnu1ZQ4jpa+VpuZiGOlWrqSP9eogdOhCGisnyewWJwRQOqK16wiGyZeRxs/Bekw65vwSIaVkBruPiTfMOo0Zh4gVa8/qJgMbJbyrwwG97z/PRgmLKCDl8z3dtA0Z7qq7fta0Gl24uyuB05dqI5J1LvAzKuWdIjT1tP8qCoxSE/xpix8hX2dt3h+/jujUgFPFZ0EVZ0xSyBNRF3MboGZnYXFUxpNjTWPKpagDHJQmqrAcDmWJnMsFY3jSu1igv3OefnWjSQ==", + "issuer_certificate_dict": { + "subject": [ + [ + [ + "countryName", + "US" + ] + ], + [ + [ + "organizationName", + "Let's Encrypt" + ] + ], + [ + [ + "commonName", + "E8" + ] + ] + ], + "issuer": [ + [ + [ + "countryName", + "US" + ] + ], + [ + [ + "organizationName", + "Internet Security Research Group" + ] + ], + [ + [ + "commonName", + "ISRG Root X1" + ] + ] + ], + "version": 3, + "serialNumber": "63959363C24E7082715918BFC3D7ED56", + "notBefore": "Mar 13 00:00:00 2024 GMT", + "notAfter": "Mar 12 23:59:59 2027 GMT", + "caIssuers": [ + "http://x1.i.lencr.org/" + ], + "crlDistributionPoints": [ + "http://x1.c.lencr.org/" + ] + }, + "destination_address": [ + "134.60.156.30", + 443 + ], + "cipher": "TLS_AES_256_GCM_SHA384", + "tls_version": 772, + "tls_handshake_latency": "0:00:00", + "resolution_latency": "0:00:00", + "tls_ech_accepted": false, + "request_sent_latency": "0:00:00.000183" + }, + "ocsp_verified": true, + "upload_progress": { + "total": 59, + "content_length": null, + "is_completed": true, + "any_error": false + } + }, + "download_progress": null, + "connection": { + "max_retries": { + "total": 0, + "connect": null, + "read": false, + "status": null, + "other": null, + "redirect": null, + "status_forcelist": "set()", + "allowed_methods": "frozenset({'TRACE', 'DELETE', 'PUT', 'OPTIONS', 'GET', 'HEAD'})", + "backoff_factor": 0, + "backoff_max": 120, + "raise_on_redirect": true, + "raise_on_status": true, + "history": [], + "respect_retry_after_header": true, + "remove_headers_on_redirect": "frozenset({'cookie', 'authorization', 'proxy-authorization'})", + "backoff_jitter": 0.0, + "retry_after_max": 21600 + }, + "config": {}, + "proxy_manager": {}, + "poolmanager": { + "headers": {}, + "connection_pool_kw": { + "maxsize": 10, + "block": false, + "disabled_svn": "set()", + "source_address": null, + "socket_family": 0, + "happy_eyeballs": false, + "keepalive_delay": 600.0, + "keepalive_idle_window": 60.0 + }, + "block": false, + "pools": { + "maxsize": 10, + "concurrency": true, + "strict_maxsize": true, + "parent": null + }, + "pool_classes_by_scheme": { + "http": { + "scheme": "http", + "ConnectionCls": { + "scheme": "http", + "default_port": 80, + "host": "", + "set_tunnel": {}, + "connect": {}, + "is_closed": "", + "is_connected": "", + "has_connected_to_proxy": "", + "proxy_is_forwarding": "", + "proxy_is_tunneling": "", + "close": {}, + "putrequest": {}, + "putheader": {}, + "request": {}, + "getresponse": {} + }, + "is_idle": "", + "is_saturated": "", + "get_response": {}, + "close": {}, + "is_same_host": {}, + "urlopen": {} + }, + "https": { + "scheme": "https", + "ConnectionCls": { + "scheme": "https", + "default_port": 443, + "cert_reqs": null, + "ca_certs": null, + "ca_cert_dir": null, + "ca_cert_data": null, + "ssl_version": null, + "ssl_minimum_version": null, + "ssl_maximum_version": null, + "assert_fingerprint": null, + "cert_file": null, + "key_file": null, + "key_password": null, + "cert_data": null, + "key_data": null, + "ciphers": null, + "connect": {} + } + } + }, + "key_fn_by_scheme": { + "http": {}, + "https": {} + } + } + } + } +} \ No newline at end of file diff --git a/unitpackage/config.py b/unitpackage/config.py new file mode 100644 index 00000000..08f05bf1 --- /dev/null +++ b/unitpackage/config.py @@ -0,0 +1,369 @@ +r""" +Configuration file support for unitpackage ELN backends. + +Loads a TOML configuration file from the XDG config directory +(``~/.config/unitpackage/config.toml`` by default, or +``$XDG_CONFIG_HOME/unitpackage/config.toml`` if set). + +The file uses named profiles grouped by backend type. A +``default_profile`` key selects which profile is used when none is +specified explicitly:: + + [elabftw] + default_profile = "production" + + [elabftw.production] + host = "https://eln.example.org" + api_key = "abc123" + verify_ssl = true + + [elabftw.staging] + host = "https://staging-eln.example.org" + api_key = "def456" + verify_ssl = false + +EXAMPLES: + +Get the default config file path:: + + >>> from pathlib import Path + >>> p = config_path() + >>> p.name + 'config.toml' + >>> p.parent.name + 'unitpackage' + +Load config from a nonexistent file:: + + >>> load_config("/nonexistent/path/config.toml") + {} + +Look up a profile from a pre-loaded config dict:: + + >>> cfg = {"elabftw": {"default_profile": "prod", "prod": {"host": "https://eln.example.org", "api_key": "key"}}} + >>> get_profile("elabftw", "prod", config=cfg) + {'host': 'https://eln.example.org', 'api_key': 'key'} + +""" + +# ******************************************************************** +# This file is part of unitpackage. +# +# Copyright (C) 2026 Johannes Hermann +# +# unitpackage is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# unitpackage is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with unitpackage. If not, see . +# ******************************************************************** +import os +import sys +from pathlib import Path + + +def config_path(): + r""" + Return the path to the unitpackage configuration file. + + On Linux and macOS, respects the ``XDG_CONFIG_HOME`` environment + variable, falling back to ``~/.config``. On Windows, uses + ``%APPDATA%`` (typically ``C:\Users\\AppData\Roaming``), + falling back to ``~/.config``. + + EXAMPLES:: + + >>> import os + >>> os.environ["XDG_CONFIG_HOME"] = "/tmp/xdg" + >>> str(config_path()).replace("\\", "/") + '/tmp/xdg/unitpackage/config.toml' + + """ + xdg_config_home = os.environ.get("XDG_CONFIG_HOME", "").strip() + if xdg_config_home: + base = Path(xdg_config_home) + elif sys.platform == "win32": + appdata = os.environ.get("APPDATA", "").strip() + base = Path(appdata) if appdata else Path.home() / ".config" + else: + base = Path.home() / ".config" + return base / "unitpackage" / "config.toml" + + +def load_config(path=None): + r""" + Load the unitpackage configuration file and return it as a dict. + + If ``path`` is ``None``, the default :func:`config_path` is used. + Returns an empty dict if the file does not exist. + + Raises ``ValueError`` if the file exists but cannot be parsed. + + EXAMPLES:: + + >>> load_config("/nonexistent/path/config.toml") + {} + + """ + if path is None: + path = config_path() + path = Path(path) + + if not path.is_file(): + return {} + + if sys.version_info >= (3, 11): + import tomllib + else: + try: + import tomli as tomllib + except ImportError as exc: + raise ImportError( + "The 'tomli' package is required for config file support " + "on Python < 3.11. Install it with: pip install tomli" + ) from exc + + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + raise ValueError(f"Failed to parse configuration file {path}: {exc}") from exc + + +def get_profile(backend, profile=None, config=None): + r""" + Return the configuration dict for a named profile of a backend. + + If ``profile`` is ``None``, the ``default_profile`` key in the + backend section selects the profile. If ``config`` is ``None``, + :func:`load_config` is called. Returns an empty dict if the + backend or profile is not found. + + EXAMPLES:: + + >>> cfg = {"elabftw": {"default_profile": "prod", "prod": {"host": "https://eln.example.org", "api_key": "key"}}} + >>> get_profile("elabftw", "prod", config=cfg) + {'host': 'https://eln.example.org', 'api_key': 'key'} + + >>> get_profile("elabftw", config=cfg) + {'host': 'https://eln.example.org', 'api_key': 'key'} + + >>> get_profile("elabftw", "nonexistent", config=cfg) + {} + + + Auto-selects when there is exactly one profile and no default:: + + >>> cfg2 = {"elabftw": {"myhost": {"host": "https://eln.example.org", "api_key": "key"}}} + >>> get_profile("elabftw", config=cfg2) + {'host': 'https://eln.example.org', 'api_key': 'key'} + + """ + if config is None: + config = load_config() + + backend_section = config.get(backend, {}) + if not backend_section: + return {} + + if profile is not None: + return backend_section.get(profile, {}) + + # Use the explicitly configured default profile + default_name = backend_section.get("default_profile") + if default_name: + return backend_section.get(default_name, {}) + + # Auto-select when there is exactly one profile + profiles = [ + k + for k, v in backend_section.items() + if k != "default_profile" and isinstance(v, dict) + ] + if len(profiles) == 1: + return backend_section[profiles[0]] + + return {} + + +def list_profiles(backend, config=None): + r""" + Return the profile names available for a backend. + + The ``default_profile`` key is excluded from the result. + + EXAMPLES:: + + >>> cfg = {"elabftw": {"default_profile": "prod", "prod": {}, "staging": {}}} + >>> list_profiles("elabftw", config=cfg) + ['prod', 'staging'] + + >>> list_profiles("kadi", config=cfg) + [] + + """ + if config is None: + config = load_config() + return [k for k in config.get(backend, {}) if k != "default_profile"] + + +def _toml_value(value): + r""" + Format a Python value as a TOML literal. + + Only the scalar types the unitpackage config actually stores --- ``bool``, + ``int``, ``float`` and ``str`` --- are supported. Anything else (e.g. a + nested ``dict`` or a ``list``) raises ``TypeError`` instead of being + silently stringified into invalid TOML. + + EXAMPLES:: + + >>> _toml_value(True) + 'true' + >>> _toml_value(False) + 'false' + >>> _toml_value(42) + '42' + >>> _toml_value("hello") + '"hello"' + >>> _toml_value('say "hi"') + '"say \\"hi\\""' + + Unsupported types are rejected rather than mis-emitted:: + + >>> _toml_value(["a", "b"]) + Traceback (most recent call last): + ... + TypeError: Cannot serialize value of type 'list' to TOML; only bool, int, float and str are supported. + + """ + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + raise TypeError( + f"Cannot serialize value of type {type(value).__name__!r} to TOML; " + "only bool, int, float and str are supported." + ) + + +def _serialize_toml(config): + r""" + Serialize a nested config dict to a TOML-formatted string. + + Supports exactly the shape the unitpackage config uses: top-level + sections (tables) holding scalar keys and at most one level of + sub-table profiles. Structures that fall outside this shape --- a + non-table top-level value, or a table nested more than one level deep + --- raise instead of being silently dropped or mis-emitted, since this + is a purpose-built serializer, not a general TOML writer. + + EXAMPLES:: + + >>> print(_serialize_toml({"b": {"x": "1", "sub": {"k": "v"}}}), end="") + [b] + x = "1" + + [b.sub] + k = "v" + + A top-level value that is not a table is rejected:: + + >>> _serialize_toml({"b": "scalar"}) + Traceback (most recent call last): + ... + ValueError: Cannot serialize 'b': top-level config values must be tables (sections), not str. + + A table nested more than one level deep is rejected:: + + >>> _serialize_toml({"b": {"prof": {"nested": {"k": "v"}}}}) + Traceback (most recent call last): + ... + ValueError: Cannot serialize nested table [b.prof].nested: only one level of sub-tables is supported. + + """ + lines = [] + for section, content in config.items(): + if not isinstance(content, dict): + raise ValueError( + f"Cannot serialize {section!r}: top-level config values must " + f"be tables (sections), not {type(content).__name__}." + ) + lines.append(f"[{section}]") + for key, value in content.items(): + if not isinstance(value, dict): + lines.append(f"{key} = {_toml_value(value)}") + lines.append("") + for key, value in content.items(): + if isinstance(value, dict): + lines.append(f"[{section}.{key}]") + for k, v in value.items(): + if isinstance(v, dict): + raise ValueError( + f"Cannot serialize nested table [{section}.{key}].{k}: " + "only one level of sub-tables is supported." + ) + lines.append(f"{k} = {_toml_value(v)}") + lines.append("") + return "\n".join(lines) + "\n" if lines else "" + + +def save_config(config, path=None): + r""" + Write the configuration dict to the TOML config file. + + If ``path`` is ``None``, the default :func:`config_path` is used. + Creates parent directories if they do not exist. + + The file holds API keys and access tokens in plain text, so on POSIX + systems it is written with owner-only permissions (``0o600``) and its + parent directory with ``0o700`` to keep the secrets out of reach of + other users. File-permission bits are a no-op on Windows. + + EXAMPLES:: + + >>> import tempfile, os + >>> d = tempfile.mkdtemp() + >>> p = os.path.join(d, "sub", "config.toml") + >>> save_config({"elabftw": {"default_profile": "prod", "prod": {"host": "https://example.org", "api_key": "key"}}}, path=p) + >>> load_config(p)["elabftw"]["prod"]["host"] + 'https://example.org' + + On POSIX systems the file is owner-readable only:: + + >>> import stat, sys + >>> sys.platform == "win32" or stat.S_IMODE(os.stat(p).st_mode) == 0o600 + True + + """ + if path is None: + path = config_path() + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + if sys.platform != "win32": + # Restrict the directory before the secret-bearing file lands in it. + try: + os.chmod(path.parent, 0o700) + except OSError: + pass + # Create (or truncate) the file with owner-only permissions from the start, + # so the secrets are never briefly world-readable between write and chmod. + if sys.platform == "win32": + path.write_text(_serialize_toml(config), encoding="utf-8") + else: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(_serialize_toml(config)) + # Enforce 0o600 even if the file pre-existed with looser bits + # (O_CREAT does not change an existing file's mode). + os.chmod(path, 0o600) diff --git a/unitpackage/eln/__init__.py b/unitpackage/eln/__init__.py new file mode 100644 index 00000000..149d3d42 --- /dev/null +++ b/unitpackage/eln/__init__.py @@ -0,0 +1,49 @@ +r""" +Electronic Lab Notebook (ELN) backend integrations. + +This package provides clients for fetching and uploading unitpackage entries +to ELN instances (:mod:`unitpackage.eln.elabftw` and +:mod:`unitpackage.eln.kadi`). + +The backend-agnostic building blocks they share --- the +:class:`~unitpackage.eln._base.BaseELNClient` abstract base class and the +datapackage-descriptor helpers --- live in :mod:`unitpackage.eln._base` and are +re-exported here so they can be imported directly from ``unitpackage.eln``:: + + >>> from unitpackage.eln import BaseELNClient + +""" + +# ******************************************************************** +# This file is part of unitpackage. +# +# Copyright (C) 2026 Johannes Hermann +# +# unitpackage is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# unitpackage is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with unitpackage. If not, see . +# ******************************************************************** +from unitpackage.eln._base import ( + DATAPACKAGE_FILENAME, + BaseELNClient, + apply_datapackage_descriptor, + build_datapackage_descriptor, + first_resource, +) + +__all__ = [ + "DATAPACKAGE_FILENAME", + "BaseELNClient", + "apply_datapackage_descriptor", + "build_datapackage_descriptor", + "first_resource", +] diff --git a/unitpackage/eln/_base.py b/unitpackage/eln/_base.py new file mode 100644 index 00000000..46f50670 --- /dev/null +++ b/unitpackage/eln/_base.py @@ -0,0 +1,234 @@ +r""" +Shared, backend-agnostic building blocks for ELN integrations. + +Defines the :class:`BaseELNClient` abstract base class implemented by the +concrete backends (:mod:`unitpackage.eln.elabftw` and +:mod:`unitpackage.eln.kadi`) together with the datapackage-descriptor helpers +they share. + +These names are re-exported from :mod:`unitpackage.eln`, so import them from +there (``from unitpackage.eln import BaseELNClient``) rather than reaching into +this private module directly. + +""" + +# ******************************************************************** +# This file is part of unitpackage. +# +# Copyright (C) 2026 Johannes Hermann +# +# unitpackage is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# unitpackage is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with unitpackage. If not, see . +# ******************************************************************** +import abc +import json +import logging + +logger = logging.getLogger("unitpackage") + +#: Canonical filename used by all ELN backends to store the frictionless +#: datapackage descriptor as a file attachment alongside the CSV data. +DATAPACKAGE_FILENAME = "datapackage.json" + + +class BaseELNClient(abc.ABC): + r""" + Abstract base class for ELN backend clients. + + ELN backends (e.g. eLabFTW) implement this interface, which guarantees + a uniform API for fetching and uploading unitpackage entries. + + Concrete subclasses must implement :meth:`from_env`, :meth:`from_config`, + :meth:`info`, :meth:`fetch_entry`, :meth:`fetch_entries`, and + :meth:`upload_entry`. + """ + + @classmethod + @abc.abstractmethod + def from_env(cls): + """Create a client from environment variables.""" + + @classmethod + @abc.abstractmethod + def from_config(cls, profile=None): + """Create a client from a configuration file profile.""" + + @abc.abstractmethod + def info(self) -> dict: + """Return server information as a dict.""" + + @abc.abstractmethod + def fetch_entry(self, entity_id: int): + """Fetch a single ELN entity and return it as an :class:`~unitpackage.entry.Entry`.""" + + @abc.abstractmethod + def fetch_entries(self, tags=None): + """Fetch multiple ELN entities and return them as a list of entries.""" + + @abc.abstractmethod + def upload_entry(self, entry, title=None, tags=None) -> int: + """Upload a unitpackage entry to the ELN and return the created entity ID.""" + + def _collect_entries(self, items, label): + r""" + Fetch each ``(entity_id, title)`` pair, skipping ones that raise + ``ValueError``. + + Shared by the concrete backends' :meth:`fetch_entries` implementations: + each backend resolves its own listing into a list of + ``(entity_id, title)`` tuples and delegates the per-entity download, + error handling, and summary logging here. + + ``items`` must be a list (so its length is the meaningful denominator + of entities actually considered). ``label`` is a human-readable noun + used in log messages (e.g. ``"record"`` or ``"items"``). + """ + entries = [] + for entity_id, title in items: + try: + entries.append(self.fetch_entry(entity_id)) + except ValueError as exc: + logger.warning("Skipping %s %s (%s): %s", label, entity_id, title, exc) + + logger.info( + "Successfully fetched %d of %d %s entries", + len(entries), + len(items), + label, + ) + return entries + + +def build_datapackage_descriptor(entry): + r""" + Build the frictionless datapackage descriptor dict for an Entry. + + Returns the same structure that ``entry.save()`` writes to disk as the + JSON file — i.e. the full datapackage (schema, field units, metadata) + but without the CSV data itself. + + This is used by ELN backends to store the complete entry description in + the ELN's metadata system so that a round-trip fetch faithfully restores + schema and metadata. + + EXAMPLES:: + + >>> import pandas as pd + >>> from unitpackage.entry import Entry + >>> df = pd.DataFrame({"t": [0.0, 1.0], "E": [-0.1, -0.2]}) + >>> entry = Entry.from_df(df=df, basename="test") + >>> descriptor = build_datapackage_descriptor(entry) + >>> list(descriptor.keys()) + ['resources'] + >>> descriptor["resources"][0]["name"] + 'test' + + """ + from frictionless import Package, Resource + + current_schema = entry.resource.schema.to_dict() + resource_descriptor = { + "name": entry.identifier, + "type": "table", + "path": f"{entry.identifier}.csv", + "format": "csv", + "mediatype": "text/csv", + "schema": current_schema, + } + if entry.resource.custom.get("metadata"): + resource_descriptor["metadata"] = entry.resource.custom["metadata"] + + package = Package(resources=[Resource.from_descriptor(resource_descriptor)]) + return package.to_dict() + + +def first_resource(descriptor): + r""" + Return the first resource dict from a datapackage descriptor. + + Validates that the descriptor carries at least one well-formed resource + with a non-empty ``path``. Shared by the ELN backends' :meth:`fetch_entry` + implementations, which use the resource's ``path`` to locate the uploaded + CSV file. + + Raises ``ValueError`` if the descriptor has no resources or the first + resource has no path. + + EXAMPLES:: + + >>> first_resource({"resources": [{"path": "a.csv"}]}) + {'path': 'a.csv'} + >>> first_resource({"resources": []}) + Traceback (most recent call last): + ... + ValueError: unitpackage descriptor has no resources. + + """ + resources = descriptor.get("resources", []) if isinstance(descriptor, dict) else [] + if not ( + isinstance(resources, list) and resources and isinstance(resources[0], dict) + ): + raise ValueError("unitpackage descriptor has no resources.") + + resource = resources[0] + if not resource.get("path"): + raise ValueError("unitpackage descriptor resource has no path.") + + return resource + + +def apply_datapackage_descriptor(entry, datapackage_json): + r""" + Reconstruct metadata and field units from a stored datapackage JSON string + and apply them to an Entry. + + Returns the updated entry, or the original entry unchanged if the JSON + cannot be parsed or contains no resources. + + EXAMPLES:: + + >>> import json, pandas as pd + >>> from unitpackage.entry import Entry + >>> df = pd.DataFrame({"t": [0.0, 1.0], "E": [-0.1, -0.2]}) + >>> entry = Entry.from_df(df=df, basename="test") + >>> descriptor = build_datapackage_descriptor(entry) + >>> entry2 = apply_datapackage_descriptor(entry, json.dumps(descriptor)) + >>> entry2 is not None + True + + """ + try: + descriptor = json.loads(datapackage_json) + # Some ELN backends double-encode JSON attachments; if parsing + # yields a string, decode one more level. + if isinstance(descriptor, str): + descriptor = json.loads(descriptor) + except (json.JSONDecodeError, TypeError) as exc: + logger.warning("Failed to parse stored datapackage descriptor: %s", exc) + return entry + + resources = descriptor.get("resources", []) + if not resources: + return entry + + res = resources[0] + + metadata = res.get("metadata", {}) + if metadata: + entry.metadata.from_dict(metadata) + + schema_fields = res.get("schema", {}).get("fields", []) + if schema_fields: + entry = entry.update_fields(schema_fields) + + return entry diff --git a/unitpackage/eln/_cli.py b/unitpackage/eln/_cli.py new file mode 100644 index 00000000..df840359 --- /dev/null +++ b/unitpackage/eln/_cli.py @@ -0,0 +1,302 @@ +r""" +Shared Click command factory for ELN backend CLIs. + +Both the eLabFTW and Kadi4Mat command groups expose the same surface --- a +group with ``--host`` / secret / ``--no-verify-ssl`` / ``--profile`` options +and ``info`` / ``fetch`` / ``export`` / ``upload`` subcommands --- differing +only in backend names, the secret option (``--api-key`` vs ``--pat``), the +client class, and how server info is printed. + +:func:`make_eln_group` builds a fully wired group from those few parameters so +the two backend CLI modules can stay thin wrappers around it. + +""" + +# ******************************************************************** +# This file is part of unitpackage. +# +# Copyright (C) 2026 Johannes Hermann +# +# unitpackage is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# unitpackage is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with unitpackage. If not, see . +# ******************************************************************** +import logging +import os + +import click + +logger = logging.getLogger("unitpackage") + + +def make_eln_group( # pylint: disable=too-many-locals,too-many-statements + *, + name, + display, + article, + noun_singular, + noun_plural, + secret_opt, + secret_envvar, + secret_help, + secret_label, + secret_config_key, + host_envvar, + section_hint, + client_factory, + info_printer, + info_help, + info_error_label, +): + r""" + Build and return a Click ``Group`` for an ELN backend. + + Parameters describe the backend's identity and wiring: + + - ``name`` --- config/backend key (e.g. ``"elabftw"``). + - ``display`` --- human-readable backend name (e.g. ``"eLabFTW"``). + - ``article`` --- ``display`` with its grammatical article + (e.g. ``"an eLabFTW"``), used in the group help text. + - ``noun_singular`` / ``noun_plural`` --- what one/many entities are called + (e.g. ``"entry"`` / ``"entries"``). + - ``secret_opt`` / ``secret_envvar`` / ``secret_help`` / ``secret_label`` + / ``secret_config_key`` --- the credential option flag, environment + variable, option help, error-message label, and config-file key. + - ``host_envvar`` --- environment variable for the host URL. + - ``section_hint`` --- config-file section example for error messages + (e.g. ``"an [elabftw.]"``). + - ``client_factory(host, secret, verify_ssl, settings)`` --- builds the + backend client. + - ``info_printer(server_info)`` --- prints ``client.info()`` output. + - ``info_help`` / ``info_error_label`` --- help text and error label for + the ``info`` command. + + """ + group_help = ( + f"Interact with {article} electronic lab notebook instance.\n\n" + f"Credentials are resolved in order: CLI options, environment " + f"variables ({host_envvar} / {secret_envvar}), then the configuration " + f"file (~/.config/unitpackage/config.toml)." + ) + + @click.group(name=name, help=group_help) + @click.option( + "--host", + type=str, + envvar=host_envvar, + default=None, + help=f"{display} server URL", + ) + @click.option( + secret_opt, + "secret", + type=str, + envvar=secret_envvar, + default=None, + help=secret_help, + ) + @click.option( + "--no-verify-ssl", + is_flag=True, + default=False, + help="Disable SSL certificate verification", + ) + @click.option( + "--profile", + type=str, + default=None, + help="Configuration profile name", + ) + @click.pass_context + def group(ctx, host, secret, no_verify_ssl, profile): + # Store raw options; client creation is deferred to _resolve_client(). + ctx.ensure_object(dict) + ctx.obj["_host"] = host + ctx.obj["_secret"] = secret + ctx.obj["_no_verify_ssl"] = no_verify_ssl + ctx.obj["_profile"] = profile + + def _resolve_client(ctx): + r"""Lazily create and cache the backend client in ``ctx.obj["client"]``.""" + if "client" in ctx.obj: + return ctx.obj["client"] + + host = ctx.obj["_host"] + secret = ctx.obj["_secret"] + no_verify_ssl = ctx.obj["_no_verify_ssl"] + profile = ctx.obj["_profile"] + + settings = None + # Priority: CLI option > env var > config file. + if not (host and secret): + from unitpackage.config import config_path, get_profile + + settings = get_profile(name, profile=profile) + if not settings: + if profile: + raise click.UsageError( + f"Profile '{profile}' not found in {config_path()}. " + f"Check the profile name or provide --host and {secret_opt}." + ) + raise click.UsageError( + f"Missing {display} credentials. Provide --host and " + f"{secret_opt}, set {host_envvar}/{secret_envvar} environment " + f"variables, or add {section_hint} section to {config_path()}." + ) + + resolved_host = host or (settings.get("host") if settings else None) + resolved_secret = secret or ( + settings.get(secret_config_key) if settings else None + ) + + if not resolved_host: + raise click.UsageError( + "Missing host URL (not in CLI, env, or config profile)." + ) + if not resolved_secret: + raise click.UsageError( + f"Missing {secret_label} (not in CLI, env, or config profile)." + ) + + if no_verify_ssl: + verify_ssl = False + elif settings: + verify_ssl = settings.get("verify_ssl", True) + else: + verify_ssl = True + + try: + client = client_factory( + resolved_host, resolved_secret, verify_ssl, settings or {} + ) + except Exception as e: + raise click.ClickException(f"Failed to create {display} client: {e}") from e + + ctx.obj["client"] = client + return client + + @group.command(help=info_help) + @click.pass_context + def info(ctx): + client = _resolve_client(ctx) + + try: + server_info = client.info() + except Exception as e: + raise click.ClickException( + f"Failed to fetch {info_error_label} info: {e}" + ) from e + + info_printer(server_info) + + @group.command( + help=f"Fetch a single {noun_singular} from {display} and save as a datapackage." + ) + @click.argument("entity_id", type=int) + @click.option( + "--outdir", + type=click.Path(file_okay=False), + default=".", + help="Output directory for the datapackage", + ) + @click.pass_context + def fetch(ctx, entity_id, outdir): + client = _resolve_client(ctx) + + try: + entry = client.fetch_entry(entity_id) + except Exception as e: + raise click.ClickException( + f"Failed to fetch {noun_singular} {entity_id}: {e}" + ) from e + + os.makedirs(outdir, exist_ok=True) + entry.save(outdir=outdir) + click.echo(f"Saved {noun_singular} {entity_id} to {outdir}") + + @group.command( + name="export", + help=f"Fetch multiple {noun_plural} from {display} filtered by tags.", + ) + @click.option( + "--tag", + "tags", + multiple=True, + help="Filter by tag (can be specified multiple times)", + ) + @click.option( + "--outdir", + type=click.Path(file_okay=False), + default=".", + help="Output directory for the datapackages", + ) + @click.pass_context + def export_cmd(ctx, tags, outdir): + client = _resolve_client(ctx) + + try: + entries = client.fetch_entries(list(tags) if tags else None) + except Exception as e: + raise click.ClickException(f"Failed to fetch {noun_plural}: {e}") from e + + if not entries: + click.echo(f"No {noun_plural} found matching the specified tags.") + return + + os.makedirs(outdir, exist_ok=True) + for entry in entries: + entry.save(outdir=outdir) + + click.echo(f"Saved {len(entries)} {noun_plural} to {outdir}") + + @group.command(help=f"Upload a local datapackage to {display}.") + @click.argument("file", type=click.Path(exists=True)) + @click.option( + "--title", + type=str, + default=None, + help=f"Title for the {display} {noun_singular}", + ) + @click.option( + "--tag", + "tags", + multiple=True, + help="Tag to add to the entry (can be specified multiple times)", + ) + @click.pass_context + def upload(ctx, file, title, tags): + from unitpackage.entry import Entry + + client = _resolve_client(ctx) + + try: + entry = Entry.from_local(file) + except Exception as e: + raise click.ClickException( + f"Failed to load datapackage from {file}: {e}" + ) from e + + try: + entity_id = client.upload_entry( + entry, title=title, tags=list(tags) if tags else None + ) + except Exception as e: + raise click.ClickException( + f"Failed to upload entry to {display}: {e}" + ) from e + + if entity_id is not None: + click.echo(f"Uploaded {file} to {display} as {noun_singular} {entity_id}") + else: + click.echo(f"Uploaded {file} to {display}") + + return group diff --git a/unitpackage/eln/_config_cli.py b/unitpackage/eln/_config_cli.py new file mode 100644 index 00000000..a364a715 --- /dev/null +++ b/unitpackage/eln/_config_cli.py @@ -0,0 +1,213 @@ +r""" +Shared Click command factory for ELN connection-profile management CLIs. + +Both the eLabFTW and Kadi4Mat ``config`` subgroups manage connection profiles +in the unitpackage config file identically, differing only in the backend key, +display name, and the name of the secret credential (``api_key`` vs ``pat``). + +:func:`make_config_group` builds the ``add`` / ``list`` / ``show`` / ``remove`` +/ ``set-default`` subgroup from those few parameters. + +""" + +# ******************************************************************** +# This file is part of unitpackage. +# +# Copyright (C) 2026 Johannes Hermann +# +# unitpackage is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# unitpackage is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with unitpackage. If not, see . +# ******************************************************************** +import click + +from unitpackage.config import ( + config_path, + get_profile, + list_profiles, + load_config, + save_config, +) + + +def make_config_group( # pylint: disable=too-many-statements + *, + backend, + display, + secret_opt, + secret_field, + secret_label, +): + r""" + Build and return the ``config`` Click ``Group`` for an ELN backend. + + Parameters: + + - ``backend`` --- config-file section key (e.g. ``"elabftw"``), also used + in command examples (``unitpackage config add ...``). + - ``display`` --- human-readable backend name (e.g. ``"eLabFTW"``). + - ``secret_opt`` --- credential option flag (e.g. ``"--api-key"``). + - ``secret_field`` --- config-file key for the secret (e.g. ``"api_key"``). + - ``secret_label`` --- human-readable secret name used in prompts and help + (e.g. ``"API key"``). + + """ + + @click.group(name="config", help=f"Manage {display} connection profiles.") + def config(): + pass + + @config.command(help=f"Add a new {display} connection profile.") + @click.argument("profile_name") + @click.option("--host", prompt=f"{display} host URL", help=f"{display} server URL") + @click.option( + secret_opt, + "secret", + prompt=secret_label, + hide_input=True, + help=f"{display} {secret_label}", + ) + @click.option( + "--verify-ssl/--no-verify-ssl", + default=True, + help="Whether to verify SSL certificates (default: yes)", + ) + @click.option( + "--set-default/--no-set-default", + "set_as_default", + default=False, + help="Set this profile as the default", + ) + def add(profile_name, host, secret, verify_ssl, set_as_default): + cfg = load_config() + section = cfg.setdefault(backend, {}) + + if profile_name in section and profile_name != "default_profile": + raise click.ClickException( + f"Profile '{profile_name}' already exists. " + "Remove it first with 'config remove'." + ) + + section[profile_name] = { + "host": host, + secret_field: secret, + "verify_ssl": verify_ssl, + } + + # If this is the first profile or --set-default was given, set as default. + actual_profiles = [k for k in section if k != "default_profile"] + if set_as_default or len(actual_profiles) == 1: + section["default_profile"] = profile_name + + save_config(cfg) + click.echo(f"Profile '{profile_name}' added to {config_path()}.") + if section.get("default_profile") == profile_name: + click.echo(f"Set '{profile_name}' as the default profile.") + + @config.command("list", help=f"List all {display} connection profiles.") + def list_cmd(): + cfg = load_config() + profiles = list_profiles(backend, config=cfg) + + if not profiles: + click.echo(f"No {display} profiles configured.") + click.echo(f"Add one with: unitpackage {backend} config add PROFILE_NAME") + return + + default = cfg.get(backend, {}).get("default_profile") + for name in profiles: + marker = " (default)" if name == default else "" + profile = cfg[backend][name] + click.echo(f" {name}{marker} - {profile.get('host', '?')}") + + @config.command(help=f"Show details of a {display} connection profile.") + @click.argument("profile_name") + @click.option( + "--show-key", + is_flag=True, + default=False, + help=f"Show the {secret_label} in plain text (default: masked)", + ) + def show(profile_name, show_key): + cfg = load_config() + profile = get_profile(backend, profile_name, config=cfg) + + if not profile: + raise click.ClickException( + f"Profile '{profile_name}' not found. " + f"Available profiles: {', '.join(list_profiles(backend, config=cfg)) or 'none'}" + ) + + default = cfg.get(backend, {}).get("default_profile") + is_default = " (default)" if profile_name == default else "" + + click.echo(f"Profile: {profile_name}{is_default}") + click.echo(f" host: {profile.get('host', 'not set')}") + + secret = profile.get(secret_field, "not set") + if show_key: + click.echo(f" {secret_field}: {secret}") + else: + # Reveal only a short prefix for recognition; never the tail, so the + # masked form leaks as little of the secret as possible. + masked = secret[:4] + "..." if len(secret) > 4 else "****" + click.echo(f" {secret_field}: {masked}") + + click.echo(f" verify_ssl: {profile.get('verify_ssl', True)}") + + @config.command(help=f"Remove a {display} connection profile.") + @click.argument("profile_name") + @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") + def remove(profile_name, yes): + cfg = load_config() + section = cfg.get(backend, {}) + profiles = list_profiles(backend, config=cfg) + + if profile_name not in profiles: + raise click.ClickException( + f"Profile '{profile_name}' not found. " + f"Available profiles: {', '.join(profiles) or 'none'}" + ) + + if not yes: + click.confirm(f"Remove profile '{profile_name}'?", abort=True) + + del section[profile_name] + + if section.get("default_profile") == profile_name: + del section["default_profile"] + click.echo( + f"Note: '{profile_name}' was the default profile. Default cleared." + ) + + save_config(cfg) + click.echo(f"Profile '{profile_name}' removed.") + + @config.command( + "set-default", help=f"Set the default {display} connection profile." + ) + @click.argument("profile_name") + def set_default(profile_name): + cfg = load_config() + profiles = list_profiles(backend, config=cfg) + + if profile_name not in profiles: + raise click.ClickException( + f"Profile '{profile_name}' not found. " + f"Available profiles: {', '.join(profiles) or 'none'}" + ) + + cfg[backend]["default_profile"] = profile_name + save_config(cfg) + click.echo(f"Default profile set to '{profile_name}'.") + + return config diff --git a/unitpackage/eln/elabftw.py b/unitpackage/eln/elabftw.py new file mode 100644 index 00000000..da35d72a --- /dev/null +++ b/unitpackage/eln/elabftw.py @@ -0,0 +1,549 @@ +r""" +Client for integrating unitpackage with eLabFTW electronic lab notebooks. + +Provides the :class:`ElabFTWClient` class for fetching items (eLabFTW +resources) from an eLabFTW instance as unitpackage entries, and for uploading +entries back to eLabFTW. + +.. note:: + + Only the eLabFTW ``items`` entity type (resources) is supported at the + moment. eLabFTW *experiments* are not yet supported. + +EXAMPLES: + +Create a client from environment variables:: + + # export ELABFTW_HOST=https://eln.example.org + # export ELABFTW_API_KEY=your-api-key + # client = ElabFTWClient.from_env() + +Or provide credentials directly:: + + # client = ElabFTWClient( + # host="https://eln.example.org", + # api_key="your-api-key", + # ) + +Fetch a single item as an Entry:: + + # entry = client.fetch_entry(42) + # entry.df # pandas DataFrame with the CSV data + # entry.metadata # metadata from eLabFTW + +Fetch multiple items filtered by tags:: + + # entries = client.fetch_entries(tags=["electrochemistry"]) + +Upload an Entry to eLabFTW:: + + # entity_id = client.upload_entry(entry, title="My Item") + +""" + +# ******************************************************************** +# This file is part of unitpackage. +# +# Copyright (C) 2026 Johannes Hermann +# +# unitpackage is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# unitpackage is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with unitpackage. If not, see . +# ******************************************************************** +import io +import json +import logging +import os +import tempfile +from pathlib import Path +from types import SimpleNamespace + +import elabapi_python +import pandas as pd + +from unitpackage.eln import ( + BaseELNClient, + apply_datapackage_descriptor, + build_datapackage_descriptor, + first_resource, +) +from unitpackage.entry import Entry + +logger = logging.getLogger("unitpackage") + +# Filename used for the datapackage descriptor attachment. + + +class ElabFTWClient(BaseELNClient): + r""" + Client for interacting with an eLabFTW electronic lab notebook instance. + + Provides methods to fetch items (eLabFTW resources) as unitpackage entries, + and to upload entries back to eLabFTW. + + .. note:: + + Only the eLabFTW ``items`` entity type is supported at the moment; + eLabFTW *experiments* are not yet supported. + + EXAMPLES: + + Create a client with explicit credentials:: + + # client = ElabFTWClient( + # host="https://eln.example.org", + # api_key="your-api-key", + # ) + + Create a client from environment variables:: + + # client = ElabFTWClient.from_env() + + """ + + def __init__(self, host, api_key, entity_type="items", verify_ssl=True): + r""" + Initialize the eLabFTW client. + + The ``host`` should be the base URL of the eLabFTW instance + (e.g., ``https://eln.example.org``). The ``/api/v2`` path is + appended automatically. + + The ``api_key`` is used for authentication via the Authorization + header. + + Only the ``"items"`` entity type (eLabFTW resources) is supported at + the moment; eLabFTW *experiments* are not yet supported, so + ``entity_type`` must be ``"items"`` (the default) and any other value + raises ``ValueError``. + + EXAMPLES:: + + # client = ElabFTWClient( + # host="https://eln.example.org", + # api_key="your-api-key", + # ) + + """ + if entity_type != "items": + raise ValueError(f"Invalid entity_type '{entity_type}'. Must be 'items'.") + self._entity_type = entity_type + + configuration = elabapi_python.Configuration() + configuration.host = f"{host.rstrip('/')}/api/v2" + configuration.verify_ssl = verify_ssl + + if not verify_ssl: + import urllib3 + + urllib3.disable_warnings(category=urllib3.exceptions.InsecureRequestWarning) + + api_client = elabapi_python.ApiClient(configuration) + api_client.set_default_header(header_name="Authorization", header_value=api_key) + + self._api_client = api_client + self.items_client = elabapi_python.ItemsApi(api_client) + self.uploads_client = elabapi_python.UploadsApi(api_client) + + @classmethod + def from_env(cls): + r""" + Create an ElabFTWClient from environment variables. + + Reads ``ELABFTW_HOST`` and ``ELABFTW_API_KEY`` from the environment. + + EXAMPLES:: + + # import os + # os.environ["ELABFTW_HOST"] = "https://eln.example.org" + # os.environ["ELABFTW_API_KEY"] = "your-api-key" + # client = ElabFTWClient.from_env() + + """ + host = os.environ.get("ELABFTW_HOST") + api_key = os.environ.get("ELABFTW_API_KEY") + + if not host: + raise ValueError( + "ELABFTW_HOST environment variable is not set. " + "Set it to the base URL of your eLabFTW instance " + "(e.g., https://eln.example.org)." + ) + if not api_key: + raise ValueError( + "ELABFTW_API_KEY environment variable is not set. " + "Generate an API key in your eLabFTW user settings." + ) + + entity_type = os.environ.get("ELABFTW_ENTITY_TYPE", "items") + return cls(host=host, api_key=api_key, entity_type=entity_type) + + @classmethod + def from_config(cls, profile=None): + r""" + Create an ElabFTWClient from a configuration file profile. + + Reads the ``[elabftw.]`` section from the unitpackage + config file (``~/.config/unitpackage/config.toml``). + If ``profile`` is ``None``, the first eLabFTW profile is used. + + EXAMPLES:: + + # client = ElabFTWClient.from_config("production") + # client = ElabFTWClient.from_config() # uses first profile + + """ + from unitpackage.config import get_profile + + settings = get_profile("elabftw", profile=profile) + + if not settings: + if profile: + raise ValueError( + f"No eLabFTW profile '{profile}' found in configuration file. " + f"Add an [elabftw.{profile}] section to your config.toml." + ) + raise ValueError( + "No eLabFTW profiles found in configuration file. " + "Add an [elabftw.] section to your config.toml." + ) + + host = settings.get("host") + api_key = settings.get("api_key") + + if not host: + raise ValueError("Missing 'host' in eLabFTW config profile.") + if not api_key: + raise ValueError("Missing 'api_key' in eLabFTW config profile.") + + verify_ssl = settings.get("verify_ssl", True) + entity_type = settings.get("entity_type", "items") + return cls( + host=host, api_key=api_key, entity_type=entity_type, verify_ssl=verify_ssl + ) + + def info(self): + r""" + Return server information as a dictionary. + + The returned dictionary contains keys such as ``version``, + ``teams_count``, and ``all_users_count``. + + EXAMPLES:: + + # client = ElabFTWClient.from_env() + # info = client.info() + # info["version"] + # '5.5.9' + + """ + info_client = elabapi_python.InfoApi(self._api_client) + response = info_client.get_info() + # ``get_info()`` returns an ``Info`` model object, not a dict; normalise + # it so callers (and the CLI's ``.get()`` lookups) get a plain dict. + if hasattr(response, "to_dict"): + return response.to_dict() + return response + + def _get_api(self): + r""" + Return the appropriate API client for the given entity type. + + """ + return self.items_client + + @staticmethod + def _parse_metadata(raw): + r"""Parse a raw metadata value into a dict. + + The eLabFTW Python client deserialises the metadata JSON string + into a ``Metadata`` model object that silently drops keys not in + its schema (like ``unitpackage``). This helper accepts a raw + JSON string *or* an already-parsed dict and returns a plain dict. + """ + if not raw: + return None + if isinstance(raw, str): + try: + return json.loads(raw) + except (json.JSONDecodeError, TypeError): + return None + if isinstance(raw, dict): + return raw + return None + + @classmethod + def _get_unitpackage_descriptor(cls, entity): + r"""Return the ``unitpackage`` descriptor dict, or ``None``.""" + parsed = cls._parse_metadata(getattr(entity, "metadata", None)) + if parsed is None: + return None + return parsed.get("unitpackage") + + @classmethod + def _has_unitpackage_metadata(cls, entity): + r"""Return True if the entity's metadata contains a ``unitpackage`` key.""" + return cls._get_unitpackage_descriptor(entity) is not None + + def _get_item_raw(self, entity_id): + r"""Fetch an item, bypassing model deserialisation of metadata. + + The eLabFTW Python client's ``get_item()`` returns a model object + whose ``Metadata`` attribute silently drops keys not in its schema + (like ``unitpackage``). This method uses ``_preload_content=False`` + to get the raw JSON and wraps it in a ``SimpleNamespace`` so that + the ``metadata`` field is preserved as a raw JSON string. + """ + api = self._get_api() + response = api.get_item(entity_id, _preload_content=False) + data = json.loads(response.data) + return SimpleNamespace(**data) + + def fetch_entry(self, entity_id): + r""" + Fetch an item from eLabFTW and return it as a unitpackage Entry. + + Only items whose metadata contains the ``unitpackage`` key are + considered valid. Raises ``ValueError`` if the entity was not + uploaded by unitpackage or has no CSV upload. + + EXAMPLES:: + + # client = ElabFTWClient.from_env() + # entry = client.fetch_entry(42) + # entry.df + # entry.metadata + + """ + entity_type = self._entity_type + + # Use raw fetch to preserve the metadata JSON string (the + # elabapi_python Metadata model drops unknown keys). + entity = self._get_item_raw(entity_id) + + # Parse the unitpackage descriptor from the entity metadata. + descriptor = self._get_unitpackage_descriptor(entity) + if descriptor is None: + raise ValueError( + f"Entity {entity_id} was not uploaded by unitpackage " + f"(no 'unitpackage' key in metadata)." + ) + + logger.info("Fetched %s %d: %s", entity_type, entity_id, entity.title) + + # Determine the expected filename from the descriptor. + res = first_resource(descriptor) + resource_path = res.get("path", "") + basename = Path(resource_path).stem + + # Find the upload matching the descriptor's resource path. + uploads = self.uploads_client.read_uploads(entity_type, entity_id) + data_upload = None + if resource_path: + for upload in uploads: + if upload.real_name == resource_path: + data_upload = upload + break + + if data_upload is None: + raise ValueError( + f"No upload named '{resource_path}' found on {entity_type} " + f"entity {entity_id}. Available uploads: " + f"{[u.real_name for u in uploads]}" + ) + + logger.info( + "Downloading upload '%s' (id=%d)", + data_upload.real_name, + data_upload.id, + ) + + # Download the data file. + response = self.uploads_client.read_upload( + entity_type, + entity_id, + data_upload.id, + format="binary", + _preload_content=False, + ) + + df = pd.read_csv(io.BytesIO(response.data)) + + # Create the entry and restore schema/metadata from the descriptor. + entry = Entry.from_df(df=df, basename=basename) + entry = apply_datapackage_descriptor(entry, json.dumps(descriptor)) + + return entry + + def fetch_entries(self, tags=None): + r""" + Fetch multiple items from eLabFTW as a list of unitpackage + Entry objects. + + Optionally filter by ``tags``, which should be a list of tag strings. + Only entities that have CSV uploads are included in the result; + entities without CSV uploads are skipped with a warning. + + EXAMPLES:: + + # client = ElabFTWClient.from_env() + # entries = client.fetch_entries(tags=["electrochemistry"]) + # len(entries) + # 5 + + """ + entity_type = self._entity_type + api = self._get_api() + + # Build keyword arguments for the API call + kwargs = {"limit": 9999} + + if tags: + # The eLabFTW API v2 supports a native ``tags`` parameter + # (list of strings, sent as ``tags[]`` query params, AND logic). + kwargs["tags"] = list(tags) + + # Fetch with _preload_content=False to preserve raw metadata JSON + # (the elabapi_python Metadata model drops unknown keys). + response = api.read_items(**kwargs, _preload_content=False) + entities = [SimpleNamespace(**item) for item in json.loads(response.data)] + + logger.info( + "Found %d %s%s", + len(entities), + entity_type, + f" matching tags {tags}" if tags else "", + ) + + # Filter to only unitpackage-managed entities before fetching data. + up_entities = [e for e in entities if self._has_unitpackage_metadata(e)] + logger.info( + "%d of %d %s have unitpackage metadata", + len(up_entities), + len(entities), + entity_type, + ) + + items = [(entity.id, entity.title) for entity in up_entities] + return self._collect_entries(items, entity_type) + + def upload_entry( + self, entry, title=None, tags=None + ): # pylint: disable=too-many-locals + r""" + Upload a unitpackage Entry to eLabFTW as a new resource (item). + + Creates a new item, sets the title and tags, uploads the CSV data + as a file attachment, and stores the datapackage descriptor in the + entity's metadata field. + + Returns the ID of the newly created item. + + EXAMPLES:: + + # from unitpackage.entry import Entry + # import pandas as pd + # df = pd.DataFrame({"t": [0, 1, 2], "E": [0.1, 0.2, 0.3]}) + # entry = Entry.from_df(df=df, basename="my_entry") + # client = ElabFTWClient.from_env() + # entity_id = client.upload_entry(entry, title="My Entry") + + """ + entity_type = self._entity_type + api = self._get_api() + + # Build the creation body + body = {} + + if title: + body["title"] = title + else: + body["title"] = entry.identifier + + if tags: + body["tags"] = list(tags) + + # Build the descriptor before creating anything so we fail fast + # on malformed entries. + descriptor = build_datapackage_descriptor(entry) + resource_path = descriptor["resources"][0]["path"] + + # Create the item + _, _, headers = api.post_item_with_http_info(body=body) + + # eLabFTW returns the new entity's URL in the Location header; the ID + # is the last path segment. Without it we cannot identify (or later + # clean up) the created item, so fail with a clear error. + location = headers.get("Location") + if not location: + raise ValueError( + "eLabFTW did not return a 'Location' header for the created " + f"{entity_type}; cannot determine the new entity ID." + ) + try: + entity_id = int(location.rstrip("/").split("/").pop()) + except ValueError as exc: + raise ValueError( + f"Could not parse entity ID from Location header '{location}'." + ) from exc + + logger.info( + "Created %s %d: %s", + entity_type, + entity_id, + body.get("title", ""), + ) + + try: + # Upload the data file with the canonical name matching the + # "path" key in the datapackage descriptor. + with tempfile.TemporaryDirectory() as tmpdir: + csv_path = os.path.join(tmpdir, resource_path) + entry.df.to_csv(csv_path, index=False) + self.uploads_client.post_upload( + entity_type, + entity_id, + file=csv_path, + comment="Data uploaded from unitpackage", + ) + logger.info("Uploaded data for %s %d", entity_type, entity_id) + + # Store the datapackage descriptor in the entity's metadata field + # under a top-level "unitpackage" key so that a round-trip fetch + # can faithfully restore schema and metadata. + metadata = { + "unitpackage": descriptor, + } + metadata_json = json.dumps(metadata, default=str) + api.patch_item({"metadata": metadata_json}, entity_id) + logger.info( + "Stored datapackage descriptor in metadata for %s %d", + entity_type, + entity_id, + ) + except Exception: # pylint: disable=broad-except + logger.warning( + "Upload failed, deleting partially created %s %d", + entity_type, + entity_id, + ) + try: + api.delete_item(entity_id) + except Exception: # pylint: disable=broad-except + logger.error( + "Failed to clean up %s %d after upload error", + entity_type, + entity_id, + ) + raise + + return entity_id diff --git a/unitpackage/eln/elabftw_cli.py b/unitpackage/eln/elabftw_cli.py new file mode 100644 index 00000000..ef9bc3a5 --- /dev/null +++ b/unitpackage/eln/elabftw_cli.py @@ -0,0 +1,120 @@ +r""" +CLI commands for eLabFTW integration. + +Provides commands to interact with an eLabFTW electronic lab notebook instance, +including fetching items (eLabFTW resources) as datapackages, exporting +collections, and uploading local datapackages. + +Only the eLabFTW ``items`` entity type is supported at the moment; eLabFTW +*experiments* are not yet supported. + +The command group itself is built by ``unitpackage.eln._cli.make_eln_group``, +which is shared with the Kadi4Mat CLI; only the eLabFTW-specific wiring (the +``--api-key`` option, the client class, and how server info is printed) lives +here. + +EXAMPLES:: + + >>> from unitpackage.test.cli import invoke + >>> from unitpackage.eln.elabftw_cli import elabftw + >>> invoke(elabftw, "--help") # doctest: +NORMALIZE_WHITESPACE + Usage: elabftw [OPTIONS] COMMAND [ARGS]... + + Interact with an eLabFTW electronic lab notebook instance. + + Credentials are resolved in order: CLI options, environment variables + (ELABFTW_HOST / ELABFTW_API_KEY), then the configuration file + (~/.config/unitpackage/config.toml). + + Options: + --host TEXT eLabFTW server URL + --api-key TEXT eLabFTW API key + --no-verify-ssl Disable SSL certificate verification + --profile TEXT Configuration profile name + --help Show this message and exit. + + Commands: + config Manage eLabFTW connection profiles. + export Fetch multiple entries from eLabFTW filtered by tags. + fetch Fetch a single entry from eLabFTW and save as a datapackage. + info Show eLabFTW server information. + upload Upload a local datapackage to eLabFTW. + +""" + +# ******************************************************************** +# This file is part of unitpackage. +# +# Copyright (C) 2026 Johannes Hermann +# +# unitpackage is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# unitpackage is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with unitpackage. If not, see . +# ******************************************************************** +import logging + +import click + +from unitpackage.eln._cli import make_eln_group +from unitpackage.eln._config_cli import make_config_group + +logger = logging.getLogger("unitpackage") + + +def _create_client(host, secret, verify_ssl, _settings): + """Create an ElabFTWClient (imported lazily so the optional dependency is + only required when the eLabFTW CLI is actually used).""" + from unitpackage.eln.elabftw import ElabFTWClient + + return ElabFTWClient(host=host, api_key=secret, verify_ssl=verify_ssl) + + +def _print_info(server_info): + """Print eLabFTW server version, team count, and user count.""" + click.echo( + f"Version: {server_info.get('elabftw_version', server_info.get('version', 'unknown'))}" + ) + click.echo(f"Teams: {server_info.get('teams_count', 'unknown')}") + click.echo( + f"Users: {server_info.get('all_users_count', server_info.get('users_count', 'unknown'))}" + ) + + +elabftw = make_eln_group( + name="elabftw", + display="eLabFTW", + article="an eLabFTW", + noun_singular="entry", + noun_plural="entries", + secret_opt="--api-key", + secret_envvar="ELABFTW_API_KEY", + secret_help="eLabFTW API key", + secret_label="API key", + secret_config_key="api_key", + host_envvar="ELABFTW_HOST", + section_hint="an [elabftw.]", + client_factory=_create_client, + info_printer=_print_info, + info_help="Show eLabFTW server information.", + info_error_label="server", +) + +# Register the config management subgroup. +elabftw.add_command( + make_config_group( + backend="elabftw", + display="eLabFTW", + secret_opt="--api-key", + secret_field="api_key", + secret_label="API key", + ) +) diff --git a/unitpackage/eln/kadi.py b/unitpackage/eln/kadi.py new file mode 100644 index 00000000..f2e9d2f3 --- /dev/null +++ b/unitpackage/eln/kadi.py @@ -0,0 +1,526 @@ +r""" +Client for integrating unitpackage with Kadi4Mat electronic lab notebooks. + +Provides the :class:`KadiClient` class for fetching records from a Kadi4Mat +instance as unitpackage entries, and for uploading entries back to Kadi4Mat. + +EXAMPLES: + +Create a client from environment variables:: + + # export KADI_HOST=https://kadi4mat.example.edu + # export KADI_PAT=your-personal-access-token + # client = KadiClient.from_env() + +Or provide credentials directly:: + + # client = KadiClient( + # host="https://kadi4mat.example.edu", + # pat="your-personal-access-token", + # ) + +Fetch a single record as an Entry:: + + # entry = client.fetch_entry(42) + # entry.df # pandas DataFrame with the CSV data + +Fetch multiple records filtered by tags:: + + # entries = client.fetch_entries(tags=["electrochemistry"]) + +Upload an Entry to Kadi4Mat:: + + # entity_id = client.upload_entry(entry, title="My Record") + +""" + +# ******************************************************************** +# This file is part of unitpackage. +# +# Copyright (C) 2026 Johannes Hermann +# +# unitpackage is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# unitpackage is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with unitpackage. If not, see . +# ******************************************************************** +import json +import logging +import os +import re +import tempfile +from pathlib import Path + +import pandas as pd +from kadi_apy import KadiManager + +from unitpackage.eln import ( + BaseELNClient, + apply_datapackage_descriptor, + build_datapackage_descriptor, + first_resource, +) +from unitpackage.entry import Entry + +logger = logging.getLogger("unitpackage") + + +def _dict_to_kadi_extras(data, key=None): + """Convert a Python value to Kadi4Mat extras format recursively. + + Dict and list values become nested ``type: "dict"`` / ``type: "list"`` + entries so they are visible as structured metadata in the Kadi UI. + """ + if isinstance(data, bool): + result = {"type": "bool", "value": data} + elif isinstance(data, int): + result = {"type": "int", "value": data} + elif isinstance(data, float): + result = {"type": "float", "value": data} + elif isinstance(data, dict): + children = [_dict_to_kadi_extras(v, key=k) for k, v in data.items()] + result = {"type": "dict", "value": children} + elif isinstance(data, list): + children = [_dict_to_kadi_extras(item) for item in data] + result = {"type": "list", "value": children} + else: + result = {"type": "str", "value": str(data)} + if key is not None: + result["key"] = key + return result + + +def _kadi_extras_to_python(extra): + """Convert a Kadi4Mat extras entry back to a plain Python value.""" + extra_type = extra.get("type") + if extra_type == "dict": + return { + child["key"]: _kadi_extras_to_python(child) + for child in extra.get("value", []) + } + if extra_type == "list": + return [_kadi_extras_to_python(item) for item in extra.get("value", [])] + if extra_type == "int": + return int(extra.get("value", 0)) + if extra_type == "float": + return float(extra.get("value", 0.0)) + if extra_type == "bool": + return bool(extra.get("value", False)) + return extra.get("value") + + +class KadiClient(BaseELNClient): + r""" + Client for interacting with a Kadi4Mat electronic lab notebook instance. + + Provides methods to fetch records as unitpackage entries and to upload + entries back to Kadi4Mat. + + EXAMPLES: + + Create a client with explicit credentials:: + + # client = KadiClient( + # host="https://kadi4mat.example.edu", + # pat="your-personal-access-token", + # ) + + Create a client from environment variables:: + + # client = KadiClient.from_env() + + """ + + def __init__(self, host, pat, verify_ssl=True, base_path=None): + r""" + Initialize the Kadi4Mat client. + + The ``host`` should be the base URL of the Kadi4Mat instance + (e.g., ``https://kadi4mat.example.edu``). + + The ``pat`` is a Personal Access Token created via the Kadi4Mat + web interface under Settings > Access tokens. + + Set ``verify_ssl=False`` to disable TLS certificate verification + (e.g., for self-signed certificates on private instances). + ``KadiManager`` accepts a ``verify`` parameter; ``False`` also + suppresses urllib3 warnings automatically. + + If the instance runs in a subpath the `base_path` can be set. + + EXAMPLES:: + + # client = KadiClient( + # host="https://kadi4mat.example.edu", + # pat="your-personal-access-token", + # verify_ssl=True, + # base_path="", + # ) + + """ + self._host = host.rstrip("/") + # Always forward ``base_path`` (defaulting to ``""``). ``KadiManager`` + # only loads its config file when ``host``/``pat`` are absent; since we + # always supply both, its internal ``config`` stays ``None``. Leaving + # ``base_path`` unset then makes ``KadiManager`` dereference that + # ``None`` config to look up a default value, raising + # ``TypeError: 'NoneType' object is not subscriptable``. Passing ``""`` + # (i.e. no subpath) short-circuits that lookup. + self._manager = KadiManager( + host=host, pat=pat, verify=verify_ssl, base_path=base_path or "" + ) + + @classmethod + def from_env(cls): + r""" + Create a KadiClient from environment variables. + + Reads ``KADI_HOST`` and ``KADI_PAT`` from the environment. + + EXAMPLES:: + + # import os + # os.environ["KADI_HOST"] = "https://kadi4mat.example.edu" + # os.environ["KADI_PAT"] = "your-personal-access-token" + # client = KadiClient.from_env() + + """ + host = os.environ.get("KADI_HOST") + pat = os.environ.get("KADI_PAT") + base_path = os.environ.get("KADI_BASE_PATH") + + if not host: + raise ValueError( + "KADI_HOST environment variable is not set. " + "Set it to the base URL of your Kadi4Mat instance " + "(e.g., https://kadi4mat.example.edu)." + ) + if not pat: + raise ValueError( + "KADI_PAT environment variable is not set. " + "Create a Personal Access Token in your Kadi4Mat settings." + ) + + return cls(host=host, pat=pat, base_path=base_path) + + @classmethod + def from_config(cls, profile=None): + r""" + Create a KadiClient from a configuration file profile. + + Reads the ``[kadi.]`` section from the unitpackage + config file (``~/.config/unitpackage/config.toml``). + If ``profile`` is ``None``, the first Kadi profile is used. + + EXAMPLES:: + + # client = KadiClient.from_config("production") + # client = KadiClient.from_config() # uses first profile + + """ + from unitpackage.config import get_profile + + settings = get_profile("kadi", profile=profile) + + if not settings: + if profile: + raise ValueError( + f"No Kadi profile '{profile}' found in configuration file. " + f"Add a [kadi.{profile}] section to your config.toml." + ) + raise ValueError( + "No Kadi profiles found in configuration file. " + "Add a [kadi.] section to your config.toml." + ) + + host = settings.get("host") + pat = settings.get("pat") + base_path = settings.get("base_path") + + if not host: + raise ValueError("Missing 'host' in Kadi config profile.") + if not pat: + raise ValueError("Missing 'pat' in Kadi config profile.") + + verify_ssl = settings.get("verify_ssl", True) + return cls(host=host, pat=pat, verify_ssl=verify_ssl, base_path=base_path) + + def info(self): + r""" + Return information about the connected Kadi4Mat instance. + + Makes a request to the server and returns a dictionary with + basic information. + + EXAMPLES:: + + # client = KadiClient.from_env() + # info = client.info() + + """ + # Use a leading-slash (relative) endpoint so ``KadiManager`` prepends + # the configured host *and* base path (for instances served from a + # subpath); hand-building an absolute URL from ``self._host`` would + # drop the base path. + response = self._manager.make_request("/info", "get") + if hasattr(response, "json"): + return response.json() + return {"status": "connected"} + + def fetch_entry(self, entity_id): # pylint: disable=too-many-locals + r""" + Fetch a record from Kadi4Mat and return it as a unitpackage Entry. + + Only records created by unitpackage (those carrying a ``unitpackage`` + metadatum) are supported. Raises ``ValueError`` if the record has no + such metadatum or if the CSV file named in the descriptor is missing. + + EXAMPLES:: + + # client = KadiClient.from_env() + # entry = client.fetch_entry(42) + # entry.df + + """ + record = self._manager.record(id=entity_id) + + record_meta = record.meta if isinstance(record.meta, dict) else {} + title = ( + record_meta.get("title") + or record_meta.get("identifier") + or f"record_{entity_id}" + ) + logger.info("Fetched record %d: %s", entity_id, title) + + # Only records created by unitpackage are supported. + extras = record.meta.get("extras", []) if isinstance(record.meta, dict) else [] + unitpackage_extra = next( + (item for item in extras if item.get("key") == "unitpackage"), + None, + ) + if unitpackage_extra is None: + raise ValueError( + f"Record {entity_id} has no 'unitpackage' metadatum. " + "Only records created by unitpackage are supported." + ) + + if unitpackage_extra.get("type") == "dict": + decoded = _kadi_extras_to_python(unitpackage_extra) + descriptor_dict = decoded if isinstance(decoded, dict) else None + else: + descriptor_dict = json.loads(unitpackage_extra.get("value", "{}")) + + if not isinstance(descriptor_dict, dict): + raise ValueError( + f"Record {entity_id}: failed to decode 'unitpackage' metadatum." + ) + + resource = first_resource(descriptor_dict) + target_csv_name = Path(resource.get("path", "")).name + + # Find the CSV file named in the descriptor. + filelist_response = record.get_filelist() + files = [] + if hasattr(filelist_response, "json"): + data = filelist_response.json() + files = data.get("items", data) if isinstance(data, dict) else data + elif isinstance(filelist_response, list): + files = filelist_response + + csv_file_id = None + csv_file_name = None + for f in files: + name = f.get("name", "") if isinstance(f, dict) else getattr(f, "name", "") + fid = f.get("id", "") if isinstance(f, dict) else getattr(f, "id", "") + if name == target_csv_name: + csv_file_id, csv_file_name = fid, name + break + + if csv_file_id is None: + raise ValueError( + f"CSV file '{target_csv_name}' referenced in the unitpackage descriptor " + f"was not found on record {entity_id}." + ) + + logger.info("Downloading CSV file '%s' (id=%s)", csv_file_name, csv_file_id) + + with tempfile.TemporaryDirectory() as tmpdir: + csv_path = os.path.join(tmpdir, csv_file_name) + record.download_file(file_id=csv_file_id, file_path=csv_path) + df = pd.read_csv(csv_path) + + basename = Path(resource.get("path", "")).stem or f"record_{entity_id}" + entry = Entry.from_df(df=df, basename=basename) + return apply_datapackage_descriptor(entry, json.dumps(descriptor_dict)) + + def fetch_entries(self, tags=None): + r""" + Fetch multiple records from Kadi4Mat as a list of unitpackage + Entry objects. + + Optionally filter by ``tags``, which should be a list of tag strings. + Records without CSV files are skipped with a warning. + + EXAMPLES:: + + # client = KadiClient.from_env() + # entries = client.fetch_entries(tags=["electrochemistry"]) + # len(entries) + + """ + base_kwargs = {} + if tags: + base_kwargs["tags"] = tags + + # The Kadi API paginates search results (10 per page by default), so a + # single request would silently drop every match beyond the first page. + # Walk all pages, requesting the maximum page size the API allows. + records = [] + page = 1 + while True: + response = self._manager.search.search_resources( + "record", page=page, per_page=100, **base_kwargs + ) + + if hasattr(response, "json"): + data = response.json() + else: + data = response + + if isinstance(data, dict): + page_records = data.get("items", []) + total_pages = data.get("_pagination", {}).get("total_pages", page) + elif isinstance(data, list): + page_records = data + total_pages = page + else: + page_records = [] + total_pages = page + + records.extend(page_records) + + if page >= total_pages: + break + page += 1 + + logger.info( + "Found %d records%s", + len(records), + f" matching tags {tags}" if tags else "", + ) + + items = [] + for rec in records: + rec_id = ( + rec.get("id") if isinstance(rec, dict) else getattr(rec, "id", None) + ) + rec_title = ( + rec.get("title", "") + if isinstance(rec, dict) + else getattr(rec, "title", "") + ) + if rec_id is None: + logger.warning("Skipping search result without a record id: %r", rec) + continue + items.append((rec_id, rec_title)) + + return self._collect_entries(items, "record") + + def upload_entry(self, entry, title=None, tags=None): + r""" + Upload a unitpackage Entry to Kadi4Mat as a new record. + + Creates a new record, uploads the CSV data as a file attachment, + and sets metadata from the entry. + + Returns the ID of the newly created record. + + EXAMPLES:: + + # from unitpackage.entry import Entry + # import pandas as pd + # df = pd.DataFrame({"t": [0, 1, 2], "E": [0.1, 0.2, 0.3]}) + # entry = Entry.from_df(df=df, basename="my_record") + # client = KadiClient.from_env() + # entity_id = client.upload_entry(entry, title="My Record") + + """ + record_title = title or entry.identifier + identifier = re.sub(r"[^a-zA-Z0-9_-]", "_", record_title).lower().strip("_") + if not identifier: + identifier = f"unitpackage_{entry.identifier}" + + record = self._manager.record( + identifier=identifier, + title=record_title, + create=True, + ) + + # ``create=True`` only creates a record when the identifier is free; + # if one already exists, kadi-apy hands back that existing record + # (``created`` is then False). Refuse to mutate a record we did not + # create so an unrelated record is never clobbered. We raise before + # uploading anything, so there is nothing to roll back. + if not getattr(record, "created", True): + raise ValueError( + f"A Kadi4Mat record with identifier '{identifier}' already " + "exists. Refusing to modify it; choose a different title or " + "remove the existing record first." + ) + + entity_id = ( + record.meta.get("id", None) if isinstance(record.meta, dict) else None + ) + if entity_id is None: + logger.warning( + "Could not determine ID for created Kadi4Mat record '%s'", record_title + ) + logger.info("Created record: %s (id=%s)", record_title, entity_id) + + try: + # Upload CSV data as file attachment. The filename must match the + # ``path`` field in the datapackage descriptor so that the two can be + # reliably linked when the record is fetched back. + with tempfile.TemporaryDirectory() as tmpdir: + csv_path = os.path.join(tmpdir, f"{entry.identifier}.csv") + entry.df.to_csv(csv_path, index=False) + record.upload_file(file_path=csv_path) + logger.info("Uploaded CSV data for record %s", entity_id) + + # Store the full datapackage descriptor (schema + metadata) as a + # nested Kadi dict metadatum so it is human-readable in the Kadi UI. + descriptor = build_datapackage_descriptor(entry) + record.add_metadatum( + metadatum=_dict_to_kadi_extras(descriptor, key="unitpackage") + ) + logger.info("Set datapackage descriptor on record %s", entity_id) + + # Add tags + if tags: + for tag in tags: + record.add_tag(tag) + logger.info("Added tags %s to record %s", tags, entity_id) + except Exception: # pylint: disable=broad-except + # A record without the ``unitpackage`` metadatum can never be + # fetched back, so leaving it behind would orphan it. Roll back the + # partially created record before re-raising. + logger.warning( + "Upload failed, deleting partially created record %s", entity_id + ) + try: + record.delete() + except Exception: # pylint: disable=broad-except + logger.error( + "Failed to clean up record %s after upload error", entity_id + ) + raise + + return entity_id diff --git a/unitpackage/eln/kadi_cli.py b/unitpackage/eln/kadi_cli.py new file mode 100644 index 00000000..bd9995ad --- /dev/null +++ b/unitpackage/eln/kadi_cli.py @@ -0,0 +1,126 @@ +r""" +CLI commands for Kadi4Mat integration. + +Provides commands to interact with a Kadi4Mat electronic lab notebook instance, +including fetching records as datapackages, exporting collections, and uploading +local datapackages. + +The command group itself is built by ``unitpackage.eln._cli.make_eln_group``, +which is shared with the eLabFTW CLI; only the Kadi4Mat-specific wiring (the +``--pat`` option, the client class, and how instance info is printed) lives +here. + +EXAMPLES:: + + >>> from unitpackage.test.cli import invoke + >>> from unitpackage.eln.kadi_cli import kadi + >>> invoke(kadi, "--help") # doctest: +NORMALIZE_WHITESPACE + Usage: kadi [OPTIONS] COMMAND [ARGS]... + + Interact with a Kadi4Mat electronic lab notebook instance. + + Credentials are resolved in order: CLI options, environment variables + (KADI_HOST / KADI_PAT), then the configuration file + (~/.config/unitpackage/config.toml). + + Options: + --host TEXT Kadi4Mat server URL + --pat TEXT Kadi4Mat Personal Access Token + --no-verify-ssl Disable SSL certificate verification + --profile TEXT Configuration profile name + --help Show this message and exit. + + Commands: + config Manage Kadi4Mat connection profiles. + export Fetch multiple records from Kadi4Mat filtered by tags. + fetch Fetch a single record from Kadi4Mat and save as a datapackage. + info Show Kadi4Mat instance information. + upload Upload a local datapackage to Kadi4Mat. + +""" + +# ******************************************************************** +# This file is part of unitpackage. +# +# Copyright (C) 2026 Johannes Hermann +# +# unitpackage is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# unitpackage is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with unitpackage. If not, see . +# ******************************************************************** +import logging + +import click + +from unitpackage.eln._cli import make_eln_group +from unitpackage.eln._config_cli import make_config_group + +logger = logging.getLogger("unitpackage") + + +def _create_client(host, secret, verify_ssl, settings): + """Create a KadiClient (imported lazily so the optional ``kadi-apy`` + dependency is only required when the Kadi4Mat CLI is actually used).""" + try: + from unitpackage.eln.kadi import KadiClient + except ImportError as e: + raise RuntimeError( + "The kadi-apy package is required for Kadi4Mat integration. " + "Install it with: pip install kadi-apy" + ) from e + + return KadiClient( + host=host, + pat=secret, + verify_ssl=verify_ssl, + base_path=settings.get("base_path"), + ) + + +def _print_info(server_info): + """Print Kadi4Mat instance information as ``key: value`` lines.""" + if isinstance(server_info, dict): + for key, value in server_info.items(): + click.echo(f"{key}: {value}") + else: + click.echo(f"Connected: {server_info}") + + +kadi = make_eln_group( + name="kadi", + display="Kadi4Mat", + article="a Kadi4Mat", + noun_singular="record", + noun_plural="records", + secret_opt="--pat", + secret_envvar="KADI_PAT", + secret_help="Kadi4Mat Personal Access Token", + secret_label="PAT", + secret_config_key="pat", + host_envvar="KADI_HOST", + section_hint="a [kadi.]", + client_factory=_create_client, + info_printer=_print_info, + info_help="Show Kadi4Mat instance information.", + info_error_label="instance", +) + +# Register the config management subgroup. +kadi.add_command( + make_config_group( + backend="kadi", + display="Kadi4Mat", + secret_opt="--pat", + secret_field="pat", + secret_label="Personal Access Token", + ) +) diff --git a/unitpackage/entrypoint.py b/unitpackage/entrypoint.py index 1c96c556..c6042607 100644 --- a/unitpackage/entrypoint.py +++ b/unitpackage/entrypoint.py @@ -4,7 +4,7 @@ EXAMPLES:: >>> from unitpackage.test.cli import invoke - >>> invoke(cli, "--help") # doctest: +NORMALIZE_WHITESPACE + >>> invoke(cli, "--help") # doctest: +NORMALIZE_WHITESPACE +ELLIPSIS Usage: cli [OPTIONS] COMMAND [ARGS]... The unitpackage suite @@ -12,8 +12,9 @@ Options: --help Show this message and exit. Commands: - csv Convert a file containing CSV data into a datapackage. - ec Convert an electrochemistry file into an echemdb datapackage. + csv Convert a file containing CSV data into a datapackage. + ec Convert an electrochemistry file into an echemdb datapackage. + elabftw Interact with an eLabFTW ... """ @@ -37,6 +38,7 @@ # You should have received a copy of the GNU General Public License # along with unitpackage. If not, see . # ******************************************************************** +import importlib import logging from pathlib import Path @@ -139,6 +141,36 @@ def convert(csv, device, outdir, metadata): cli.add_command(convert) +if importlib.util.find_spec("elabapi_python") is None: + + @click.command(name="elabftw") + def elabftw(**kwargs): + """ + Interact with an eLabFTW instance (needs unitpackage[elabftw]). + + """ + raise ImportError("Install `unitpackage[elabftw]` to use this feature.") + +else: + from unitpackage.eln.elabftw_cli import elabftw + +cli.add_command(elabftw) + +if importlib.util.find_spec("kadi_apy") is None: + + @click.command(name="kadi") + def kadi(**kwargs): + """ + Interact with a Kadi4Mat instance (needs unitpackage[kadi]). + + """ + raise ImportError("Install `unitpackage[kadi]` to use this feature.") + +else: + from unitpackage.eln.kadi_cli import kadi + +cli.add_command(kadi) + # Register command docstrings for doctesting. # Since commands are not functions anymore due to their decorator, their diff --git a/unitpackage/metadata.py b/unitpackage/metadata.py index 554dc4c1..be5ae8d7 100644 --- a/unitpackage/metadata.py +++ b/unitpackage/metadata.py @@ -28,7 +28,7 @@ # ******************************************************************** # This file is part of unitpackage. # -# Copyright (C) 2026 Albert Engstfeld +# Copyright (C) 2026 Johannes Hermann # # unitpackage is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/unitpackage/test/eln/__init__.py b/unitpackage/test/eln/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/unitpackage/test/eln/conftest.py b/unitpackage/test/eln/conftest.py new file mode 100644 index 00000000..0e3d0ab1 --- /dev/null +++ b/unitpackage/test/eln/conftest.py @@ -0,0 +1,96 @@ +""" +Pytest configuration for ELN integration tests. + +Registers custom markers so tests can be selected/deselected +via ``pytest -m elabftw`` or ``pytest -m kadi``. +""" + +import sys +from unittest.mock import MagicMock + +import pandas as pd +import pytest + +from unitpackage.test.eln.fixture_loader import CSV_DATA + + +def pytest_configure(config): + """Register pytest markers for eLabFTW tests.""" + config.addinivalue_line( + "markers", + "elabftw: marks tests for the eLabFTW integration module", + ) + config.addinivalue_line( + "markers", + "kadi: marks tests for the Kadi4Mat integration module", + ) + + +# --------------------------------------------------------------------------- +# Provide a fake elabapi_python module so tests can run without the real +# package installed. The fake module is injected into sys.modules before any +# test imports unitpackage.elabftw. +# --------------------------------------------------------------------------- + +_fake_elabapi = MagicMock() +# Ensure commonly accessed classes are stable Mock objects +_fake_elabapi.Configuration = MagicMock +_fake_elabapi.ApiClient = MagicMock +_fake_elabapi.InfoApi = MagicMock +_fake_elabapi.ExperimentsApi = MagicMock +_fake_elabapi.ItemsApi = MagicMock +_fake_elabapi.UploadsApi = MagicMock +# Set __spec__ so importlib.util.find_spec() works +_fake_elabapi.__spec__ = MagicMock() + +sys.modules.setdefault("elabapi_python", _fake_elabapi) + +# --------------------------------------------------------------------------- +# Provide a fake kadi_apy module so kadi tests can run without the real +# package installed. +# --------------------------------------------------------------------------- + +_fake_kadi_apy = MagicMock() +_fake_kadi_apy.KadiManager = MagicMock +_fake_kadi_apy.__spec__ = MagicMock() + +sys.modules.setdefault("kadi_apy", _fake_kadi_apy) + +# --------------------------------------------------------------------------- +# Shared fixtures available to all ELN backend tests +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def csv_bytes(): + """CSV test data as bytes.""" + return CSV_DATA + + +@pytest.fixture() +def sample_df(): + """A small DataFrame matching CSV_DATA.""" + return pd.DataFrame( + { + "t": [0.0, 0.01, 0.02], + "E": [-0.103, -0.102, -0.101], + "j": [43.01, 51.41, 42.89], + } + ) + + +@pytest.fixture() +def sample_entry(sample_df): # pylint: disable=redefined-outer-name + """A unitpackage Entry built from the sample DataFrame.""" + from unitpackage.entry import Entry + + entry = Entry.from_df(df=sample_df, basename="test_entry") + entry = entry.update_fields( + fields=[ + {"name": "t", "unit": "s"}, + {"name": "E", "unit": "V"}, + {"name": "j", "unit": "A / m2"}, + ] + ) + entry.metadata.from_dict({"echemdb": {"source": {"citationKey": "test_2026"}}}) + return entry diff --git a/unitpackage/test/eln/fixture_loader.py b/unitpackage/test/eln/fixture_loader.py new file mode 100644 index 00000000..fc587a4e --- /dev/null +++ b/unitpackage/test/eln/fixture_loader.py @@ -0,0 +1,297 @@ +""" +Utilities for loading recorded ELN API fixtures into Mock objects. + +Provides helper functions that read fixture JSON files produced by +``record_fixtures.py`` and construct ``unittest.mock.Mock`` objects +for use in ``test_elabftw.py`` and ``test_kadi.py``. + +Typical usage in tests:: + + from test.fixture_loader import get_versions, load_fixture + + @pytest.fixture(params=get_versions("elabftw")) + def elabftw_version(request): + return request.param + + def test_with_recorded_data(elabftw_version): + csv_data = get_csv_bytes("elabftw", elabftw_version) + ... + +""" + +import base64 +import json +from pathlib import Path +from unittest.mock import Mock + +# Fixtures live in the project's root ``test/`` data tree (alongside +# ``test/loader_data`` and ``test/generated``). Anchor to the repo root the +# same way ``unitpackage.test.cli`` locates ``test/loader_data`` --- relative +# to the installed package --- rather than depending on an environment +# variable. This file sits at ``/unitpackage/test/eln/``, so the +# repo root is four levels up. +FIXTURES_DIR = Path(__file__).resolve().parents[3] / "test" / "eln-fixtures" + +CSV_DATA = b"t,E,j\n0.0,-0.103,43.01\n0.01,-0.102,51.41\n0.02,-0.101,42.89\n" + +#: Column names of :data:`CSV_DATA`. +SAMPLE_COLUMNS = ["t", "E", "j"] + +#: Canonical schema fields (with units) matching :data:`CSV_DATA`, shared by +#: the eLabFTW and Kadi4Mat tests when building ``unitpackage`` descriptors. +SAMPLE_FIELDS = [ + {"name": "t", "type": "number", "unit": "s"}, + {"name": "E", "type": "number", "unit": "V"}, + {"name": "j", "type": "number", "unit": "A / m2"}, +] + +#: Expected ``{name: unit}`` mapping after a descriptor round-trip. +SAMPLE_UNITS = {field["name"]: field["unit"] for field in SAMPLE_FIELDS} + +#: Canonical frictionless datapackage descriptor (one CSV resource matching +#: :data:`CSV_DATA`). Shared by the eLabFTW and Kadi4Mat tests, which each wrap +#: it in their backend's own metadata storage format: eLabFTW nests it under a +#: ``"unitpackage"`` key in the entity's JSON ``metadata`` field, while Kadi4Mat +#: encodes it as a typed ``extras`` tree via ``_dict_to_kadi_extras``. +SAMPLE_DESCRIPTOR = { + "resources": [ + { + "name": "test_entry", + "type": "table", + "path": "test_entry.csv", + "format": "csv", + "mediatype": "text/csv", + "schema": {"fields": SAMPLE_FIELDS}, + "metadata": {"echemdb": {"source": {"citationKey": "test_2026"}}}, + } + ] +} + + +def get_versions(backend): + """ + Return a sorted list of recorded version strings. + + Returns + ------- + list of str + Version strings (e.g., ``["5.4.1"]``), sorted ascending. + + """ + backend_dir = FIXTURES_DIR / backend + if not backend_dir.is_dir(): + return [] + return [ + p.name + for p in sorted(backend_dir.iterdir()) + if p.is_dir() and not p.name.startswith(".") + ] + + +def get_latest_version(backend): + """ + Return the most recent recorded version string. + + Returns ``None`` if no fixtures exist. + """ + versions = get_versions(backend) + return versions[-1] if versions else None + + +def load_fixture(backend, version, operation): + """ + Load a single fixture JSON file and return its contents as a dict. + + Parameters + ---------- + backend : str + The backend name (``"elabftw"``). + version : str + The ELN version string (directory name under the backend). + operation : str + The operation name (filename without ``.json``), e.g., + ``"info"``, ``"patch_metadata"``, ``"list_uploads"``. + + Returns + ------- + dict + The parsed JSON contents. + + Raises + ------ + FileNotFoundError + If the fixture file does not exist. + + """ + path = FIXTURES_DIR / backend / version / f"{operation}.json" + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def load_manifest(backend, version): + """Load the ``manifest.json`` for a given backend and version.""" + return load_fixture(backend, version, "manifest") + + +def get_csv_bytes(backend, version=None): + """ + Return the recorded CSV data as ``bytes``. + + Decodes the base64-encoded CSV content from the ``download_csv.json`` + fixture file. + + Parameters + ---------- + backend : str + The backend name (``"elabftw"``). + version : str or None + The ELN version string. If ``None``, uses the latest version. + + Returns + ------- + bytes + The raw CSV file content. + + """ + version = version or get_latest_version(backend) + if version is None: + raise FileNotFoundError(f"No fixtures found for backend '{backend}'") + data = load_fixture(backend, version, "download_csv") + response = data.get("response", {}) + b64 = response.get("data_base64") + if b64: + return base64.b64decode(b64) + return ( + response.get("data", b"").encode("utf-8") + if isinstance(response.get("data"), str) + else b"" + ) + + +def make_mock_uploads(backend, version=None): + """ + Build a list of ``Mock`` upload objects from the recorded + ``list_uploads.json`` fixture. + + Parameters + ---------- + backend : str + The backend name (``"elabftw"``). + version : str or None + The ELN version string. If ``None``, uses the latest version. + + Returns + ------- + list of unittest.mock.Mock + + """ + version = version or get_latest_version(backend) + if version is None: + raise FileNotFoundError(f"No fixtures found for backend '{backend}'") + + data = load_fixture(backend, version, "list_uploads") + response = data.get("response", []) + + if not isinstance(response, list): + response = [response] + + mocks = [] + for item in response: + upload = Mock() + for key, value in item.items(): + setattr(upload, key, value) + mocks.append(upload) + + return mocks + + +def make_mock_kadi_record(version, csv_bytes, backend="kadi"): + """ + Build a ``Mock`` Kadi4Mat record from recorded fixture data. + + Wires up ``get_filelist()`` from ``list_uploads.json`` and + ``download_file()`` to write *csv_bytes* to the requested path, + mirroring the behaviour of a real ``kadi_apy`` record object. + + Parameters + ---------- + version : str + The Kadi4Mat version string (directory name under the backend). + csv_bytes : bytes + Raw CSV content written by the ``download_file`` mock. + backend : str + The backend name (default ``"kadi"``). + + Returns + ------- + unittest.mock.Mock + + """ + entity_data = load_fixture(backend, version, "get_entity") + response = entity_data["response"] + + uploads_data = load_fixture(backend, version, "list_uploads") + filelist_response = uploads_data["response"] + + record = Mock() + record.id = response["id"] + record.title = response["title"] + record.identifier = response["identifier"] + record.meta = response + + filelist_mock = Mock() + filelist_mock.json.return_value = filelist_response + record.get_filelist.return_value = filelist_mock + + def _download(file_id, file_path): # pylint: disable=unused-argument + # ``file_id`` is part of the real ``record.download_file`` signature + # (callers pass it by keyword) but is unused by this fixture mock. + with open(file_path, "wb") as fh: + fh.write(csv_bytes) + + record.download_file = _download + return record + + +def make_field_dict(entry): + """ + Return a ``{name: field_descriptor}`` dict for an Entry's fields. + + Works with both frictionless ``Field`` objects (which have + ``.to_dict()``) and plain dicts. + + Parameters + ---------- + entry : unitpackage.entry.Entry + + Returns + ------- + dict + + """ + + def _as_dict(f): + return f.to_dict() if hasattr(f, "to_dict") else f + + return {_as_dict(f)["name"]: _as_dict(f) for f in entry.fields} + + +def assert_basic_entry(entry): + """Assert *entry* is a well-formed Entry with the sample CSV data. + + Shared by the eLabFTW and Kadi4Mat ``fetch_entry`` tests. + """ + assert entry is not None + assert hasattr(entry, "df") + assert list(entry.df.columns) == SAMPLE_COLUMNS + assert len(entry.df) == 3 + + +def assert_field_units(entry): + """Assert *entry*'s field units match :data:`SAMPLE_UNITS`. + + Shared by the eLabFTW and Kadi4Mat round-trip / fixture tests. + """ + field_dict = make_field_dict(entry) + for name, unit in SAMPLE_UNITS.items(): + assert field_dict[name].get("unit") == unit diff --git a/unitpackage/test/eln/record_fixtures.py b/unitpackage/test/eln/record_fixtures.py new file mode 100644 index 00000000..695dd984 --- /dev/null +++ b/unitpackage/test/eln/record_fixtures.py @@ -0,0 +1,1000 @@ +#!/usr/bin/env python +# This is a developer tool, run manually against a live ELN server to record +# API fixtures -- not part of the test suite. Several pylint checks are +# intentionally disabled for the whole module: +# - broad-exception-caught: the recorder catches all errors so it can always +# clean up the live test entity before re-raising. +# - protected-access: it deliberately instruments client internals +# (``_manager``, ``_api_client``) to capture raw API responses. +# - too-many-{locals,statements,return-statements}: inherent to the linear +# "record every step" workflows. +# - no-else-raise: the ``try/except/raise/else`` blocks are clearer with the +# ``else`` (post-success steps) kept explicit. +# - wrong-import-position: ``unitpackage`` is imported after a ``sys.path`` +# bootstrap so the script runs from a source checkout. +# - missing-function-docstring / unused-argument: backend hook callbacks with +# fixed signatures. +# pylint: disable=duplicate-code,broad-exception-caught,protected-access +# pylint: disable=too-many-locals,too-many-statements,too-many-return-statements +# pylint: disable=no-else-raise,wrong-import-position,missing-function-docstring +# pylint: disable=unused-argument +""" +Record API fixtures from a real ELN instance for mock-based testing. + +Connects to a live ELN instance (eLabFTW or Kadi4Mat), performs the +standard unitpackage operations (info, upload, fetch_entry, fetch_entries), +and records the raw API responses as JSON fixture files. Fixtures are +saved in versioned directories so that API changes across ELN releases +can be tracked and tested against. + +Usage:: + + # eLabFTW -- version auto-detected from server info + python test/record_fixtures.py elabftw + + # eLabFTW -- with explicit credentials + python test/record_fixtures.py elabftw --host https://eln.example.org --api-key KEY + + # Kadi4Mat -- version auto-detected from server info + python test/record_fixtures.py kadi + + # Kadi4Mat -- with explicit credentials + python test/record_fixtures.py kadi --host https://kadi4mat.example.edu --pat TOKEN + + # Keep the test entity on the server + python test/record_fixtures.py elabftw --no-cleanup + +Credentials are resolved in this order: + 1. CLI options (--host, --api-key / --pat) + 2. Environment variables (ELABFTW_HOST/ELABFTW_API_KEY or KADI_HOST/KADI_PAT) + 3. Configuration file (~/.config/unitpackage/config.toml, via --profile) +""" + +import abc +import base64 +import io +import json +import logging +import os +import re +import sys +import tempfile +import uuid +from datetime import datetime, timezone +from functools import wraps +from pathlib import Path + +import click +import pandas as pd + +# Ensure the unitpackage package is importable when running this script +# directly. This file sits at ``/unitpackage/test/eln/``, so the +# repo root (which contains the ``unitpackage`` package) is four levels up. +_REPO_ROOT = Path(__file__).resolve().parents[3] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +from unitpackage.entry import ( # noqa: E402 # pylint: disable=wrong-import-position + Entry, +) + +logger = logging.getLogger("unitpackage.record_fixtures") + +# Fixtures live at ``/test/eln-fixtures``, anchored to the repo root +# derived above (consistent with ``unitpackage.test.eln.fixture_loader``). +FIXTURES_DIR = _REPO_ROOT / "test" / "eln-fixtures" + +# --------------------------------------------------------------------------- +# Standard test data +# --------------------------------------------------------------------------- + +TEST_CSV_DATA = "t,E,j\n0.0,-0.103,43.01\n0.01,-0.102,51.41\n0.02,-0.101,42.89\n" +TEST_FIELDS = [ + {"name": "t", "unit": "s"}, + {"name": "E", "unit": "V"}, + {"name": "j", "unit": "A / m2"}, +] +TEST_METADATA = {"echemdb": {"source": {"citationKey": "fixture_test_2026"}}} +TEST_TITLE_PREFIX = "unitpackage_fixture_test" +TEST_TITLE_RE = re.compile(r"^unitpackage_fixture_test_[0-9a-f]{8}$") +TEST_TAGS = ["unitpackage-test"] + + +def _build_test_entry(): + """Build the standard test Entry used for fixture recording.""" + df = pd.read_csv(io.StringIO(TEST_CSV_DATA)) + entry = Entry.from_df(df=df, basename="fixture_test") + entry = entry.update_fields(fields=TEST_FIELDS) + entry.metadata.from_dict(TEST_METADATA) + return entry + + +# --------------------------------------------------------------------------- +# Serialization helpers +# --------------------------------------------------------------------------- + + +def _save_json(data, directory, filename): + """Write *data* as pretty-printed JSON to *directory*/*filename*.""" + os.makedirs(directory, exist_ok=True) + path = os.path.join(directory, filename) + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2, default=str, ensure_ascii=False) + logger.info("Saved %s", path) + + +def _redact(data, replacements): + """Recursively replace sensitive strings in fixture data. + + *replacements* is a list of ``(sensitive, placeholder)`` pairs. + Only non-empty *sensitive* strings are replaced. Apply the most + specific replacements first (e.g., full name before first/last name). + """ + if not replacements: + return data + if isinstance(data, str): + for sensitive, placeholder in replacements: + if sensitive: + data = data.replace(sensitive, placeholder) + return data + if isinstance(data, dict): + return {k: _redact(v, replacements) for k, v in data.items()} + if isinstance(data, list): + return [_redact(item, replacements) for item in data] + return data + + +def _redact_directory(version_dir, replacements): + """Apply *replacements* to all JSON files in *version_dir* in-place.""" + if not replacements: + return + for json_path in sorted(Path(version_dir).glob("*.json")): + with open(json_path, encoding="utf-8") as fh: + data = json.load(fh) + redacted = _redact(data, replacements) + with open(json_path, "w", encoding="utf-8") as fh: + json.dump(redacted, fh, indent=2, default=str, ensure_ascii=False) + logger.debug("Redacted %s", json_path) + + +def _serialize(obj): + """Best-effort serialization of an API response object to a JSON-safe value.""" + match obj: + case None: + return None + case str() | int() | float() | bool(): + return obj + case bytes(): + return base64.b64encode(obj).decode("ascii") + case dict(): + return {k: _serialize(v) for k, v in obj.items()} + case list() | tuple(): + return [_serialize(item) for item in obj] + case _ if hasattr(obj, "to_dict"): + return obj.to_dict() + case _ if hasattr(obj, "__dict__"): + return { + k: _serialize(v) + for k, v in obj.__dict__.items() + if not k.startswith("_") + } + case _: + return str(obj) + + +# --------------------------------------------------------------------------- +# Recording wrapper helpers +# --------------------------------------------------------------------------- + + +def _unwrap(obj, method_name): + """Restore *obj.method_name* to the original if it was previously wrapped.""" + current = getattr(obj, method_name) + original = getattr(current, "_recording_original", None) + if original is not None: + setattr(obj, method_name, original) + + +def _serialize_json_response(result): + """Serialize a response that may expose a ``.json()`` method.""" + if hasattr(result, "json"): + return result.json() + return _serialize(result) + + +def _wrap(obj, method_name, store, operation_name, full_method_name, serializer=None): + """Replace *obj.method_name* with a wrapper that records the response.""" + _unwrap(obj, method_name) + original = getattr(obj, method_name) + serialize_fn = serializer or _serialize + + @wraps(original) + def wrapper(*args, **kwargs): + result = original(*args, **kwargs) + store[operation_name] = { + "operation": operation_name, + "method": full_method_name, + "response": serialize_fn(result), + } + return result + + wrapper._recording_original = original # pylint: disable=protected-access + setattr(obj, method_name, wrapper) + + +def _wrap_binary(obj, method_name, store, operation_name, full_method_name): + """Replace *obj.method_name* with a wrapper that records binary responses.""" + _unwrap(obj, method_name) + original = getattr(obj, method_name) + + @wraps(original) + def wrapper(*args, **kwargs): + result = original(*args, **kwargs) + + data = getattr(result, "data", result) + if isinstance(data, bytes): + serialized = {"data_base64": base64.b64encode(data).decode("ascii")} + else: + serialized = _serialize(data) + + store[operation_name] = { + "operation": operation_name, + "method": full_method_name, + "response": serialized, + } + return result + + wrapper._recording_original = original # pylint: disable=protected-access + setattr(obj, method_name, wrapper) + + +# --------------------------------------------------------------------------- +# Base recorder +# --------------------------------------------------------------------------- + + +class BaseRecorder(abc.ABC): + """Template for ELN fixture recorders. + + Subclasses must set ``backend_name`` and implement: + ``extract_version``, ``create_test_entry``, ``install_hooks``, + ``list_test_entities``, and ``delete_entity``. + """ + + backend_name: str = "" + + @abc.abstractmethod + def extract_version(self, info_dict): + """Extract the ELN version string from an info response dict.""" + + def get_redaction_replacements(self, client, info_dict): + """Return ``(sensitive, placeholder)`` pairs for fixture redaction.""" + return [] + + def _extract_additional_replacements(self, version_dir): + """Return extra redaction pairs derived from recorded fixture files.""" + return [] + + @abc.abstractmethod + def create_test_entry(self, client, version_dir, title): + """Create a test entity on the ELN and return its ID.""" + + @abc.abstractmethod + def install_hooks(self, client, store): + """Install recording hooks on *client* that write into *store*.""" + + def cleanup_leftovers(self, client): + """Delete leftover test entities from previous runs.""" + try: + entities = self.list_test_entities(client) + except Exception as exc: + click.echo(f" WARNING: Could not list leftover entities: {exc}", err=True) + return + + for entity_id, title in entities: + if not TEST_TITLE_RE.match(title or ""): + click.echo( + f" Skipping entity {entity_id} ({title!r}): " + "title does not match fixture pattern" + ) + continue + try: + self.delete_entity(client, entity_id) + click.echo(f" Deleted leftover entity {entity_id} ({title})") + except Exception as exc: + click.echo( + f" WARNING: Failed to delete entity {entity_id}: {exc}", + err=True, + ) + + @abc.abstractmethod + def list_test_entities(self, client): + """Return ``[(entity_id, title), ...]`` for test entities on the server.""" + + @abc.abstractmethod + def delete_entity(self, client, entity_id): + """Delete a single entity from the server.""" + + def record(self, client, outdir, cleanup=True, version_override=None): + """Run the full recording workflow.""" + entity_id = None + captured = {} + + # Phase 0: clean up leftovers from previous runs + click.echo("Cleaning up leftover test entities...") + self.cleanup_leftovers(client) + + try: + # Phase 1: server info + click.echo("Recording server info...") + info_dict = client.info() + click.echo(info_dict) + version = version_override or self.extract_version(info_dict) + version_dir = os.path.join(outdir, version) + _save_json( + {"operation": "info", "method": "client.info", "response": info_dict}, + version_dir, + "info.json", + ) + click.echo(f" ELN version: {version}") + + # Phase 2: create test entry using raw API calls (recorded per-step) + uid = uuid.uuid4().hex[:8] + title = f"{TEST_TITLE_PREFIX}_{uid}" + click.echo(f"Creating test entry via raw API ({title})...") + entity_id = self.create_test_entry(client, version_dir, title) + click.echo(f" Created test entity: {entity_id}") + + # Phase 3: install hooks and fetch entry back + click.echo("Fetching test entity with recording hooks...") + self.install_hooks(client, captured) + fetched = client.fetch_entry(entity_id) + assert fetched is not None, "fetch_entry returned None" + + for op_name, data in captured.items(): + _save_json(data, version_dir, f"{op_name}.json") + click.echo(f" Recorded {len(captured)} API responses") + + # Phase 4: fetch_entries + click.echo("Recording fetch_entries...") + captured_list = {} + self.install_hooks(client, captured_list) + entries = client.fetch_entries(tags=TEST_TAGS) + for op_name, data in captured_list.items(): + _save_json(data, version_dir, f"list_{op_name}.json") + + _save_json( + { + "operation": "fetch_entries", + "method": "client.fetch_entries", + "response": {"count": len(entries)}, + }, + version_dir, + "fetch_entries.json", + ) + click.echo(f" Found {len(entries)} entries via fetch_entries") + + except Exception: + logger.exception("Recording failed") + if entity_id is not None and cleanup: + self._try_cleanup(client, entity_id) + raise + + else: + # Phase 5: cleanup + cleanup_performed = False + if cleanup and entity_id is not None: + cleanup_performed = self._try_cleanup(client, entity_id) + elif entity_id is not None: + click.echo(f" Skipping cleanup (entity {entity_id} kept on server)") + + # Write manifest + try: + from unitpackage import __version__ as up_version + except (ImportError, AttributeError): + up_version = "unknown" + + manifest = { + "backend": self.backend_name, + "version": version, + "recorded_at": datetime.now(timezone.utc).isoformat(), + "unitpackage_version": up_version, + "entity_id": entity_id, + "cleanup_performed": cleanup_performed, + "test_data": { + "csv_columns": ["t", "E", "j"], + "field_units": {f["name"]: f["unit"] for f in TEST_FIELDS}, + "title": title, + }, + } + _save_json(manifest, version_dir, "manifest.json") + + # Redact sensitive information from all fixture files + replacements = self.get_redaction_replacements(client, info_dict) + replacements.extend(self._extract_additional_replacements(version_dir)) + if replacements: + _redact_directory(version_dir, replacements) + click.echo( + f" Redacted {len(replacements)} sensitive value(s) " + "from fixture files" + ) + + click.echo(f"Fixture recording complete: {version_dir}") + + def _try_cleanup(self, client, entity_id): + """Best-effort cleanup of the test entity.""" + try: + self.delete_entity(client, entity_id) + click.echo(f" Cleaned up test entity {entity_id}") + return True + except Exception as exc: + click.echo( + f" WARNING: Cleanup of entity {entity_id} failed: {exc}\n" + " Manual cleanup required.", + err=True, + ) + return False + + +# --------------------------------------------------------------------------- +# eLabFTW recorder +# --------------------------------------------------------------------------- + + +class ElabFTWRecorder(BaseRecorder): + """Record API fixtures from a real eLabFTW instance.""" + + backend_name = "elabftw" + + def get_redaction_replacements( + self, client, info_dict + ): # pylint: disable=unused-argument + """Get sensitive values to redact from fixture files.""" + replacements = [] + # eLabFTW info() does not include the host URL; extract it from the + # API client configuration (strips the /api/v2 suffix). + config_host = getattr( + getattr(getattr(client, "_api_client", None), "configuration", None), + "host", + "", + ) + if config_host: + base_host = re.sub(r"/api/v\d+.*$", "", config_host) + if base_host: + replacements.append((base_host, "https://elabftw.example.org")) + return replacements + + def _extract_additional_replacements(self, version_dir): + """Extract user name fields from the recorded patch_metadata response.""" + replacements = [] + entity_path = Path(version_dir) / "patch_metadata.json" + if not entity_path.exists(): + return replacements + try: + with open(entity_path, encoding="utf-8") as fh: + data = json.load(fh) + resp = data.get("response", {}) + # Full name first (most specific), then parts. + fullname = resp.get("fullname", "") + if fullname: + replacements.append((fullname, "Test User")) + firstname = resp.get("firstname", "") + if firstname: + replacements.append((firstname, "Test")) + lastname = resp.get("lastname", "") + if lastname: + replacements.append((lastname, "User")) + except Exception as exc: # pylint: disable=broad-except + logger.debug( + "Could not extract user fields from patch_metadata.json: %s", exc + ) + return replacements + + def extract_version(self, info_dict): + """Extract eLabFTW version from info response.""" + version = info_dict.get("elabftw_version") or info_dict.get("version") + if not version: + raise ValueError( + "Could not extract version from eLabFTW info response. " + "Use --version to specify it manually." + ) + return str(version) + + def create_test_entry(self, client, version_dir, title): + """Create a test item using raw elabapi_python calls.""" + from unitpackage.eln import build_datapackage_descriptor + + api = client.items_client + + # Step 1: Create the item + body = {"title": title, "tags": list(TEST_TAGS)} + _, status_code, headers = api.post_item_with_http_info(body=body) + + location = headers.get("Location") + entity_id = int(location.split("/").pop()) + + _save_json( + { + "operation": "post_entity", + "method": "ItemsApi.post_item_with_http_info", + "request": {"body": body}, + "response": { + "status_code": status_code, + "location": location, + "entity_id": entity_id, + }, + }, + version_dir, + "post_entity.json", + ) + click.echo(f" POST items -> {entity_id}") + + # Step 2: Upload CSV file + entry = _build_test_entry() + with tempfile.TemporaryDirectory() as tmpdir: + csv_path = os.path.join(tmpdir, f"{entry.identifier}.csv") + entry.df.to_csv(csv_path, index=False) + csv_upload_result = client.uploads_client.post_upload( + "items", + entity_id, + file=csv_path, + comment="Data uploaded from unitpackage", + ) + _save_json( + { + "operation": "post_upload_csv", + "method": "UploadsApi.post_upload", + "request": { + "entity_type": "items", + "entity_id": entity_id, + "comment": "Data uploaded from unitpackage", + }, + "response": _serialize(csv_upload_result), + }, + version_dir, + "post_upload_csv.json", + ) + click.echo(" POST upload (CSV)") + + # Step 3: Store datapackage descriptor in entity metadata + descriptor = build_datapackage_descriptor(entry) + metadata = {"unitpackage": descriptor} + metadata_json = json.dumps(metadata, default=str) + patch_result = api.patch_item({"metadata": metadata_json}, entity_id) + + _save_json( + { + "operation": "patch_metadata", + "method": "ItemsApi.patch_item", + "request": { + "entity_id": entity_id, + "metadata": metadata, + }, + "response": _serialize(patch_result), + }, + version_dir, + "patch_metadata.json", + ) + click.echo(" PATCH metadata (unitpackage descriptor)") + + return entity_id + + def install_hooks(self, client, store): + """Install recording hooks on API client methods.""" + api = client.items_client + _wrap(api, "get_item", store, "get_entity", "ItemsApi.get_item") + _wrap(api, "read_items", store, "list_entities", "ItemsApi.read_items") + + _wrap( + client.uploads_client, + "read_uploads", + store, + "list_uploads", + "UploadsApi.read_uploads", + ) + _wrap_binary( + client.uploads_client, + "read_upload", + store, + "download_csv", + "UploadsApi.read_upload", + ) + + def list_test_entities(self, client): + """List test entities from the server.""" + entities = client.items_client.read_items(tags=list(TEST_TAGS), limit=9999) + return [(e.id, e.title) for e in entities] + + def delete_entity(self, client, entity_id): + """Delete an entity from the server.""" + client.items_client.delete_item(entity_id) + + +# --------------------------------------------------------------------------- +# Kadi4Mat recorder +# --------------------------------------------------------------------------- + + +def _wrap_kadi_record(manager, store, full_method_name="KadiManager.record"): + """Hook ``manager.record()`` to capture the entity and sub-object calls. + + After the real ``record()`` call returns, the wrapper: + 1. Records ``record.meta`` as ``get_entity``. + 2. Hooks ``record.get_filelist()`` to record ``list_uploads``. + 3. Hooks ``record.download_file()`` to record ``download_csv`` + (reads the file back to embed its content as base64). + """ + _unwrap(manager, "record") + original = getattr(manager, "record") + + @wraps(original) + def wrapper(*args, **kwargs): + record = original(*args, **kwargs) + + meta = record.meta if isinstance(record.meta, dict) else {} + store["get_entity"] = { + "operation": "get_entity", + "method": full_method_name, + "response": meta, + } + + _wrap( + record, + "get_filelist", + store, + "list_uploads", + "record.get_filelist", + serializer=_serialize_json_response, + ) + + # Hook download_file — reads the written file to embed CSV as base64. + orig_download = record.download_file + + @wraps(orig_download) + def wrapped_download(*a, **kw): + result = orig_download(*a, **kw) + file_path = kw.get("file_path") or (a[1] if len(a) > 1 else None) + response = { + "args": [str(x) for x in a], + "kwargs": {k: str(v) for k, v in kw.items()}, + } + if file_path and os.path.exists(str(file_path)): + with open(file_path, "rb") as fh: + response["data_base64"] = base64.b64encode(fh.read()).decode( + "ascii" + ) + store["download_csv"] = { + "operation": "download_csv", + "method": "record.download_file", + "response": response, + } + return result + + record.download_file = wrapped_download + + return record + + wrapper._recording_original = original + setattr(manager, "record", wrapper) + + +class KadiRecorder(BaseRecorder): + """Record API fixtures from a real Kadi4Mat instance.""" + + backend_name = "kadi" + + def extract_version(self, info_dict): + version = info_dict.get("version") or info_dict.get("kadi_version") + + if not version: + raise ValueError( + "Could not extract version from Kadi4Mat info response. " + "Use --version to specify it manually." + ) + return str(version) + + def get_redaction_replacements(self, client, info_dict): + replacements = [] + host = getattr(client, "_host", "") + if host: + replacements.append((host, "https://kadi.example.org")) + return replacements + + def _extract_additional_replacements(self, version_dir): + """Extract user fields from the recorded get_entity response.""" + replacements = [] + entity_path = Path(version_dir) / "get_entity.json" + if not entity_path.exists(): + return replacements + try: + with open(entity_path, encoding="utf-8") as fh: + data = json.load(fh) + resp = data.get("response", {}) + creator = resp.get("creator", {}) + displayname = creator.get("displayname", "") + if displayname: + replacements.append((displayname, "testuser")) + identity = creator.get("identity", {}) + email = identity.get("email", "") + if email: + replacements.append((email, "test@example.org")) + username = identity.get("username", "") + if username and username != displayname: + replacements.append((username, "testuser")) + except Exception as exc: + logger.debug("Could not extract user fields from get_entity.json: %s", exc) + return replacements + + def create_test_entry(self, client, version_dir, title): + """Create a test record on the Kadi4Mat instance.""" + from unitpackage.eln import build_datapackage_descriptor + from unitpackage.eln.kadi import _dict_to_kadi_extras + + identifier = re.sub(r"[^a-zA-Z0-9_-]", "_", title).lower().strip("_") + + # Step 1: Create the record + record = client._manager.record( + identifier=identifier, + title=title, + create=True, + ) + record_meta = record.meta if isinstance(record.meta, dict) else {} + record_id = record_meta.get("id") + + _save_json( + { + "operation": "create_record", + "method": "KadiManager.record", + "request": {"identifier": identifier, "title": title, "create": True}, + "response": _serialize(record_meta), + }, + version_dir, + "create_record.json", + ) + click.echo(f" CREATE record -> {record_id}") + + # Step 2: Upload CSV file + entry = _build_test_entry() + with tempfile.TemporaryDirectory() as tmpdir: + csv_path = os.path.join(tmpdir, f"{entry.identifier}.csv") + entry.df.to_csv(csv_path, index=False) + upload_result = record.upload_file(file_path=csv_path) + _save_json( + { + "operation": "upload_file_csv", + "method": "record.upload_file", + "request": {"file_path": f"{entry.identifier}.csv"}, + "response": _serialize(upload_result), + }, + version_dir, + "upload_file_csv.json", + ) + click.echo(" UPLOAD file (CSV)") + + # Step 3: Store datapackage descriptor as Kadi extras metadatum + descriptor = build_datapackage_descriptor(entry) + metadatum = _dict_to_kadi_extras(descriptor, key="unitpackage") + add_result = record.add_metadatum(metadatum=metadatum) + _save_json( + { + "operation": "add_metadatum_datapackage", + "method": "record.add_metadatum", + "request": {"metadatum": metadatum}, + "response": _serialize(add_result), + }, + version_dir, + "add_metadatum_datapackage.json", + ) + click.echo(" ADD metadatum (datapackage descriptor)") + + # Step 4: Add tags + for tag in TEST_TAGS: + record.add_tag(tag) + click.echo(f" ADD tags {TEST_TAGS}") + + return record_id + + def install_hooks(self, client, store): + # Hook manager.record() — this also hooks get_filelist and + # download_file on the returned record object. + _wrap_kadi_record(client._manager, store) + + # Hook search.search_resources for fetch_entries (list_entities). + if hasattr(client._manager, "search"): + _wrap( + client._manager.search, + "search_resources", + store, + "list_entities", + "search.search_resources", + serializer=_serialize_json_response, + ) + + def list_test_entities(self, client): + response = client._manager.search.search_resources( + "record", + tags=TEST_TAGS, + ) + records = [] + if hasattr(response, "json"): + data = response.json() + items = data.get("items", data) if isinstance(data, dict) else data + elif isinstance(response, list): + items = response + else: + items = [] + + for item in items: + rec_id = ( + item.get("id") if isinstance(item, dict) else getattr(item, "id", None) + ) + rec_title = ( + item.get("title", "") + if isinstance(item, dict) + else getattr(item, "title", "") + ) + if rec_id is not None: + records.append((rec_id, rec_title)) + return records + + def delete_entity(self, client, entity_id): + record = client._manager.record(id=entity_id) + record.delete() + + +# --------------------------------------------------------------------------- +# Client resolution helpers +# --------------------------------------------------------------------------- + + +_BACKEND_INFO = { + "elabftw": { + "import": ("unitpackage.eln.elabftw", "ElabFTWClient"), + "cred_kwarg": "api_key", + "env_hint": "ELABFTW_HOST/ELABFTW_API_KEY", + }, + "kadi": { + "import": ("unitpackage.eln.kadi", "KadiClient"), + "cred_kwarg": "pat", + "env_hint": "KADI_HOST/KADI_PAT", + }, +} + + +def _resolve_client(backend, host, credential, no_verify_ssl, profile): + """Resolve an ELN client from CLI args, env vars, or config profile.""" + import importlib + + info = _BACKEND_INFO[backend] + mod = importlib.import_module(info["import"][0]) + client_cls = getattr(mod, info["import"][1]) + cred_kwarg = info["cred_kwarg"] + + if host and credential: + return client_cls( + host=host, **{cred_kwarg: credential}, verify_ssl=not no_verify_ssl + ) + + from unitpackage.config import config_path, get_profile + + settings = get_profile(backend, profile=profile) + if not settings: + cred_flag = cred_kwarg.replace("_", "-") + raise click.UsageError( + f"Missing {backend} credentials. Provide --host and --{cred_flag}, " + f"set {info['env_hint']} environment variables, " + f"or add a [{backend}.] section to {config_path()}." + ) + + resolved_host = host or settings.get("host") + resolved_cred = credential or settings.get(cred_kwarg) + if not resolved_host or not resolved_cred: + raise click.UsageError( + f"Missing host or {cred_kwarg} in {backend} credentials." + ) + + verify_ssl = ( + not no_verify_ssl if no_verify_ssl else settings.get("verify_ssl", True) + ) + return client_cls( + host=resolved_host, **{cred_kwarg: resolved_cred}, verify_ssl=verify_ssl + ) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def _common_options(f): + """Shared Click options for recorder subcommands.""" + f = click.option("--outdir", default=None, help="Output directory")(f) + f = click.option( + "--no-cleanup", + is_flag=True, + default=False, + help="Keep test entity on server", + )(f) + f = click.option( + "--version", + "version_override", + default=None, + help="Override detected version", + )(f) + f = click.option("--profile", default=None, help="Config profile name")(f) + f = click.option( + "--no-verify-ssl", + is_flag=True, + default=False, + help="Disable SSL verification", + )(f) + f = click.option( + "-v", + "--verbose", + is_flag=True, + help="Enable verbose logging", + )(f) + return f + + +@click.group() +def cli(): + """Record API fixtures from a real ELN instance for mock-based testing.""" + + +@cli.command() +@_common_options +@click.option("--host", envvar="ELABFTW_HOST", default=None, help="eLabFTW server URL") +@click.option( + "--api-key", envvar="ELABFTW_API_KEY", default=None, help="eLabFTW API key" +) +def elabftw( + verbose, host, api_key, no_verify_ssl, profile, version_override, no_cleanup, outdir +): + """Record API fixtures from a real eLabFTW instance.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=level, format="%(levelname)s: %(message)s") + + client = _resolve_client("elabftw", host, api_key, no_verify_ssl, profile) + outdir = outdir or str(FIXTURES_DIR / "elabftw") + try: + ElabFTWRecorder().record( + client, + outdir, + cleanup=not no_cleanup, + version_override=version_override, + ) + finally: + # Explicitly shut down the thread pool so Python's atexit doesn't + # hit a closed file descriptor in ApiClient.__del__. + pool = getattr(client._api_client, "pool", None) + if pool is not None: + pool.close() + pool.join() + + +@cli.command() +@_common_options +@click.option("--host", envvar="KADI_HOST", default=None, help="Kadi4Mat server URL") +@click.option( + "--pat", envvar="KADI_PAT", default=None, help="Kadi4Mat personal access token" +) +def kadi( + verbose, host, pat, no_verify_ssl, profile, version_override, no_cleanup, outdir +): + """Record API fixtures from a real Kadi4Mat instance.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig(level=level, format="%(levelname)s: %(message)s") + + client = _resolve_client("kadi", host, pat, no_verify_ssl, profile) + outdir = outdir or str(FIXTURES_DIR / "kadi") + KadiRecorder().record( + client, + outdir, + cleanup=not no_cleanup, + version_override=version_override, + ) + + +if __name__ == "__main__": + cli() # pylint: disable=no-value-for-parameter diff --git a/unitpackage/test/eln/test_cli_commands.py b/unitpackage/test/eln/test_cli_commands.py new file mode 100644 index 00000000..da7c1596 --- /dev/null +++ b/unitpackage/test/eln/test_cli_commands.py @@ -0,0 +1,230 @@ +""" +Tests for the shared ELN CLI commands (fetch / export / upload). + +Both backend command groups are built by the same factory +(:func:`unitpackage.eln._cli.make_eln_group`), so these tests are parameterised +over both backends. The backend client class is patched, so no real API or +optional dependency is needed. +""" + +# pylint: disable=redefined-outer-name + +import importlib +from unittest.mock import Mock, patch + +import pytest +from click.testing import CliRunner + +# Each backend: how to load its group, which client class to patch, the +# credential flags, and the nouns / display name used in command output. +_BACKENDS = [ + pytest.param( + { + "group": ("unitpackage.eln.elabftw_cli", "elabftw"), + "client_path": "unitpackage.eln.elabftw.ElabFTWClient", + "creds": ["--host", "https://e.example", "--api-key", "k"], + "singular": "entry", + "plural": "entries", + "display": "eLabFTW", + # SDK module patched so client construction needs no real server. + "sdk_patch": "unitpackage.eln.elabftw.elabapi_python", + "profile_creds": { + "host": "https://e.example", + "api_key": "k", + "verify_ssl": True, + }, + }, + marks=pytest.mark.elabftw, + id="elabftw", + ), + pytest.param( + { + "group": ("unitpackage.eln.kadi_cli", "kadi"), + "client_path": "unitpackage.eln.kadi.KadiClient", + "creds": ["--host", "https://k.example", "--pat", "k"], + "singular": "record", + "plural": "records", + "display": "Kadi4Mat", + "sdk_patch": "unitpackage.eln.kadi.KadiManager", + "profile_creds": { + "host": "https://k.example", + "pat": "k", + "verify_ssl": True, + }, + }, + marks=pytest.mark.kadi, + id="kadi", + ), +] + + +@pytest.fixture(params=_BACKENDS) +def backend(request): + """Backend descriptor with the group object resolved.""" + spec = dict(request.param) + module_name, attr = spec["group"] + spec["group_obj"] = getattr(importlib.import_module(module_name), attr) + return spec + + +def test_cli_fetch_saves_entry(backend, tmp_path): + """``fetch`` downloads one entity and saves it to --outdir.""" + with patch(backend["client_path"]) as mock_client_cls: + entry = Mock() + mock_client_cls.return_value.fetch_entry.return_value = entry + + result = CliRunner().invoke( + backend["group_obj"], + [*backend["creds"], "fetch", "42", "--outdir", str(tmp_path)], + ) + + assert result.exit_code == 0, result.output + mock_client_cls.return_value.fetch_entry.assert_called_once_with(42) + entry.save.assert_called_once() + assert f"Saved {backend['singular']} 42" in result.output + + +def test_cli_fetch_reports_failure(backend, tmp_path): + """A fetch error is surfaced as a non-zero exit with a clear message.""" + with patch(backend["client_path"]) as mock_client_cls: + mock_client_cls.return_value.fetch_entry.side_effect = ValueError("boom") + + result = CliRunner().invoke( + backend["group_obj"], + [*backend["creds"], "fetch", "42", "--outdir", str(tmp_path)], + ) + + assert result.exit_code != 0 + assert "Failed to fetch" in result.output + + +def test_cli_export_saves_entries(backend, tmp_path): + """``export`` saves every fetched entry and reports the count.""" + with patch(backend["client_path"]) as mock_client_cls: + e1, e2 = Mock(), Mock() + mock_client_cls.return_value.fetch_entries.return_value = [e1, e2] + + result = CliRunner().invoke( + backend["group_obj"], + [*backend["creds"], "export", "--tag", "cv", "--outdir", str(tmp_path)], + ) + + assert result.exit_code == 0, result.output + mock_client_cls.return_value.fetch_entries.assert_called_once_with(["cv"]) + e1.save.assert_called_once() + e2.save.assert_called_once() + assert f"Saved 2 {backend['plural']}" in result.output + + +def test_cli_export_empty(backend, tmp_path): + """``export`` with no matches prints a friendly message, not an error.""" + with patch(backend["client_path"]) as mock_client_cls: + mock_client_cls.return_value.fetch_entries.return_value = [] + + result = CliRunner().invoke( + backend["group_obj"], + [*backend["creds"], "export", "--outdir", str(tmp_path)], + ) + + assert result.exit_code == 0, result.output + assert f"No {backend['plural']} found" in result.output + + +def test_cli_upload(backend, tmp_path): + """``upload`` loads a local datapackage and uploads it, echoing the new id.""" + datapackage = tmp_path / "entry.json" + datapackage.write_text("{}") + + with ( + patch(backend["client_path"]) as mock_client_cls, + patch("unitpackage.entry.Entry.from_local") as from_local, + ): + from_local.return_value = Mock() + mock_client_cls.return_value.upload_entry.return_value = 100 + + result = CliRunner().invoke( + backend["group_obj"], + [ + *backend["creds"], + "upload", + str(datapackage), + "--title", + "My Title", + "--tag", + "x", + ], + ) + + assert result.exit_code == 0, result.output + from_local.assert_called_once_with(str(datapackage)) + mock_client_cls.return_value.upload_entry.assert_called_once() + call = mock_client_cls.return_value.upload_entry.call_args + assert call.kwargs.get("title") == "My Title" + assert call.kwargs.get("tags") == ["x"] + assert "100" in result.output + assert backend["display"] in result.output + + +def test_cli_upload_reports_failure(backend, tmp_path): + """An upload error is surfaced as a non-zero exit with a clear message.""" + datapackage = tmp_path / "entry.json" + datapackage.write_text("{}") + + with ( + patch(backend["client_path"]) as mock_client_cls, + patch("unitpackage.entry.Entry.from_local") as from_local, + ): + from_local.return_value = Mock() + mock_client_cls.return_value.upload_entry.side_effect = RuntimeError("nope") + + result = CliRunner().invoke( + backend["group_obj"], + [*backend["creds"], "upload", str(datapackage)], + ) + + assert result.exit_code != 0 + assert "Failed to upload" in result.output + + +# --------------------------------------------------------------------------- +# Credential resolution (CLI options > env > config profile) +# +# These were previously duplicated per backend (test_kadi.py::TestCLIIntegration +# and test_config.py::TestCLIProfileIntegration); they share the same factory +# (make_eln_group), so they live here, parameterised over both backends. +# --------------------------------------------------------------------------- + + +def test_cli_profile_option_in_help(backend): + """``--profile`` appears in the group --help output.""" + result = CliRunner().invoke(backend["group_obj"], ["--help"]) + assert "--profile" in result.output + + +def test_cli_options_override_config(backend): + """CLI --host and the secret flag take precedence (no config needed).""" + with patch(backend["sdk_patch"]): + result = CliRunner().invoke(backend["group_obj"], [*backend["creds"], "info"]) + # Got past credential resolution: no UsageError about missing credentials. + assert "Missing" not in result.output + + +def test_cli_profile_loads_config(backend, tmp_path): + """``--profile`` resolves credentials from the config file.""" + with ( + patch(backend["sdk_patch"]), + patch("unitpackage.config.get_profile", return_value=backend["profile_creds"]), + patch("unitpackage.config.config_path", return_value=tmp_path / "config.toml"), + ): + result = CliRunner().invoke( + backend["group_obj"], ["--profile", "production", "info"] + ) + assert "Missing" not in result.output + + +def test_cli_no_credentials_error(backend): + """No credentials anywhere gives a helpful, non-zero error.""" + with patch("unitpackage.config.load_config", return_value={}): + result = CliRunner().invoke(backend["group_obj"], ["info"]) + assert result.exit_code != 0 + assert "Missing" in result.output or "credentials" in result.output diff --git a/unitpackage/test/eln/test_config.py b/unitpackage/test/eln/test_config.py new file mode 100644 index 00000000..a678f421 --- /dev/null +++ b/unitpackage/test/eln/test_config.py @@ -0,0 +1,538 @@ +""" +Tests for unitpackage.config -- the multi-backend configuration system. + +Run with: pytest test/test_config.py +""" + +# pylint: disable=redefined-outer-name,missing-function-docstring + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from unitpackage.config import ( + _serialize_toml, + _toml_value, + config_path, + get_profile, + list_profiles, + load_config, + save_config, +) + +SAMPLE_TOML = """\ +[elabftw] +default_profile = "production" + +[elabftw.production] +host = "https://eln.example.org" +api_key = "abc123" +verify_ssl = true + +[elabftw.staging] +host = "https://staging-eln.example.org" +api_key = "def456" +verify_ssl = false +""" + + +# =================================================================== +# Test: config_path +# =================================================================== + + +class TestConfigPath: + """Tests for config_path().""" + + def test_default_path(self): + """Without XDG_CONFIG_HOME, returns ~/.config/unitpackage/config.toml.""" + with patch.dict("os.environ", {}, clear=False): + # Remove XDG_CONFIG_HOME if present + import os + + env = os.environ.copy() + env.pop("XDG_CONFIG_HOME", None) + with patch.dict("os.environ", env, clear=True): + p = config_path() + assert p == Path.home() / ".config" / "unitpackage" / "config.toml" + + def test_xdg_config_home(self): + """Respects XDG_CONFIG_HOME when set.""" + with patch.dict("os.environ", {"XDG_CONFIG_HOME": "/custom/config"}): + p = config_path() + assert p == Path("/custom/config/unitpackage/config.toml") + + def test_xdg_empty_string(self): + """Empty XDG_CONFIG_HOME falls back to ~/.config.""" + with patch.dict("os.environ", {"XDG_CONFIG_HOME": ""}): + p = config_path() + assert p == Path.home() / ".config" / "unitpackage" / "config.toml" + + def test_windows_appdata(self): + """On Windows, uses APPDATA when XDG_CONFIG_HOME is not set.""" + env = {"APPDATA": "C:\\Users\\testuser\\AppData\\Roaming"} + with ( + patch("unitpackage.config.sys") as mock_sys, + patch.dict("os.environ", env, clear=True), + ): + mock_sys.platform = "win32" + mock_sys.version_info = __import__("sys").version_info + p = config_path() + assert ( + p + == Path("C:\\Users\\testuser\\AppData\\Roaming") + / "unitpackage" + / "config.toml" + ) + + def test_windows_xdg_overrides_appdata(self): + """On Windows, XDG_CONFIG_HOME takes precedence over APPDATA.""" + env = { + "XDG_CONFIG_HOME": "D:\\custom\\config", + "APPDATA": "C:\\Users\\testuser\\AppData\\Roaming", + } + with ( + patch("unitpackage.config.sys") as mock_sys, + patch.dict("os.environ", env, clear=True), + ): + mock_sys.platform = "win32" + p = config_path() + assert p == Path("D:\\custom\\config") / "unitpackage" / "config.toml" + + def test_windows_no_appdata_falls_back(self): + """On Windows without APPDATA, falls back to ~/.config.""" + with ( + patch("unitpackage.config.sys") as mock_sys, + patch.dict("os.environ", {}, clear=True), + ): + mock_sys.platform = "win32" + p = config_path() + assert p == Path.home() / ".config" / "unitpackage" / "config.toml" + + +# =================================================================== +# Test: load_config +# =================================================================== + + +class TestLoadConfig: + """Tests for load_config().""" + + def test_missing_file_returns_empty_dict(self): + """Nonexistent file returns empty dict without error.""" + assert not load_config("/nonexistent/path/config.toml") + + def test_valid_toml(self, tmp_path): + """Valid TOML is parsed correctly.""" + config_file = tmp_path / "config.toml" + config_file.write_text(SAMPLE_TOML) + + cfg = load_config(config_file) + assert cfg["elabftw"]["production"]["host"] == "https://eln.example.org" + assert cfg["elabftw"]["production"]["api_key"] == "abc123" + assert cfg["elabftw"]["production"]["verify_ssl"] is True + assert cfg["elabftw"]["staging"]["verify_ssl"] is False + + def test_malformed_toml_raises(self, tmp_path): + """Malformed TOML raises ValueError.""" + config_file = tmp_path / "config.toml" + config_file.write_text("[invalid\nthis is not valid toml") + + with pytest.raises(ValueError, match="Failed to parse"): + load_config(config_file) + + def test_empty_file(self, tmp_path): + """Empty TOML file returns empty dict.""" + config_file = tmp_path / "config.toml" + config_file.write_text("") + + assert not load_config(config_file) + + +# =================================================================== +# Test: get_profile +# =================================================================== + + +SAMPLE_CONFIG = { + "elabftw": { + "default_profile": "production", + "production": { + "host": "https://eln.example.org", + "api_key": "abc123", + "verify_ssl": True, + }, + "staging": { + "host": "https://staging.example.org", + "api_key": "def456", + "verify_ssl": False, + }, + }, +} + + +class TestGetProfile: + """Tests for get_profile().""" + + def test_explicit_profile(self): + """get_profile with explicit name returns the correct profile.""" + result = get_profile("elabftw", "production", config=SAMPLE_CONFIG) + assert result["host"] == "https://eln.example.org" + assert result["api_key"] == "abc123" + + def test_default_profile(self): + """get_profile without name uses default_profile.""" + result = get_profile("elabftw", config=SAMPLE_CONFIG) + assert result["host"] == "https://eln.example.org" + + def test_single_profile_auto_selected(self): + """get_profile auto-selects the only profile when no default is set.""" + cfg = { + "elabftw": { + "production": {"host": "https://eln.example.org", "api_key": "abc"}, + }, + } + assert get_profile("elabftw", config=cfg) == { + "host": "https://eln.example.org", + "api_key": "abc", + } + + def test_multiple_profiles_no_default_returns_empty(self): + """get_profile returns empty dict when multiple profiles exist but no default is set.""" + cfg = { + "elabftw": { + "prod": {"host": "https://prod.example.org", "api_key": "k1"}, + "staging": {"host": "https://staging.example.org", "api_key": "k2"}, + }, + } + assert get_profile("elabftw", config=cfg) == {} + + def test_default_profile_points_to_missing(self): + """get_profile returns empty dict when default_profile references a nonexistent profile.""" + cfg = { + "elabftw": { + "default_profile": "deleted", + "production": {"host": "https://eln.example.org", "api_key": "abc"}, + }, + } + assert get_profile("elabftw", config=cfg) == {} + + def test_explicit_second_profile(self): + """get_profile with second profile name returns it correctly.""" + result = get_profile("elabftw", "staging", config=SAMPLE_CONFIG) + assert result["host"] == "https://staging.example.org" + assert result["verify_ssl"] is False + + def test_nonexistent_profile(self): + """Nonexistent profile returns empty dict.""" + assert get_profile("elabftw", "nonexistent", config=SAMPLE_CONFIG) == {} + + def test_nonexistent_backend(self): + """Nonexistent backend returns empty dict.""" + assert get_profile("kadi", config=SAMPLE_CONFIG) == {} + + +# =================================================================== +# Test: list_profiles +# =================================================================== + + +class TestListProfiles: + """Tests for list_profiles().""" + + def test_list_profiles(self): + """Returns profile names, excluding default_profile key.""" + result = list_profiles("elabftw", config=SAMPLE_CONFIG) + assert result == ["production", "staging"] + + def test_list_empty_backend(self): + """Nonexistent backend returns empty list.""" + assert list_profiles("kadi", config=SAMPLE_CONFIG) == [] + + +# =================================================================== +# Test: _toml_value +# =================================================================== + + +class TestTomlValue: + """Tests for _toml_value().""" + + def test_bool_true(self): + assert _toml_value(True) == "true" + + def test_bool_false(self): + assert _toml_value(False) == "false" + + def test_int(self): + assert _toml_value(42) == "42" + + def test_float(self): + assert _toml_value(3.14) == "3.14" + + def test_string(self): + assert _toml_value("hello") == '"hello"' + + def test_string_with_quotes(self): + assert _toml_value('say "hi"') == '"say \\"hi\\""' + + def test_string_with_backslash(self): + assert _toml_value("a\\b") == '"a\\\\b"' + + def test_rejects_list(self): + """Lists are not serializable and raise TypeError.""" + with pytest.raises(TypeError, match="list"): + _toml_value(["a", "b"]) + + def test_rejects_dict(self): + """Dicts are not serializable as a scalar and raise TypeError.""" + with pytest.raises(TypeError, match="dict"): + _toml_value({"k": "v"}) + + def test_rejects_none(self): + """None is not serializable and raises TypeError.""" + with pytest.raises(TypeError, match="NoneType"): + _toml_value(None) + + +# =================================================================== +# Test: _serialize_toml +# =================================================================== + + +class TestSerializeToml: + """Tests for _serialize_toml().""" + + def test_basic_structure(self): + """Produces valid TOML with section headers.""" + result = _serialize_toml({"s": {"key": "val"}}) + assert "[s]" in result + assert 'key = "val"' in result + + def test_sub_tables(self): + """Sub-dicts become [section.key] tables.""" + result = _serialize_toml({"s": {"sub": {"k": "v"}}}) + assert "[s.sub]" in result + assert 'k = "v"' in result + + def test_bool_values(self): + """Booleans are serialized as true/false.""" + result = _serialize_toml({"s": {"sub": {"flag": True}}}) + assert "flag = true" in result + + def test_empty_config(self): + """Empty dict produces empty string.""" + assert _serialize_toml({}) == "" + + def test_rejects_non_table_section(self): + """A non-table top-level value raises rather than being dropped.""" + with pytest.raises(ValueError, match="top-level"): + _serialize_toml({"b": "scalar"}) + + def test_rejects_deeply_nested_table(self): + """A table nested more than one level deep raises.""" + with pytest.raises(ValueError, match="nested table"): + _serialize_toml({"b": {"prof": {"nested": {"k": "v"}}}}) + + def test_rejects_list_value_in_profile(self): + """A list value inside a profile raises via the value serializer.""" + with pytest.raises(TypeError, match="list"): + _serialize_toml({"b": {"prof": {"tags": ["a", "b"]}}}) + + +# =================================================================== +# Test: save_config +# =================================================================== + + +class TestSaveConfig: + """Tests for save_config().""" + + def test_round_trip(self, tmp_path): + """save_config + load_config round-trip preserves data.""" + cfg = { + "elabftw": { + "default_profile": "prod", + "prod": { + "host": "https://eln.example.org", + "api_key": "abc123", + "verify_ssl": True, + }, + }, + } + p = tmp_path / "config.toml" + save_config(cfg, path=p) + loaded = load_config(p) + assert loaded["elabftw"]["default_profile"] == "prod" + assert loaded["elabftw"]["prod"]["host"] == "https://eln.example.org" + assert loaded["elabftw"]["prod"]["api_key"] == "abc123" + assert loaded["elabftw"]["prod"]["verify_ssl"] is True + + def test_creates_parent_dirs(self, tmp_path): + """Creates missing parent directories.""" + p = tmp_path / "deep" / "nested" / "config.toml" + save_config({"elabftw": {"prod": {"host": "x"}}}, path=p) + assert p.is_file() + + def test_preserves_bool_types(self, tmp_path): + """Boolean values survive round-trip as booleans (not strings).""" + cfg = {"elabftw": {"p": {"verify_ssl": False}}} + p = tmp_path / "config.toml" + save_config(cfg, path=p) + loaded = load_config(p) + assert loaded["elabftw"]["p"]["verify_ssl"] is False + + def test_empty_config(self, tmp_path): + """Empty dict writes a valid (empty) file.""" + p = tmp_path / "config.toml" + save_config({}, path=p) + assert not load_config(p) + + +# =================================================================== +# Test: ElabFTWClient.from_config +# =================================================================== + + +class TestElabFTWFromConfig: + """Tests for ElabFTWClient.from_config().""" + + def test_from_config_valid(self): + """from_config with valid profile creates a client.""" + from unitpackage.eln.elabftw import ElabFTWClient + + with ( + patch("unitpackage.eln.elabftw.elabapi_python"), + patch("unitpackage.config.load_config", return_value=SAMPLE_CONFIG), + ): + client = ElabFTWClient.from_config("production") + assert client is not None + + def test_from_config_default_profile(self): + """from_config without name uses default_profile.""" + from unitpackage.eln.elabftw import ElabFTWClient + + with ( + patch("unitpackage.eln.elabftw.elabapi_python"), + patch("unitpackage.config.load_config", return_value=SAMPLE_CONFIG), + ): + client = ElabFTWClient.from_config() + assert client is not None + + def test_from_config_missing_profile(self): + """from_config raises ValueError for missing profile.""" + from unitpackage.eln.elabftw import ElabFTWClient + + with patch("unitpackage.config.load_config", return_value=SAMPLE_CONFIG): + with pytest.raises(ValueError, match="No eLabFTW profile 'nonexistent'"): + ElabFTWClient.from_config("nonexistent") + + def test_from_config_no_profiles(self): + """from_config raises ValueError when no profiles exist.""" + from unitpackage.eln.elabftw import ElabFTWClient + + with patch("unitpackage.config.load_config", return_value={}): + with pytest.raises(ValueError, match="No eLabFTW profiles found"): + ElabFTWClient.from_config() + + def test_from_config_missing_host(self): + """from_config raises ValueError when host is missing.""" + from unitpackage.eln.elabftw import ElabFTWClient + + bad_config = {"elabftw": {"broken": {"api_key": "key"}}} + with patch("unitpackage.config.load_config", return_value=bad_config): + with pytest.raises(ValueError, match="Missing 'host'"): + ElabFTWClient.from_config("broken") + + def test_from_config_missing_api_key(self): + """from_config raises ValueError when api_key is missing.""" + from unitpackage.eln.elabftw import ElabFTWClient + + bad_config = {"elabftw": {"broken": {"host": "https://eln.example.org"}}} + with patch("unitpackage.config.load_config", return_value=bad_config): + with pytest.raises(ValueError, match="Missing 'api_key'"): + ElabFTWClient.from_config("broken") + + +# =================================================================== +# Test: KadiClient.from_config +# =================================================================== + + +SAMPLE_KADI_CONFIG = { + "kadi": { + "default_profile": "production", + "production": { + "host": "https://kadi.example.edu", + "pat": "abc123", + "verify_ssl": True, + }, + "staging": { + "host": "https://staging-kadi.example.edu", + "pat": "def456", + "verify_ssl": False, + }, + }, +} + + +@pytest.mark.kadi +class TestKadiFromConfig: + """Tests for KadiClient.from_config().""" + + def test_from_config_valid(self): + """from_config with valid profile creates a client.""" + from unitpackage.eln.kadi import KadiClient + + with ( + patch("unitpackage.eln.kadi.KadiManager"), + patch("unitpackage.config.load_config", return_value=SAMPLE_KADI_CONFIG), + ): + client = KadiClient.from_config("production") + assert client is not None + + def test_from_config_default_profile(self): + """from_config without name uses default_profile.""" + from unitpackage.eln.kadi import KadiClient + + with ( + patch("unitpackage.eln.kadi.KadiManager"), + patch("unitpackage.config.load_config", return_value=SAMPLE_KADI_CONFIG), + ): + client = KadiClient.from_config() + assert client is not None + + def test_from_config_missing_profile(self): + """from_config raises ValueError for missing profile.""" + from unitpackage.eln.kadi import KadiClient + + with patch("unitpackage.config.load_config", return_value=SAMPLE_KADI_CONFIG): + with pytest.raises(ValueError, match="No Kadi profile 'nonexistent'"): + KadiClient.from_config("nonexistent") + + def test_from_config_no_profiles(self): + """from_config raises ValueError when no profiles exist.""" + from unitpackage.eln.kadi import KadiClient + + with patch("unitpackage.config.load_config", return_value={}): + with pytest.raises(ValueError, match="No Kadi profiles found"): + KadiClient.from_config() + + def test_from_config_missing_host(self): + """from_config raises ValueError when host is missing.""" + from unitpackage.eln.kadi import KadiClient + + bad_config = {"kadi": {"broken": {"pat": "abc"}}} + with patch("unitpackage.config.load_config", return_value=bad_config): + with pytest.raises(ValueError, match="Missing 'host'"): + KadiClient.from_config("broken") + + def test_from_config_missing_pat(self): + """from_config raises ValueError when pat is missing.""" + from unitpackage.eln.kadi import KadiClient + + bad_config = {"kadi": {"broken": {"host": "https://kadi.example.edu"}}} + with patch("unitpackage.config.load_config", return_value=bad_config): + with pytest.raises(ValueError, match="Missing 'pat'"): + KadiClient.from_config("broken") diff --git a/unitpackage/test/eln/test_config_cli.py b/unitpackage/test/eln/test_config_cli.py new file mode 100644 index 00000000..2a5480e3 --- /dev/null +++ b/unitpackage/test/eln/test_config_cli.py @@ -0,0 +1,403 @@ +""" +Tests for the ELN config CLI commands (add / list / show / remove / set-default). + +Both backend ``config`` subgroups are built by the same factory +(:func:`unitpackage.eln._config_cli.make_config_group`), differing only in the +config-section key, display name, and the secret credential flag/field +(``--api-key`` / ``api_key`` vs ``--pat`` / ``pat``). These tests are therefore +parameterised over both backends. + +Run with: pytest test/eln/test_config_cli.py +""" + +# pylint: disable=redefined-outer-name,unused-argument + +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from unitpackage.config import load_config, save_config +from unitpackage.entrypoint import cli + +# Each backend: the config-section key, display name (used in messages), and +# the secret option flag / config field that distinguish the two CLIs. +_BACKENDS = [ + pytest.param( + { + "key": "elabftw", + "display": "eLabFTW", + "secret_opt": "--api-key", + "secret_field": "api_key", + }, + marks=pytest.mark.elabftw, + id="elabftw", + ), + pytest.param( + { + "key": "kadi", + "display": "Kadi4Mat", + "secret_opt": "--pat", + "secret_field": "pat", + }, + marks=pytest.mark.kadi, + id="kadi", + ), +] + + +@pytest.fixture(params=_BACKENDS) +def backend(request): + """Backend descriptor (key / display / secret flag+field).""" + return request.param + + +@pytest.fixture() +def config_file(tmp_path): + """Provide a temp config file and patch config_path to point to it.""" + path = tmp_path / "config.toml" + with patch("unitpackage.config.config_path", return_value=path): + yield path + + +def _prof(backend, host="x", secret="k", **extra): + """Build a profile dict with the backend's secret field name.""" + return {"host": host, backend["secret_field"]: secret, **extra} + + +def _seed_config(backend, path, profiles=None, default=None): + """Write a config file with the given profiles for *backend*.""" + section = {} + if default: + section["default_profile"] = default + for name, data in (profiles or {}).items(): + section[name] = data + save_config({backend["key"]: section}, path=path) + + +# =================================================================== +# Test: config add +# =================================================================== + + +class TestConfigAdd: + """Tests for 'config add' CLI command.""" + + def test_add_profile(self, backend, config_file): + """Add a profile and verify it appears in the config file.""" + result = CliRunner().invoke( + cli, + [ + backend["key"], + "config", + "add", + "production", + "--host", + "https://eln.example.org", + backend["secret_opt"], + "abc123", + ], + ) + assert result.exit_code == 0 + assert "production" in result.output + cfg = load_config(config_file) + section = cfg[backend["key"]]["production"] + assert section["host"] == "https://eln.example.org" + assert section[backend["secret_field"]] == "abc123" + assert section["verify_ssl"] is True + + def test_first_profile_becomes_default(self, backend, config_file): + """First profile added is automatically set as default.""" + CliRunner().invoke( + cli, + [ + backend["key"], + "config", + "add", + "prod", + "--host", + "https://eln.example.org", + backend["secret_opt"], + "key", + ], + ) + cfg = load_config(config_file) + assert cfg[backend["key"]]["default_profile"] == "prod" + + def test_second_profile_no_auto_default(self, backend, config_file): + """Second profile does not change the default.""" + _seed_config(backend, config_file, {"prod": _prof(backend)}, default="prod") + CliRunner().invoke( + cli, + [ + backend["key"], + "config", + "add", + "staging", + "--host", + "https://staging.example.org", + backend["secret_opt"], + "key2", + ], + ) + cfg = load_config(config_file) + assert cfg[backend["key"]]["default_profile"] == "prod" + + def test_add_duplicate_fails(self, backend, config_file): + """Adding an existing profile name produces an error.""" + _seed_config(backend, config_file, {"prod": _prof(backend)}) + result = CliRunner().invoke( + cli, + [ + backend["key"], + "config", + "add", + "prod", + "--host", + "https://new.example.org", + backend["secret_opt"], + "newkey", + ], + ) + assert result.exit_code != 0 + assert "already exists" in result.output + + def test_add_with_set_default(self, backend, config_file): + """--set-default flag sets the new profile as default.""" + _seed_config(backend, config_file, {"prod": _prof(backend)}, default="prod") + CliRunner().invoke( + cli, + [ + backend["key"], + "config", + "add", + "staging", + "--host", + "https://staging.example.org", + backend["secret_opt"], + "key2", + "--set-default", + ], + ) + cfg = load_config(config_file) + assert cfg[backend["key"]]["default_profile"] == "staging" + + def test_add_no_verify_ssl(self, backend, config_file): + """--no-verify-ssl sets verify_ssl to false.""" + result = CliRunner().invoke( + cli, + [ + backend["key"], + "config", + "add", + "dev", + "--host", + "https://dev.example.org", + backend["secret_opt"], + "devkey", + "--no-verify-ssl", + ], + ) + assert result.exit_code == 0 + cfg = load_config(config_file) + assert cfg[backend["key"]]["dev"]["verify_ssl"] is False + + +# =================================================================== +# Test: config list +# =================================================================== + + +class TestConfigList: + """Tests for 'config list' CLI command.""" + + def test_list_empty(self, backend, config_file): + """No profiles shows a helpful message.""" + result = CliRunner().invoke(cli, [backend["key"], "config", "list"]) + assert result.exit_code == 0 + assert f"No {backend['display']} profiles" in result.output + + def test_list_profiles(self, backend, config_file): + """Lists profiles.""" + _seed_config( + backend, + config_file, + { + "prod": _prof(backend, host="https://prod.example.org", secret="k1"), + "staging": _prof( + backend, host="https://staging.example.org", secret="k2" + ), + }, + default="prod", + ) + result = CliRunner().invoke(cli, [backend["key"], "config", "list"]) + assert result.exit_code == 0 + assert "prod" in result.output + assert "staging" in result.output + + def test_list_marks_default(self, backend, config_file): + """Default profile is marked with '(default)'.""" + _seed_config( + backend, + config_file, + {"prod": _prof(backend, host="https://prod.example.org", secret="k1")}, + default="prod", + ) + result = CliRunner().invoke(cli, [backend["key"], "config", "list"]) + assert "(default)" in result.output + + +# =================================================================== +# Test: config show +# =================================================================== + + +class TestConfigShow: + """Tests for 'config show' CLI command.""" + + def test_show_profile(self, backend, config_file): + """Shows profile details.""" + _seed_config( + backend, + config_file, + { + "prod": _prof( + backend, + host="https://eln.example.org", + secret="abc123secret", + verify_ssl=True, + ), + }, + default="prod", + ) + result = CliRunner().invoke(cli, [backend["key"], "config", "show", "prod"]) + assert result.exit_code == 0 + assert "https://eln.example.org" in result.output + assert "(default)" in result.output + + def test_show_masks_secret(self, backend, config_file): + """Secret credential is masked by default.""" + _seed_config( + backend, + config_file, + {"prod": _prof(backend, secret="abc123secret")}, + ) + result = CliRunner().invoke(cli, [backend["key"], "config", "show", "prod"]) + assert "abc123secret" not in result.output + assert "abc1" in result.output + assert "..." in result.output + # The tail of the secret must not be revealed by the mask. + assert "cret" not in result.output + + def test_show_with_show_key(self, backend, config_file): + """--show-key reveals the full secret credential.""" + _seed_config( + backend, + config_file, + {"prod": _prof(backend, secret="abc123secret")}, + ) + result = CliRunner().invoke( + cli, [backend["key"], "config", "show", "prod", "--show-key"] + ) + assert "abc123secret" in result.output + + def test_show_nonexistent(self, backend, config_file): + """Nonexistent profile produces an error.""" + result = CliRunner().invoke(cli, [backend["key"], "config", "show", "nope"]) + assert result.exit_code != 0 + assert "not found" in result.output + + +# =================================================================== +# Test: config remove +# =================================================================== + + +class TestConfigRemove: + """Tests for 'config remove' CLI command.""" + + def test_remove_profile(self, backend, config_file): + """Removes a profile from the config file.""" + _seed_config( + backend, + config_file, + { + "prod": _prof(backend), + "staging": _prof(backend, host="y", secret="k2"), + }, + default="prod", + ) + result = CliRunner().invoke( + cli, [backend["key"], "config", "remove", "staging", "--yes"] + ) + assert result.exit_code == 0 + assert "removed" in result.output + cfg = load_config(config_file) + assert "staging" not in cfg[backend["key"]] + assert "prod" in cfg[backend["key"]] + + def test_remove_default_clears_default(self, backend, config_file): + """Removing the default profile clears the default_profile key.""" + _seed_config(backend, config_file, {"prod": _prof(backend)}, default="prod") + result = CliRunner().invoke( + cli, [backend["key"], "config", "remove", "prod", "--yes"] + ) + assert result.exit_code == 0 + assert "Default cleared" in result.output + cfg = load_config(config_file) + assert "default_profile" not in cfg.get(backend["key"], {}) + + def test_remove_nonexistent(self, backend, config_file): + """Removing a nonexistent profile produces an error.""" + result = CliRunner().invoke( + cli, [backend["key"], "config", "remove", "nope", "--yes"] + ) + assert result.exit_code != 0 + assert "not found" in result.output + + def test_remove_prompts_for_confirmation(self, backend, config_file): + """Without --yes, remove prompts for confirmation.""" + _seed_config(backend, config_file, {"prod": _prof(backend)}) + # Send 'n' to abort + result = CliRunner().invoke( + cli, [backend["key"], "config", "remove", "prod"], input="n\n" + ) + assert result.exit_code != 0 + # Profile should still exist + cfg = load_config(config_file) + assert "prod" in cfg[backend["key"]] + + +# =================================================================== +# Test: config set-default +# =================================================================== + + +class TestConfigSetDefault: + """Tests for 'config set-default' CLI command.""" + + def test_set_default(self, backend, config_file): + """Sets the default profile.""" + _seed_config( + backend, + config_file, + { + "prod": _prof(backend), + "staging": _prof(backend, host="y", secret="k2"), + }, + default="prod", + ) + result = CliRunner().invoke( + cli, [backend["key"], "config", "set-default", "staging"] + ) + assert result.exit_code == 0 + cfg = load_config(config_file) + assert cfg[backend["key"]]["default_profile"] == "staging" + + def test_set_default_nonexistent(self, backend, config_file): + """Setting nonexistent profile as default produces an error.""" + result = CliRunner().invoke( + cli, [backend["key"], "config", "set-default", "nope"] + ) + assert result.exit_code != 0 + assert "not found" in result.output diff --git a/unitpackage/test/eln/test_elabftw.py b/unitpackage/test/eln/test_elabftw.py new file mode 100644 index 00000000..9435daa1 --- /dev/null +++ b/unitpackage/test/eln/test_elabftw.py @@ -0,0 +1,767 @@ +""" +Comprehensive tests for unitpackage.elabftw -- the eLabFTW integration module. + +Every eLabFTW API call is mocked; no live server is needed. +Run with: pytest -m elabftw +""" + +# pylint: disable=redefined-outer-name,protected-access,too-few-public-methods +# Parallel per-backend test suites share structurally similar assertions. +# pylint: disable=duplicate-code + +import io +import json +from unittest.mock import MagicMock, Mock, patch + +import pandas as pd +import pytest + +from unitpackage.test.eln.fixture_loader import ( + CSV_DATA, + SAMPLE_COLUMNS, + SAMPLE_DESCRIPTOR, + assert_basic_entry, + assert_field_units, + get_csv_bytes, + get_versions, + load_fixture, + load_manifest, + make_field_dict, + make_mock_uploads, +) + +# --------------------------------------------------------------------------- +# Fixture loader (recorded real API responses) +# --------------------------------------------------------------------------- + + +_BACKEND = "elabftw" +_ELABFTW_VERSIONS = get_versions(_BACKEND) + +# --------------------------------------------------------------------------- +# Constants / mock data shared across tests +# --------------------------------------------------------------------------- + +# eLabFTW stores the descriptor as JSON in the entity's ``metadata`` field, +# nested under a ``"unitpackage"`` key. +UNITPACKAGE_METADATA = {"unitpackage": SAMPLE_DESCRIPTOR} + +_USE_DEFAULT = object() + + +def _make_entity_dict( + exp_id=42, + title="CV measurement on Pt(111)", + tags="electrochemistry,CV", + body="

Cyclic voltammetry experiment

", + metadata=_USE_DEFAULT, + created_at="2026-01-15", + modified_at="2026-01-16", + sharelink="https://elab.example.org/experiments.php?mode=view&id=42", +): + """Return a dict mimicking a raw eLabFTW API JSON response for an entity.""" + if metadata is _USE_DEFAULT: + metadata = UNITPACKAGE_METADATA + return { + "id": exp_id, + "title": title, + "tags": tags, + "body": body, + "metadata": json.dumps(metadata) if metadata is not None else None, + "created_at": created_at, + "modified_at": modified_at, + "sharelink": sharelink, + } + + +def _make_raw_response(data): + """Wrap *data* in a response-like object with ``.data`` as JSON bytes. + + Mimics the urllib3 HTTPResponse returned by elabapi_python when + ``_preload_content=False`` is used. + """ + resp = Mock() + resp.data = json.dumps(data).encode() + return resp + + +def _make_mock_upload( + upload_id=1, real_name="test_entry.csv", comment="Measurement data" +): + """Return a Mock that looks like an eLabFTW upload object.""" + up = Mock() + up.id = upload_id + up.real_name = real_name + up.comment = comment + return up + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def mock_experiment(): + """Raw response mimicking ``get_item(..., _preload_content=False)``.""" + if _ELABFTW_VERSIONS: + version = _ELABFTW_VERSIONS[-1] + # Use the patch_metadata response as entity data (get_entity.json only + # has HTTP metadata since it was recorded with _preload_content=False). + entity_data = load_fixture(_BACKEND, version, "patch_metadata")["response"] + # Override metadata with the unitpackage descriptor from the request. + entity_data["metadata"] = json.dumps( + load_fixture(_BACKEND, version, "patch_metadata")["request"]["metadata"] + ) + return _make_raw_response(entity_data) + return _make_raw_response(_make_entity_dict()) + + +@pytest.fixture() +def mock_upload(): + """Mock eLabFTW upload object.""" + if _ELABFTW_VERSIONS: + uploads = make_mock_uploads(_BACKEND, _ELABFTW_VERSIONS[-1]) + return uploads[0] + return _make_mock_upload() + + +@pytest.fixture(params=_ELABFTW_VERSIONS if _ELABFTW_VERSIONS else [None]) +def fixture_version(request): + """Pytest fixture: parameterised over each recorded eLabFTW version.""" + version = request.param + if version is None: + pytest.skip("No elabftw fixtures recorded") + return version + + +# --------------------------------------------------------------------------- +# Helpers -- build a minimal ElabFTWClient without hitting the real API +# --------------------------------------------------------------------------- + + +def _make_client(): # pylint: disable=protected-access + """ + Instantiate an ElabFTWClient with all API objects replaced by Mocks. + + The fake elabapi_python module is already injected into sys.modules + by conftest.py, so we can import directly. + """ + from unitpackage.eln.elabftw import ElabFTWClient + + client = ElabFTWClient.__new__(ElabFTWClient) + client._entity_type = "items" + client._api_client = MagicMock() + client.items_client = MagicMock() + client.uploads_client = MagicMock() + return client + + +# =================================================================== +# Test: initialisation +# =================================================================== + + +@pytest.mark.elabftw +class TestClientInit: + """Tests for ElabFTWClient.__init__ and from_env.""" + + def test_client_init(self): + """Configuration and ApiClient are created with the supplied values.""" + from unitpackage.eln.elabftw import ElabFTWClient + + with patch("unitpackage.eln.elabftw.elabapi_python") as elabapi: + mock_config = MagicMock() + elabapi.Configuration.return_value = mock_config + + mock_api_client = MagicMock() + elabapi.ApiClient.return_value = mock_api_client + + client = ElabFTWClient( + host="https://elab.example.org", + api_key="test-key-123", + verify_ssl=True, + ) + + elabapi.Configuration.assert_called_once() + assert mock_config.host == "https://elab.example.org/api/v2" + assert mock_config.verify_ssl is True + assert client._entity_type == "items" # pylint: disable=protected-access + + def test_client_init_no_verify_ssl(self): + """Configuration.verify_ssl is set to False when verify_ssl=False.""" + from unitpackage.eln.elabftw import ElabFTWClient + + with patch("unitpackage.eln.elabftw.elabapi_python") as elabapi: + mock_config = MagicMock() + elabapi.Configuration.return_value = mock_config + + ElabFTWClient( + host="https://elab.example.org", + api_key="test-key-123", + verify_ssl=False, + ) + + assert mock_config.verify_ssl is False + + def test_client_from_env(self): + """from_env reads ELABFTW_HOST and ELABFTW_API_KEY from the environment.""" + from unitpackage.eln.elabftw import ElabFTWClient + + env = { + "ELABFTW_HOST": "https://env.example.org", + "ELABFTW_API_KEY": "env-key-456", + } + with ( + patch("unitpackage.eln.elabftw.elabapi_python"), + patch.dict("os.environ", env, clear=False), + ): + ElabFTWClient.from_env() + # Client was created without error + + def test_client_from_env_missing_vars(self): + """from_env raises when the required env vars are absent.""" + from unitpackage.eln.elabftw import ElabFTWClient + + with patch.dict("os.environ", {}, clear=True): + with pytest.raises((KeyError, ValueError, EnvironmentError)): + ElabFTWClient.from_env() + + +# =================================================================== +# Test: info +# =================================================================== + + +@pytest.mark.elabftw +class TestInfo: + """Tests for ElabFTWClient.info().""" + + def test_info(self): + """info() delegates to InfoApi.get_info and returns a dict.""" + client = _make_client() + + mock_info = { + "elabftw_version": "5.5.9", + "teams_count": 3, + "all_users_count": 25, + } + mock_info_api = MagicMock() + mock_info_api.get_info.return_value = mock_info + + with patch("unitpackage.eln.elabftw.elabapi_python") as mock_elabapi: + mock_elabapi.InfoApi.return_value = mock_info_api + result = client.info() + + assert isinstance(result, dict) + assert result["elabftw_version"] == "5.5.9" + + def test_info_normalizes_model_to_dict(self): + """A model object (with ``.to_dict()``) is normalised to a plain dict. + + The real ``InfoApi.get_info()`` returns an ``Info`` model, not a dict; + ``info()`` must convert it so the CLI's ``.get()`` lookups work. + """ + client = _make_client() + + model = Mock(spec=["to_dict"]) + model.to_dict.return_value = {"elabftw_version": "5.5.14", "teams_count": 27} + mock_info_api = MagicMock() + mock_info_api.get_info.return_value = model + + with patch("unitpackage.eln.elabftw.elabapi_python") as mock_elabapi: + mock_elabapi.InfoApi.return_value = mock_info_api + result = client.info() + + assert isinstance(result, dict) + assert result["elabftw_version"] == "5.5.14" + assert result.get("teams_count") == 27 + + +# =================================================================== +# Test: fetch_entry +# =================================================================== + + +@pytest.mark.elabftw +class TestFetchEntry: + """Tests for ElabFTWClient.fetch_entry().""" + + def test_fetch_entry(self, mock_experiment, mock_upload, csv_bytes): + """fetch_entry returns a valid Entry from an items entity.""" + client = _make_client() + + client.items_client.get_item.return_value = mock_experiment + client.uploads_client.read_uploads.return_value = [mock_upload] + + csv_response = Mock() + csv_response.data = csv_bytes + client.uploads_client.read_upload.return_value = csv_response + + entry = client.fetch_entry(42) + + client.items_client.get_item.assert_called_once_with(42, _preload_content=False) + client.uploads_client.read_uploads.assert_called_once_with("items", 42) + + assert_basic_entry(entry) + + def test_fetch_entry_no_matching_upload(self, mock_experiment): + """fetch_entry raises ValueError when no upload matches the descriptor path.""" + client = _make_client() + client.items_client.get_item.return_value = mock_experiment + + other = _make_mock_upload(real_name="something_else.csv") + client.uploads_client.read_uploads.return_value = [other] + + with pytest.raises(ValueError, match="No upload named"): + client.fetch_entry(42) + + def test_fetch_entry_rejects_non_unitpackage(self): + """fetch_entry raises ValueError for entities without unitpackage metadata.""" + client = _make_client() + + client.items_client.get_item.return_value = _make_raw_response( + _make_entity_dict(metadata={"some_other_tool": {}}) + ) + + with pytest.raises(ValueError, match="not uploaded by unitpackage"): + client.fetch_entry(42) + + def test_fetch_entry_rejects_empty_metadata(self): + """fetch_entry raises ValueError for entities with no metadata.""" + client = _make_client() + + client.items_client.get_item.return_value = _make_raw_response( + _make_entity_dict(metadata=None) + ) + + with pytest.raises(ValueError, match="not uploaded by unitpackage"): + client.fetch_entry(42) + + def test_fetch_entry_metadata_mapping(self, csv_bytes): + """Metadata stored under the 'unitpackage' key is restored on fetch.""" + client = _make_client() + + client.items_client.get_item.return_value = _make_raw_response( + _make_entity_dict() + ) + client.uploads_client.read_uploads.return_value = [_make_mock_upload()] + + csv_response = Mock() + csv_response.data = csv_bytes + client.uploads_client.read_upload.return_value = csv_response + + entry = client.fetch_entry(42) + + metadata = entry.resource.custom.get("metadata", {}) + assert metadata is not None + assert "echemdb" in metadata + + +# =================================================================== +# Test: fetch_entries +# =================================================================== + + +@pytest.mark.elabftw +class TestFetchEntries: + """Tests for ElabFTWClient.fetch_entries().""" + + def test_fetch_entries_all(self, csv_bytes): + """fetch_entries returns multiple Entry objects.""" + client = _make_client() + + dict1 = _make_entity_dict(exp_id=1, title="Item_1") + dict2 = _make_entity_dict(exp_id=2, title="Item_2") + client.items_client.read_items.return_value = _make_raw_response([dict1, dict2]) + + # fetch_entries calls fetch_entry for each, which calls get_item + client.items_client.get_item.side_effect = [ + _make_raw_response(dict1), + _make_raw_response(dict2), + ] + + client.uploads_client.read_uploads.return_value = [_make_mock_upload()] + csv_response = Mock() + csv_response.data = csv_bytes + client.uploads_client.read_upload.return_value = csv_response + + entries = client.fetch_entries() + + assert len(entries) == 2 + for e in entries: + assert hasattr(e, "df") + + def test_fetch_entries_skips_non_unitpackage(self, csv_bytes): + """fetch_entries only returns entities with unitpackage metadata.""" + client = _make_client() + + up_dict = _make_entity_dict(exp_id=1, title="UP_Item") + foreign_dict = _make_entity_dict( + exp_id=2, title="Foreign", metadata={"other": {}} + ) + no_meta_dict = _make_entity_dict(exp_id=3, title="NoMeta", metadata=None) + + client.items_client.read_items.return_value = _make_raw_response( + [up_dict, foreign_dict, no_meta_dict] + ) + + # Only up_dict should be fetched + client.items_client.get_item.return_value = _make_raw_response(up_dict) + client.uploads_client.read_uploads.return_value = [_make_mock_upload()] + csv_response = Mock() + csv_response.data = csv_bytes + client.uploads_client.read_upload.return_value = csv_response + + entries = client.fetch_entries() + + assert len(entries) == 1 + # get_item should only be called once (for the unitpackage entity) + client.items_client.get_item.assert_called_once_with(1, _preload_content=False) + + def test_fetch_entries_with_tags(self, csv_bytes): + """fetch_entries filters by tags when provided.""" + client = _make_client() + + dict1 = _make_entity_dict(exp_id=1, title="I1", tags="electrochemistry,CV") + client.items_client.read_items.return_value = _make_raw_response([dict1]) + + # fetch_entries calls fetch_entry which calls get_item + client.items_client.get_item.return_value = _make_raw_response(dict1) + + client.uploads_client.read_uploads.return_value = [_make_mock_upload()] + csv_response = Mock() + csv_response.data = csv_bytes + client.uploads_client.read_upload.return_value = csv_response + + entries = client.fetch_entries(tags=["CV"]) + + # The tag must be forwarded to the eLabFTW read_items API call. + call_kwargs = client.items_client.read_items.call_args.kwargs + assert call_kwargs.get("tags") == ["CV"] + assert len(entries) == 1 + + +# =================================================================== +# Test: upload_entry +# =================================================================== + + +@pytest.mark.elabftw +class TestUploadEntry: + """Tests for ElabFTWClient.upload_entry().""" + + def test_upload_entry(self, sample_entry): + """upload_entry posts an item, uploads CSV, and patches metadata.""" + client = _make_client() + + client.items_client.post_item_with_http_info.return_value = ( + None, + 201, + {"Location": "/api/v2/items/100"}, + ) + + result = client.upload_entry(sample_entry) + + client.items_client.post_item_with_http_info.assert_called_once() + assert client.uploads_client.post_upload.call_count == 1 # CSV only + client.items_client.patch_item.assert_called_once() + # Verify metadata contains the unitpackage descriptor + patch_args = client.items_client.patch_item.call_args + body = patch_args.args[0] + metadata = json.loads(body["metadata"]) + assert "unitpackage" in metadata + assert result == 100 + + def test_upload_entry_custom_title(self, sample_entry): + """A custom title is forwarded to the API call.""" + client = _make_client() + + client.items_client.post_item_with_http_info.return_value = ( + None, + 201, + {"Location": "/api/v2/items/101"}, + ) + + client.upload_entry(sample_entry, title="My Custom Title") + + call_kwargs = client.items_client.post_item_with_http_info.call_args + body = call_kwargs.kwargs.get("body", {}) + assert body.get("title") == "My Custom Title" + + def test_upload_entry_with_tags(self, sample_entry): + """Tags are forwarded into the item creation body.""" + client = _make_client() + + client.items_client.post_item_with_http_info.return_value = ( + None, + 201, + {"Location": "/api/v2/items/105"}, + ) + + client.upload_entry(sample_entry, tags=["test", "cv"]) + + call_kwargs = client.items_client.post_item_with_http_info.call_args + body = call_kwargs.kwargs.get("body", {}) + assert body.get("tags") == ["test", "cv"] + + def test_upload_entry_stores_unitpackage_metadata(self, sample_entry): + """upload_entry stores the datapackage descriptor under the 'unitpackage' key.""" + client = _make_client() + + client.items_client.post_item_with_http_info.return_value = ( + None, + 201, + {"Location": "/api/v2/items/102"}, + ) + + client.upload_entry(sample_entry) + + patch_args = client.items_client.patch_item.call_args + body = patch_args.args[0] + metadata = json.loads(body["metadata"]) + assert "unitpackage" in metadata + assert "resources" in metadata["unitpackage"] + resource = metadata["unitpackage"]["resources"][0] + assert resource["name"] == "test_entry" + assert "schema" in resource + + def test_upload_entry_cleans_up_on_failure(self, sample_entry): + """Partially created item is deleted when upload fails.""" + client = _make_client() + + client.items_client.post_item_with_http_info.return_value = ( + None, + 201, + {"Location": "/api/v2/items/200"}, + ) + client.uploads_client.post_upload.side_effect = RuntimeError("upload failed") + + with pytest.raises(RuntimeError, match="upload failed"): + client.upload_entry(sample_entry) + + client.items_client.delete_item.assert_called_once_with(200) + + def test_upload_entry_missing_location_header(self, sample_entry): + """A missing Location header raises a clear error, not AttributeError.""" + client = _make_client() + + client.items_client.post_item_with_http_info.return_value = (None, 201, {}) + + with pytest.raises(ValueError, match="Location"): + client.upload_entry(sample_entry) + + +# =================================================================== +# Test: round-trip upload → fetch +# =================================================================== + + +@pytest.mark.elabftw +class TestRoundTrip: + """Tests for upload → fetch round-trip.""" + + def test_upload_then_fetch_preserves_entry(self, sample_entry): + """Full round-trip via entity metadata field.""" + client = _make_client() + + # --- Upload phase --- + client.items_client.post_item_with_http_info.return_value = ( + None, + 201, + {"Location": "/api/v2/items/100"}, + ) + + client.upload_entry(sample_entry) + + # Capture the metadata that was patched onto the entity + patch_args = client.items_client.patch_item.call_args + body = patch_args.args[0] + stored_metadata_json = body["metadata"] + + # --- Fetch phase --- + # Build a raw response with the stored metadata + entity_dict = _make_entity_dict(exp_id=100, title="test_entry") + entity_dict["metadata"] = stored_metadata_json + client.items_client.get_item.return_value = _make_raw_response(entity_dict) + + csv_upload = _make_mock_upload(upload_id=1, real_name="test_entry.csv") + client.uploads_client.read_uploads.return_value = [csv_upload] + + csv_response = Mock() + csv_response.data = CSV_DATA + client.uploads_client.read_upload.return_value = csv_response + + result = client.fetch_entry(100) + + # Assert field units and column names are faithfully preserved + assert_field_units(result) + assert list(result.df.columns) == SAMPLE_COLUMNS + + +# =================================================================== +# Test: fixture-based tests (recorded real API responses) +# =================================================================== + + +@pytest.mark.elabftw +class TestFixtureVersions: + """Tests that replay recorded real eLabFTW API responses.""" + + def _wire_client(self, version): + """Return (client, entity_id) with mocks populated from fixture data.""" + client = _make_client() + + # Use the patch_metadata response as entity data source. + # (get_entity.json only has HTTP metadata since it was recorded + # with _preload_content=False.) + patch_data = load_fixture(_BACKEND, version, "patch_metadata") + entity_data = dict(patch_data["response"]) + entity_id = entity_data["id"] + + # Override metadata with the unitpackage descriptor from the request. + entity_data["metadata"] = json.dumps(patch_data["request"]["metadata"]) + + client.items_client.get_item.return_value = _make_raw_response(entity_data) + + uploads = make_mock_uploads(_BACKEND, version) + client.uploads_client.read_uploads.return_value = uploads + + csv_data = get_csv_bytes(_BACKEND, version) + + csv_response = Mock() + csv_response.data = csv_data + client.uploads_client.read_upload.return_value = csv_response + + return client, entity_id + + def test_fetch_entry_from_fixture(self, fixture_version): + """fetch_entry returns a valid Entry from a real recorded fixture.""" + client, entity_id = self._wire_client(fixture_version) + entry = client.fetch_entry(entity_id) + assert entry is not None + assert hasattr(entry, "df") + assert len(entry.df) > 0 + + def test_fetch_entry_columns_from_fixture(self, fixture_version): + """fetch_entry returns an Entry with the correct column names.""" + client, entity_id = self._wire_client(fixture_version) + entry = client.fetch_entry(entity_id) + assert "t" in entry.df.columns + assert "E" in entry.df.columns + + def test_fetch_entry_restores_units_from_fixture(self, fixture_version): + """fetch_entry restores field units from the stored datapackage.json.""" + client, entity_id = self._wire_client(fixture_version) + entry = client.fetch_entry(entity_id) + assert_field_units(entry) + + def test_fetch_entry_restores_metadata_from_fixture(self, fixture_version): + """fetch_entry restores metadata stored in the datapackage descriptor.""" + client, entity_id = self._wire_client(fixture_version) + entry = client.fetch_entry(entity_id) + custom = entry.resource.custom + metadata = custom.get("metadata", {}) + assert "echemdb" in metadata + + def test_info_from_fixture(self, fixture_version): + """info() returns data matching the recorded fixture.""" + client = _make_client() + info_data = load_fixture(_BACKEND, fixture_version, "info")["response"] + + mock_info_api = MagicMock() + mock_info_api.get_info.return_value = info_data + + with patch("unitpackage.eln.elabftw.elabapi_python") as mock_elabapi: + mock_elabapi.InfoApi.return_value = mock_info_api + result = client.info() + + assert isinstance(result, dict) + assert "elabftw_version" in result + + def test_upload_entry_from_fixture(self, fixture_version, sample_entry): + """upload_entry succeeds with real recorded response shapes.""" + client = _make_client() + post_data = load_fixture(_BACKEND, fixture_version, "post_entity")["response"] + + client.items_client.post_item_with_http_info.return_value = ( + None, + post_data["status_code"], + {"Location": post_data["location"]}, + ) + + result = client.upload_entry(sample_entry) + assert result == post_data["entity_id"] + + def test_fetch_entries_from_fixture(self, fixture_version): + """fetch_entries returns entries using recorded fixture data.""" + client, _entity_id = self._wire_client(fixture_version) + + # Build listing response from the same entity data source. + patch_data = load_fixture(_BACKEND, fixture_version, "patch_metadata") + entity_data = dict(patch_data["response"]) + entity_data["metadata"] = json.dumps(patch_data["request"]["metadata"]) + + client.items_client.read_items.return_value = _make_raw_response([entity_data]) + + entries = client.fetch_entries() + assert len(entries) == 1 + assert_basic_entry(entries[0]) + assert_field_units(entries[0]) + + def test_fetch_entry_csv_values_from_fixture(self, fixture_version): + """Fetched DataFrame values match the recorded CSV byte-for-byte.""" + client, entity_id = self._wire_client(fixture_version) + entry = client.fetch_entry(entity_id) + + csv_data = get_csv_bytes(_BACKEND, fixture_version) + expected_df = pd.read_csv(io.BytesIO(csv_data)) + pd.testing.assert_frame_equal(entry.df, expected_df) + + def test_fetch_entry_matches_manifest(self, fixture_version): + """Fetched entry columns and units match the manifest test_data.""" + client, entity_id = self._wire_client(fixture_version) + entry = client.fetch_entry(entity_id) + + manifest = load_manifest(_BACKEND, fixture_version) + test_data = manifest["test_data"] + assert list(entry.df.columns) == test_data["csv_columns"] + + field_dict = make_field_dict(entry) + for name, unit in test_data["field_units"].items(): + assert field_dict[name].get("unit") == unit + + def test_fetch_entry_identifier_from_fixture(self, fixture_version): + """Entry identifier matches the descriptor resource name.""" + client, entity_id = self._wire_client(fixture_version) + entry = client.fetch_entry(entity_id) + + patch_data = load_fixture(_BACKEND, fixture_version, "patch_metadata") + descriptor = patch_data["request"]["metadata"]["unitpackage"] + expected_name = descriptor["resources"][0]["name"] + assert entry.identifier == expected_name + + def test_info_version_from_fixture(self, fixture_version): + """Info response version matches the fixture directory name.""" + client = _make_client() + info_data = load_fixture(_BACKEND, fixture_version, "info")["response"] + + mock_info_api = MagicMock() + mock_info_api.get_info.return_value = info_data + + with patch("unitpackage.eln.elabftw.elabapi_python") as mock_elabapi: + mock_elabapi.InfoApi.return_value = mock_info_api + result = client.info() + + assert result.get("elabftw_version") == fixture_version + + def test_fetch_entry_metadata_values_from_fixture(self, fixture_version): + """Metadata citationKey is preserved through the round-trip.""" + client, entity_id = self._wire_client(fixture_version) + entry = client.fetch_entry(entity_id) + + metadata = entry.resource.custom.get("metadata", {}) + source = metadata.get("echemdb", {}).get("source", {}) + assert "citationKey" in source + assert isinstance(source["citationKey"], str) + assert len(source["citationKey"]) > 0 diff --git a/unitpackage/test/eln/test_kadi.py b/unitpackage/test/eln/test_kadi.py new file mode 100644 index 00000000..2ea29d4e --- /dev/null +++ b/unitpackage/test/eln/test_kadi.py @@ -0,0 +1,746 @@ +""" +Comprehensive tests for unitpackage.kadi -- the Kadi4Mat integration module. + +Every Kadi4Mat API call is mocked; no live server is needed. +Run with: pytest -m kadi +""" + +# pylint: disable=redefined-outer-name,protected-access,too-few-public-methods +# Parallel per-backend test suites share structurally similar assertions. +# pylint: disable=duplicate-code + +import io +import json +from unittest.mock import MagicMock, Mock, patch + +import pandas as pd +import pytest + +from unitpackage.test.eln.fixture_loader import ( + CSV_DATA, + SAMPLE_COLUMNS, + SAMPLE_DESCRIPTOR, + assert_basic_entry, + assert_field_units, + get_csv_bytes, + get_versions, + load_fixture, + load_manifest, + make_field_dict, + make_mock_kadi_record, +) + +# --------------------------------------------------------------------------- +# Fixture loader (recorded real API responses) +# --------------------------------------------------------------------------- + + +_BACKEND = "kadi" +_KADI_VERSIONS = get_versions(_BACKEND) + +# --------------------------------------------------------------------------- +# Constants / mock data shared across tests +# --------------------------------------------------------------------------- + +_KADI_CSV_NAME = "test_entry.csv" + + +def _make_mock_record( + record_id=42, + title="CV measurement on Pt(111)", + identifier="cv_measurement_pt111", +): + """Return a self-contained Mock mimicking a Kadi4Mat record with a unitpackage descriptor.""" + from unitpackage.eln.kadi import _dict_to_kadi_extras + + # Kadi4Mat stores the descriptor as a typed ``extras`` tree under the + # ``"unitpackage"`` key. + extras = _dict_to_kadi_extras(SAMPLE_DESCRIPTOR, key="unitpackage") + + rec = Mock() + rec.id = record_id + rec.title = title + rec.identifier = identifier + rec.meta = { + "id": record_id, + "title": title, + "identifier": identifier, + "extras": [extras], + } + + filelist_mock = Mock() + filelist_mock.json.return_value = { + "items": [_make_mock_file_entry("csv-id", _KADI_CSV_NAME)] + } + rec.get_filelist.return_value = filelist_mock + + def _download(**kwargs): + with open(kwargs["file_path"], "wb") as fh: + fh.write(CSV_DATA) + + rec.download_file = _download + return rec + + +def _make_mock_file_entry(file_id="abc-123", name="test_entry.csv"): + """Return a dict that looks like a Kadi4Mat file list entry.""" + return {"id": file_id, "name": name} + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def mock_record(): + """Mock Kadi4Mat record, populated from fixtures when available.""" + if _KADI_VERSIONS: + return make_mock_kadi_record(_KADI_VERSIONS[-1], CSV_DATA) + return _make_mock_record() + + +@pytest.fixture(params=_KADI_VERSIONS if _KADI_VERSIONS else [None]) +def fixture_version(request): + """Pytest fixture: parameterised over each recorded Kadi version.""" + version = request.param + if version is None: + pytest.skip("No kadi fixtures recorded") + return version + + +# --------------------------------------------------------------------------- +# Helpers -- build a minimal KadiClient without hitting the real API +# --------------------------------------------------------------------------- + + +def _make_client(): + """ + Instantiate a KadiClient with the KadiManager replaced by a Mock. + + The fake kadi_apy module is already injected into sys.modules + by conftest.py, so we can import directly. + """ + from unitpackage.eln.kadi import KadiClient + + client = KadiClient.__new__(KadiClient) + client._manager = MagicMock() + client._host = "https://kadi.example.org" + return client + + +# =================================================================== +# Test: initialisation +# =================================================================== + + +@pytest.mark.kadi +class TestClientInit: + """Tests for KadiClient.__init__ and from_env.""" + + def test_client_init(self): + """KadiManager is created with the supplied host, pat, and verify=True by default.""" + from unitpackage.eln.kadi import KadiClient + + with patch("unitpackage.eln.kadi.KadiManager") as mock_manager_cls: + KadiClient( + host="https://kadi.example.edu", + pat="test-pat-123", + ) + mock_manager_cls.assert_called_once() + kwargs = mock_manager_cls.call_args.kwargs + assert kwargs["host"] == "https://kadi.example.edu" + assert kwargs["pat"] == "test-pat-123" + assert kwargs["verify"] is True + + def test_client_init_forwards_empty_base_path(self): + """base_path is always forwarded (defaulting to "") so KadiManager + never falls back to dereferencing its unloaded (None) config.""" + from unitpackage.eln.kadi import KadiClient + + with patch("unitpackage.eln.kadi.KadiManager") as mock_manager_cls: + KadiClient(host="https://kadi.example.edu", pat="test-pat-123") + assert mock_manager_cls.call_args.kwargs["base_path"] == "" + + def test_client_init_forwards_explicit_base_path(self): + """An explicit base_path (subpath instance) is forwarded as given.""" + from unitpackage.eln.kadi import KadiClient + + with patch("unitpackage.eln.kadi.KadiManager") as mock_manager_cls: + KadiClient( + host="https://kadi.example.edu", + pat="test-pat-123", + base_path="kadi", + ) + assert mock_manager_cls.call_args.kwargs["base_path"] == "kadi" + + def test_client_init_no_verify_ssl(self): + """KadiManager is created with verify=False when verify_ssl=False.""" + from unitpackage.eln.kadi import KadiClient + + with patch("unitpackage.eln.kadi.KadiManager") as mock_manager_cls: + KadiClient( + host="https://kadi.example.edu", + pat="test-pat-123", + verify_ssl=False, + ) + mock_manager_cls.assert_called_once() + kwargs = mock_manager_cls.call_args.kwargs + assert kwargs["host"] == "https://kadi.example.edu" + assert kwargs["pat"] == "test-pat-123" + assert kwargs["verify"] is False + + def test_client_from_env(self): + """from_env reads KADI_HOST and KADI_PAT from the environment.""" + from unitpackage.eln.kadi import KadiClient + + env = { + "KADI_HOST": "https://env-kadi.example.edu", + "KADI_PAT": "env-pat-456", + } + with ( + patch("unitpackage.eln.kadi.KadiManager"), + patch.dict("os.environ", env, clear=False), + ): + assert KadiClient.from_env() is not None + + def test_client_from_env_missing_vars(self): + """from_env raises when the required env vars are absent.""" + from unitpackage.eln.kadi import KadiClient + + with patch.dict("os.environ", {}, clear=True): + with pytest.raises((KeyError, ValueError, EnvironmentError)): + KadiClient.from_env() + + +# =================================================================== +# Test: info +# =================================================================== + + +@pytest.mark.kadi +class TestInfo: + """Tests for KadiClient.info().""" + + def test_info(self): + """info() makes a request and returns a dict.""" + client = _make_client() + + mock_response = Mock() + mock_response.json.return_value = {"version": "1.8.0"} + client._manager.make_request.return_value = mock_response + + result = client.info() + + assert isinstance(result, dict) + assert result["version"] == "1.8.0" + + +# =================================================================== +# Test: fetch_entry +# =================================================================== + + +@pytest.mark.kadi +class TestFetchEntry: + """Tests for KadiClient.fetch_entry().""" + + def test_fetch_entry(self, mock_record): + """fetch_entry downloads a record's CSV and returns a valid Entry.""" + client = _make_client() + client._manager.record.return_value = mock_record + + entry = client.fetch_entry(42) + + client._manager.record.assert_called_once_with(id=42) + assert_basic_entry(entry) + + def test_fetch_entry_no_csv(self, mock_record): + """fetch_entry raises ValueError when no CSV file exists.""" + client = _make_client() + + client._manager.record.return_value = mock_record + + filelist_response = Mock() + filelist_response.json.return_value = { + "items": [_make_mock_file_entry("img-id", "image.png")] + } + mock_record.get_filelist.return_value = filelist_response + + with pytest.raises(ValueError, match="(?i)csv"): + client.fetch_entry(42) + + def test_fetch_entry_metadata_mapping(self): + """Metadata stored in the unitpackage descriptor is restored on fetch.""" + client = _make_client() + mock_rec = _make_mock_record() + client._manager.record.return_value = mock_rec + + entry = client.fetch_entry(42) + + metadata = entry.resource.custom.get("metadata", {}) + assert metadata is not None + assert "echemdb" in metadata + + +# =================================================================== +# Test: fetch_entries +# =================================================================== + + +@pytest.mark.kadi +class TestFetchEntries: + """Tests for KadiClient.fetch_entries().""" + + def test_fetch_entries_all(self): + """fetch_entries returns multiple Entry objects.""" + client = _make_client() + + search_response = Mock() + search_response.json.return_value = { + "items": [ + {"id": 1, "title": "Record 1"}, + {"id": 2, "title": "Record 2"}, + ] + } + client._manager.search.search_resources.return_value = search_response + + with patch.object(client, "fetch_entry") as mock_fetch: + mock_fetch.side_effect = [ + Mock(spec=["df", "identifier"]), + Mock(spec=["df", "identifier"]), + ] + + entries = client.fetch_entries() + + assert len(entries) == 2 + assert mock_fetch.call_count == 2 + + def test_fetch_entries_with_tags(self): + """fetch_entries passes tags to the search API.""" + client = _make_client() + + search_response = Mock() + search_response.json.return_value = {"items": [{"id": 1, "title": "E1"}]} + client._manager.search.search_resources.return_value = search_response + + with patch.object(client, "fetch_entry") as mock_fetch: + mock_fetch.return_value = Mock(spec=["df", "identifier"]) + entries = client.fetch_entries(tags=["electrochemistry"]) + + client._manager.search.search_resources.assert_called_once_with( + "record", page=1, per_page=100, tags=["electrochemistry"] + ) + assert len(entries) == 1 + + def test_fetch_entries_skips_non_unitpackage(self): + """fetch_entries skips records lacking a 'unitpackage' metadatum. + + Exercises the real fetch_entry rejection together with the + _collect_entries skip path (no fetch_entry mock). + """ + client = _make_client() + + search_response = Mock() + search_response.json.return_value = { + "items": [{"id": 7, "title": "Foreign record"}] + } + client._manager.search.search_resources.return_value = search_response + + # A record without the unitpackage metadatum -> fetch_entry raises + # ValueError -> the record is skipped rather than aborting the export. + foreign = Mock() + foreign.meta = {"id": 7, "title": "Foreign record", "extras": []} + client._manager.record.return_value = foreign + + entries = client.fetch_entries() + assert len(entries) == 0 + + def test_fetch_entries_walks_all_pages(self): + """fetch_entries follows pagination beyond the first page.""" + client = _make_client() + + page1 = Mock() + page1.json.return_value = { + "items": [{"id": 1, "title": "R1"}, {"id": 2, "title": "R2"}], + "_pagination": {"total_pages": 2}, + } + page2 = Mock() + page2.json.return_value = { + "items": [{"id": 3, "title": "R3"}], + "_pagination": {"total_pages": 2}, + } + client._manager.search.search_resources.side_effect = [page1, page2] + + with patch.object(client, "fetch_entry") as mock_fetch: + mock_fetch.side_effect = [Mock(spec=["df", "identifier"]) for _ in range(3)] + + entries = client.fetch_entries() + + assert len(entries) == 3 + assert client._manager.search.search_resources.call_count == 2 + second_call = client._manager.search.search_resources.call_args_list[1] + assert second_call.kwargs["page"] == 2 + + +# =================================================================== +# Test: upload_entry +# =================================================================== + + +@pytest.mark.kadi +class TestUploadEntry: + """Tests for KadiClient.upload_entry().""" + + def test_upload_entry(self, sample_entry): + """upload_entry creates a record, uploads the CSV, and stores the datapackage descriptor.""" + client = _make_client() + + mock_record = Mock() + mock_record.meta = {"id": 100} + client._manager.record.return_value = mock_record + + result = client.upload_entry(sample_entry) + + client._manager.record.assert_called_once() + mock_record.upload_file.assert_called_once() + assert result == 100 + + mock_record.add_metadatum.assert_called_once() + call_kwargs = mock_record.add_metadatum.call_args.kwargs["metadatum"] + assert call_kwargs["key"] == "unitpackage" + assert call_kwargs["type"] == "dict" + from unitpackage.eln.kadi import _kadi_extras_to_python + + descriptor = _kadi_extras_to_python(call_kwargs) + assert "resources" in descriptor + + def test_upload_entry_custom_title(self, sample_entry): + """A custom title is used for the record.""" + client = _make_client() + + mock_record = Mock() + mock_record.meta = {"id": 101} + client._manager.record.return_value = mock_record + + client.upload_entry(sample_entry, title="My Custom Title") + + call_kwargs = client._manager.record.call_args + assert call_kwargs.kwargs.get("title") == "My Custom Title" + + def test_upload_entry_with_tags(self, sample_entry): + """Tags are added to the record after creation.""" + client = _make_client() + + mock_record = Mock() + mock_record.meta = {"id": 102} + client._manager.record.return_value = mock_record + + client.upload_entry(sample_entry, tags=["test", "cv"]) + + assert mock_record.add_tag.call_count == 2 + + def test_upload_entry_rolls_back_on_failure(self, sample_entry): + """A failure after record creation deletes the orphaned record.""" + client = _make_client() + + mock_record = Mock() + mock_record.meta = {"id": 103} + mock_record.created = True + mock_record.add_metadatum.side_effect = RuntimeError("boom") + client._manager.record.return_value = mock_record + + with pytest.raises(RuntimeError, match="boom"): + client.upload_entry(sample_entry) + + mock_record.delete.assert_called_once() + + def test_upload_entry_refuses_existing_record(self, sample_entry): + """An identifier collision is refused without touching the record.""" + client = _make_client() + + mock_record = Mock() + mock_record.meta = {"id": 104} + # kadi-apy returns an existing record (created is False) on collision. + mock_record.created = False + client._manager.record.return_value = mock_record + + with pytest.raises(ValueError, match="already"): + client.upload_entry(sample_entry) + + mock_record.upload_file.assert_not_called() + mock_record.add_metadatum.assert_not_called() + mock_record.delete.assert_not_called() + + +# =================================================================== +# Test: descriptor helpers and fetch with unitpackage descriptor +# =================================================================== + + +@pytest.mark.kadi +class TestDescriptor: + """Tests for descriptor helpers and fetch with unitpackage descriptor.""" + + def test_build_datapackage_descriptor(self, sample_entry): + """build_datapackage_descriptor produces a frictionless package dict with schema.""" + from unitpackage.eln import build_datapackage_descriptor + + descriptor = build_datapackage_descriptor(sample_entry) + + assert isinstance(descriptor, dict) + assert "resources" in descriptor + schema_fields = descriptor["resources"][0].get("schema", {}).get("fields", []) + field_units = {f["name"]: f.get("unit") for f in schema_fields} + assert field_units.get("t") == "s" + assert field_units.get("E") == "V" + + def test_fetch_entry_with_unitpackage_descriptor(self, mock_record, sample_entry): + """fetch_entry restores schema and metadata from a stored unitpackage descriptor.""" + from unitpackage.eln import build_datapackage_descriptor + + client = _make_client() + + descriptor = build_datapackage_descriptor(sample_entry) + datapackage_json = json.dumps(descriptor) + + mock_record.meta = { + "id": 42, + "title": mock_record.title, + "extras": [ + {"key": "unitpackage", "value": datapackage_json, "type": "str"}, + ], + } + client._manager.record.return_value = mock_record + + filelist_response = Mock() + filelist_response.json.return_value = { + "items": [_make_mock_file_entry("csv-id", "test_entry.csv")] + } + mock_record.get_filelist.return_value = filelist_response + + def mock_download(file_id, file_path): # pylint: disable=unused-argument + with open(file_path, "wb") as f: + f.write(CSV_DATA) + + mock_record.download_file = mock_download + + entry = client.fetch_entry(42) + assert_field_units(entry) + + def test_fetch_entry_no_unitpackage_descriptor_raises(self, mock_record): + """fetch_entry raises ValueError for records without a unitpackage metadatum.""" + client = _make_client() + + mock_record.meta = {"id": 42, "title": mock_record.title, "extras": []} + client._manager.record.return_value = mock_record + + with pytest.raises(ValueError, match="unitpackage"): + client.fetch_entry(42) + + +# =================================================================== +# Test: round-trip upload -> fetch +# =================================================================== + + +@pytest.mark.kadi +class TestRoundTrip: + """Tests for upload → fetch round-trip.""" + + def test_upload_then_fetch_preserves_entry(self, sample_entry): + """Full round-trip: upload_entry stores descriptor; fetch_entry restores it.""" + client = _make_client() + + mock_record = Mock() + mock_record.meta = {"id": 42} + client._manager.record.return_value = mock_record + + client.upload_entry(sample_entry, title="Test Round-Trip") + + stored_extras = mock_record.add_metadatum.call_args.kwargs["metadatum"] + + mock_record.meta = { + "id": 42, + "title": "Test Round-Trip", + "extras": [stored_extras], + } + + filelist_response = Mock() + filelist_response.json.return_value = { + "items": [_make_mock_file_entry("csv-id", "test_entry.csv")] + } + mock_record.get_filelist.return_value = filelist_response + + def mock_download(file_id, file_path): # pylint: disable=unused-argument + with open(file_path, "wb") as f: + f.write(CSV_DATA) + + mock_record.download_file = mock_download + + result = client.fetch_entry(42) + assert_field_units(result) + assert list(result.df.columns) == SAMPLE_COLUMNS + + +# =================================================================== +# Test: fixture-based tests (recorded real API responses) +# =================================================================== + + +@pytest.mark.kadi +class TestFixtureVersions: + """Tests that replay recorded real Kadi4Mat API responses.""" + + def _wire_client(self, version): + """Return (client, record) with mocks populated from fixture data.""" + client = _make_client() + csv_data = get_csv_bytes(_BACKEND, version) + record = make_mock_kadi_record(version, csv_data) + client._manager.record.return_value = record + return client, record + + def test_fetch_entry_from_fixture(self, fixture_version): + """fetch_entry returns a valid Entry from a real recorded fixture.""" + client, record = self._wire_client(fixture_version) + entry = client.fetch_entry(record.id) + assert entry is not None + assert hasattr(entry, "df") + assert len(entry.df) > 0 + + def test_fetch_entry_columns_from_fixture(self, fixture_version): + """fetch_entry returns an Entry with the expected column names.""" + client, record = self._wire_client(fixture_version) + entry = client.fetch_entry(record.id) + assert "t" in entry.df.columns + assert "E" in entry.df.columns + + def test_fetch_entry_restores_units_from_fixture(self, fixture_version): + """fetch_entry restores field units from the stored datapackage descriptor.""" + client, record = self._wire_client(fixture_version) + entry = client.fetch_entry(record.id) + assert_field_units(entry) + + def test_fetch_entry_restores_metadata_from_fixture(self, fixture_version): + """fetch_entry restores metadata stored in the datapackage descriptor.""" + client, record = self._wire_client(fixture_version) + entry = client.fetch_entry(record.id) + metadata = entry.resource.custom.get("metadata", {}) + assert "echemdb" in metadata + + def test_info_from_fixture(self, fixture_version): + """info() returns data matching the recorded fixture.""" + client = _make_client() + info_data = load_fixture(_BACKEND, fixture_version, "info")["response"] + + mock_response = Mock() + mock_response.json.return_value = info_data + client._manager.make_request.return_value = mock_response + + result = client.info() + assert isinstance(result, dict) + assert "version" in result + + def test_upload_entry_from_fixture(self, fixture_version, sample_entry): + """upload_entry succeeds with real recorded response shapes.""" + client = _make_client() + create_data = load_fixture(_BACKEND, fixture_version, "create_record")[ + "response" + ] + + mock_record = Mock() + mock_record.meta = create_data + mock_record.created = True + client._manager.record.return_value = mock_record + + result = client.upload_entry(sample_entry) + assert result == create_data["id"] + + def test_fetch_entries_from_fixture(self, fixture_version): + """fetch_entries returns entries using recorded fixture data.""" + client, _record = self._wire_client(fixture_version) + + list_data = load_fixture(_BACKEND, fixture_version, "list_list_entities")[ + "response" + ] + + search_response = Mock() + search_response.json.return_value = list_data + client._manager.search.search_resources.return_value = search_response + + entries = client.fetch_entries() + assert len(entries) >= 1 + assert_basic_entry(entries[0]) + assert_field_units(entries[0]) + + def test_fetch_entry_csv_values_from_fixture(self, fixture_version): + """Fetched DataFrame values match the recorded CSV byte-for-byte.""" + client, record = self._wire_client(fixture_version) + entry = client.fetch_entry(record.id) + + csv_data = get_csv_bytes(_BACKEND, fixture_version) + expected_df = pd.read_csv(io.BytesIO(csv_data)) + pd.testing.assert_frame_equal(entry.df, expected_df) + + def test_fetch_entry_matches_manifest(self, fixture_version): + """Fetched entry columns and units match the manifest test_data.""" + client, record = self._wire_client(fixture_version) + entry = client.fetch_entry(record.id) + + manifest = load_manifest(_BACKEND, fixture_version) + test_data = manifest["test_data"] + assert list(entry.df.columns) == test_data["csv_columns"] + + field_dict = make_field_dict(entry) + for name, unit in test_data["field_units"].items(): + assert field_dict[name].get("unit") == unit + + def test_fetch_entry_identifier_from_fixture(self, fixture_version): + """Entry identifier matches the descriptor resource name.""" + from unitpackage.eln.kadi import _kadi_extras_to_python + + client, record = self._wire_client(fixture_version) + entry = client.fetch_entry(record.id) + + entity_data = load_fixture(_BACKEND, fixture_version, "get_entity")["response"] + extras = entity_data.get("extras", []) + up_extra = next(e for e in extras if e.get("key") == "unitpackage") + if up_extra.get("type") == "dict": + descriptor = _kadi_extras_to_python(up_extra) + else: + descriptor = json.loads(up_extra.get("value", "{}")) + expected_name = descriptor["resources"][0]["name"] + assert entry.identifier == expected_name + + def test_info_version_from_fixture(self, fixture_version): + """Info response version matches the fixture directory name.""" + client = _make_client() + info_data = load_fixture(_BACKEND, fixture_version, "info")["response"] + + mock_response = Mock() + mock_response.json.return_value = info_data + client._manager.make_request.return_value = mock_response + + result = client.info() + assert result.get("version") == fixture_version + + def test_fetch_entry_metadata_values_from_fixture(self, fixture_version): + """Metadata citationKey is preserved through the round-trip.""" + client, record = self._wire_client(fixture_version) + entry = client.fetch_entry(record.id) + + metadata = entry.resource.custom.get("metadata", {}) + source = metadata.get("echemdb", {}).get("source", {}) + assert "citationKey" in source + assert isinstance(source["citationKey"], str) + assert len(source["citationKey"]) > 0 + + def test_fixture_consistency(self, fixture_version): + """get_entity.json and list_get_entity.json describe the same entity.""" + single = load_fixture(_BACKEND, fixture_version, "get_entity")["response"] + list_ver = load_fixture(_BACKEND, fixture_version, "list_get_entity")[ + "response" + ] + assert single["id"] == list_ver["id"] + assert single["title"] == list_ver["title"] + assert single["identifier"] == list_ver["identifier"]