## Installation
-```
-pip3 install datajoint
-```
-If you already have an older version of DataJoint installed using `pip`, upgrade with
```bash
-pip3 install --upgrade datajoint
+pip install datajoint
```
-## Python Native Blobs
-
-DataJoint 0.12 adds full support for all native python data types in blobs: tuples, lists, sets, dicts, strings, bytes, `None`, and all their recursive combinations.
-The new blobs are a superset of the old functionality and are fully backward compatible.
-In previous versions, only MATLAB-style numerical arrays were fully supported.
-Some Python datatypes such as dicts were coerced into numpy recarrays and then fetched as such.
-
-However, since some Python types were coerced into MATLAB types, old blobs and new blobs may now be fetched as different types of objects even if they were inserted the same way.
-For example, new `dict` objects will be returned as `dict` while the same types of objects inserted with `datajoint 0.11` will be recarrays.
-Since this is a big change, we chose to temporarily disable this feature by default in DataJoint for Python 0.12.x, allowing users to adjust their code if necessary.
-From 13.x, the flag will default to True (on), and will ultimately be removed when corresponding decode support for the new format is added to datajoint-matlab (see: datajoint-matlab #222, datajoint-python #765).
+or with Conda:
-The flag is configured by setting the `enable_python_native_blobs` flag in `dj.config`.
-
-```python
-import datajoint as dj
-dj.config["enable_python_native_blobs"] = True
-```
-
-You can safely enable this setting if both of the following are true:
-
- * The only kinds of blobs your pipeline have inserted previously were numerical arrays.
- * You do not need to share blob data between Python and MATLAB.
-
-Otherwise, read the following explanation.
-
-DataJoint v0.12 expands DataJoint's blob serialization mechanism with
-improved support for complex native python datatypes, such as dictionaries
-and lists of strings.
-
-Prior to DataJoint v0.12, certain python native datatypes such as
-dictionaries were 'squashed' into numpy structured arrays when saved into
-blob attributes. This facilitated easier data sharing between MATLAB
-and Python for certain record types. However, this created a discrepancy
-between insert and fetch datatypes which could cause problems in other
-portions of users pipelines.
-
-DataJoint v0.12, removes the squashing behavior, instead encoding native python datatypes in blobs directly.
-However, this change creates a compatibility problem for pipelines
-which previously relied on the type squashing behavior since records
-saved via the old squashing format will continue to fetch
-as structured arrays, whereas new record inserted in DataJoint 0.12 with
-`enable_python_native_blobs` would result in records returned as the
-appropriate native python type (dict, etc).
-Furthermore, DataJoint for MATLAB does not yet support unpacking native Python datatypes.
-
-With `dj.config["enable_python_native_blobs"]` set to `False`,
-any attempt to insert any datatype other than a numpy array will result in an exception.
-This is meant to get users to read this message in order to allow proper testing
-and migration of pre-0.12 pipelines to 0.12 in a safe manner.
-
-The exact process to update a specific pipeline will vary depending on
-the situation, but generally the following strategies may apply:
-
- * Altering code to directly store numpy structured arrays or plain
- multidimensional arrays. This strategy is likely best one for those
- tables requiring compatibility with MATLAB.
- * Adjust code to deal with both structured array and native fetched data
- for those tables that are populated with `dict`s in blobs in pre-0.12 version.
- In this case, insert logic is not adjusted, but downstream consumers
- are adjusted to handle records saved under the old and new schemes.
- * Migrate data into a fresh schema, fetching the old data, converting blobs to
- a uniform data type and re-inserting.
- * Drop/Recompute imported/computed tables to ensure they are in the new
- format.
-
-As always, be sure that your data is safely backed up before modifying any
-important DataJoint schema or records.
-
-## Documentation and Tutorials
-A number of labs are currently adopting DataJoint and we are quickly getting the documentation in shape in February 2017.
-
-* https://datajoint.io -- start page
-* https://docs.datajoint.io -- up-to-date documentation
-* https://tutorials.datajoint.io -- step-by-step tutorials
-* https://catalog.datajoint.io -- catalog of example pipelines
-
-## Running Tests Locally
-
-
-* Create an `.env` with desired development environment values e.g.
-``` sh
-PY_VER=3.7
-ALPINE_VER=3.10
-MYSQL_VER=5.7
-MINIO_VER=RELEASE.2021-09-03T03-56-13Z
-UID=1000
-GID=1000
+```bash
+conda install -c conda-forge datajoint
```
-* `cp local-docker-compose.yml docker-compose.yml`
-* `docker-compose up -d` (Note configured `JUPYTER_PASSWORD`)
-* Select a means of running Tests e.g. Docker Terminal, or Local Terminal (see bottom)
-* Add entry in `/etc/hosts` for `127.0.0.1 fakeservices.datajoint.io`
-* Run desired tests. Some examples are as follows:
-
-| Use Case | Shell Code |
-| ---------------------------- | ------------------------------------------------------------------------------ |
-| Run all tests | `nosetests -vsw tests --with-coverage --cover-package=datajoint` |
-| Run one specific class test | `nosetests -vs --tests=tests.test_fetch:TestFetch.test_getattribute_for_fetch1` |
-| Run one specific basic test | `nosetests -vs --tests=tests.test_external_class:test_insert_and_fetch` |
-
-### Launch Docker Terminal
-* Shell into `datajoint-python_app_1` i.e. `docker exec -it datajoint-python_app_1 sh`
+## Example Pipeline
+
-### Launch Local Terminal
-* See `datajoint-python_app` environment variables in `local-docker-compose.yml`
-* Launch local terminal
-* `export` environment variables in shell
-* Add entry in `/etc/hosts` for `127.0.0.1 fakeservices.datajoint.io`
+**Cite DataJoint:** [Yatsenko et al., 2026](https://arxiv.org/abs/2602.16585) — RRID: [SCR_014543](https://scicrunch.org/resolver/SCR_014543)
+## Resources
+- **[Documentation](https://docs.datajoint.com)** — Complete guides and reference
+ - [Tutorials](https://docs.datajoint.com/tutorials/) — Learn by example
+ - [How-To Guides](https://docs.datajoint.com/how-to/) — Task-oriented guides
+ - [API Reference](https://docs.datajoint.com/api/) — Complete API documentation
+ - [Migration Guide](https://docs.datajoint.com/how-to/migrate-to-v20/) — Upgrade from legacy versions
+- **[DataJoint Elements](https://docs.datajoint.com/elements/)** — Example pipelines for neuroscience
+- **[GitHub Discussions](https://github.com/datajoint/datajoint-python/discussions)** — Community support
+## Contributing
-### Launch Jupyter Notebook for Interactive Use
-* Navigate to `localhost:8888`
-* Input Jupyter password
-* Launch a notebook i.e. `New > Python 3`
+See [CONTRIBUTING.md](https://github.com/datajoint/datajoint-python/blob/master/CONTRIBUTING.md) for development setup and guidelines.
diff --git a/RELEASE_MEMO.md b/RELEASE_MEMO.md
new file mode 100644
index 000000000..73700b602
--- /dev/null
+++ b/RELEASE_MEMO.md
@@ -0,0 +1,227 @@
+# DataJoint Release Memo
+
+## Branch Structure
+
+| Branch | Purpose | Version |
+|--------|---------|---------|
+| `master` | Main development | 2.1.x |
+| `maint/2.0` | Maintenance releases | 2.0.x |
+
+For 2.0.x bugfixes:
+1. Commit to `maint/2.0`
+2. Tag and release as v2.0.x
+3. Cherry-pick to master if applicable
+
+---
+
+## Writing Release Notes
+
+Good release notes help users understand what changed and whether they need to take action.
+
+### Categories
+
+Organize changes into these categories (in order):
+
+| Category | When to Use | Example |
+|----------|-------------|---------|
+| **BREAKING** | Changes that require user action | API changes, removed features |
+| **Added** | New features | New methods, new options |
+| **Changed** | Behavior changes (non-breaking) | Performance improvements, defaults |
+| **Deprecated** | Features marked for removal | Old syntax warnings |
+| **Fixed** | Bug fixes | Error corrections |
+| **Security** | Security patches | Vulnerability fixes |
+
+### Format
+
+```markdown
+## What's Changed
+
+### BREAKING CHANGES
+- **`fetch()` removed** — Use `to_dicts()`, `to_pandas()`, or `to_arrays()` instead (#123)
+
+### Added
+- New `to_polars()` method for Polars DataFrame output (#456)
+- Support for custom codecs via `@codec` decorator (#789)
+
+### Changed
+- Improved query performance for complex joins (2-3x faster)
+- Default connection timeout increased to 30s
+
+### Fixed
+- Fixed incorrect NULL handling in aggregations (#234)
+
+### Full Changelog
+https://github.com/datajoint/datajoint-python/compare/v2.0.0...v2.1.0
+```
+
+### Guidelines
+
+1. **Lead with breaking changes** — Users need to see these first
+2. **Explain the "why"** — Not just what changed, but why it matters
+3. **Link to PRs/issues** — For users who want details
+4. **Use imperative mood** — "Add feature" not "Added feature"
+5. **Be concise** — One line per change, details in PR
+
+### PR Labels
+
+The release drafter uses PR labels to categorize changes:
+
+| Label | Category |
+|-------|----------|
+| `breaking` | BREAKING CHANGES |
+| `enhancement` | Added |
+| `bug` | Fixed |
+| `documentation` | (usually excluded) |
+
+Ensure PRs have appropriate labels before merging.
+
+---
+
+## PyPI Release Process
+
+### Steps
+
+1. **Add labels to merged PRs** for release-drafter categorization
+2. **Run "Manual Draft Release" workflow** on GitHub Actions
+3. **Edit the draft release**:
+ - Set release name to `Release X.Y.Z`
+ - Set tag to `vX.Y.Z`
+ - Review and edit release notes
+4. **Publish the release**
+5. Automation will:
+ - Update `version.py` to `X.Y.Z`
+ - Build and publish to PyPI
+ - Create PR to merge version update back to master
+
+### Version Note
+
+The release drafter computes version from the previous tag. You may need to **manually edit** the release name for major version changes.
+
+The regex in `post_draft_release_published.yaml` extracts version from the release name:
+```bash
+VERSION=$(echo "${{ github.event.release.name }}" | grep -oP '\d+\.\d+\.\d+')
+```
+
+---
+
+## Conda-Forge Release Process
+
+DataJoint has a [conda-forge feedstock](https://github.com/conda-forge/datajoint-feedstock).
+
+### How Conda-Forge Updates Work
+
+Conda-forge has **automated bots** that detect new PyPI releases and create PRs automatically:
+
+1. **You publish to PyPI** (via the GitHub release workflow)
+2. **regro-cf-autotick-bot** detects the new version within ~24 hours
+3. **Bot creates a PR** to the feedstock with updated version and hash
+4. **Maintainers review and merge**
+5. **Package builds automatically** for all platforms
+
+### Manual Update (if bot doesn't trigger)
+
+If the bot doesn't create a PR, manually update the feedstock:
+
+1. **Fork** [conda-forge/datajoint-feedstock](https://github.com/conda-forge/datajoint-feedstock)
+
+2. **Edit `recipe/meta.yaml`**:
+ ```yaml
+ {% set version = "2.1.0" %}
+
+ package:
+ name: datajoint
+ version: {{ version }}
+
+ source:
+ url: https://pypi.io/packages/source/d/datajoint/datajoint-{{ version }}.tar.gz
+ sha256:
+
+ build:
+ number: 0 # Reset to 0 for new version
+ ```
+
+3. **Get the SHA256 hash**:
+ ```bash
+ curl -sL https://pypi.org/pypi/datajoint/2.1.0/json | jq -r '.urls[] | select(.packagetype=="sdist") | .digests.sha256'
+ ```
+
+4. **Check dependencies** match `pyproject.toml`:
+ ```yaml
+ requirements:
+ host:
+ - python {{ python_min }}
+ - pip
+ - setuptools >=62.0
+ run:
+ - python >={{ python_min }}
+ - numpy
+ - pandas
+ - pymysql >=1.0
+ - minio
+ - packaging
+ # ... etc
+ ```
+
+5. **Submit PR** to the feedstock
+
+### Verification
+
+After release:
+```bash
+conda search datajoint -c conda-forge
+```
+
+---
+
+## Documentation Release Process
+
+Documentation is hosted at [docs.datajoint.com](https://docs.datajoint.com) and built from [datajoint-docs](https://github.com/datajoint/datajoint-docs).
+
+### How Documentation Builds Work
+
+The documentation build:
+1. Checks out `datajoint-python` from the `master` branch
+2. Uses mkdocstrings to generate API docs from source docstrings
+3. Builds static site with MkDocs
+4. Deploys to `gh-pages` branch
+
+### Triggering a Documentation Build
+
+Documentation rebuilds automatically when:
+- Changes are pushed to `datajoint-docs` main branch
+
+To manually trigger a rebuild (e.g., after updating docstrings in datajoint-python):
+```bash
+gh workflow run development.yml --repo datajoint/datajoint-docs
+```
+
+Or use the "Run workflow" button in GitHub Actions.
+
+### Updating Documentation
+
+1. **For docstring changes**: Update docstrings in `datajoint-python`, then trigger a docs rebuild
+2. **For content changes**: Edit files in `datajoint-docs/src/`, push to main
+3. **Docstring style**: Use NumPy-style docstrings (see CONTRIBUTING.md)
+
+### Verification
+
+After build completes:
+- Check [docs.datajoint.com](https://docs.datajoint.com)
+- Verify API reference pages show updated content
+
+---
+
+## Maintainers
+
+- @datajointbot
+- @dimitri-yatsenko
+- @ttngu207
+
+## Links
+
+- [datajoint-python on GitHub](https://github.com/datajoint/datajoint-python)
+- [datajoint-docs on GitHub](https://github.com/datajoint/datajoint-docs)
+- [datajoint-feedstock on GitHub](https://github.com/conda-forge/datajoint-feedstock)
+- [datajoint on Anaconda.org](https://anaconda.org/conda-forge/datajoint)
+- [datajoint on PyPI](https://pypi.org/project/datajoint/)
+- [docs.datajoint.com](https://docs.datajoint.com)
diff --git a/activate.sh b/activate.sh
new file mode 100644
index 000000000..1632accc8
--- /dev/null
+++ b/activate.sh
@@ -0,0 +1,4 @@
+#! /usr/bin/bash
+# This script registers dot plugins so that we can use graphviz
+# to write png images
+dot -c
\ No newline at end of file
diff --git a/datajoint.pub b/datajoint.pub
deleted file mode 100644
index 4aaa823d2..000000000
--- a/datajoint.pub
+++ /dev/null
@@ -1,6 +0,0 @@
------BEGIN PUBLIC KEY-----
-MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDUMOo2U7YQ1uOrKU/IreM3AQP2
-AXJC3au+S9W+dilxHcJ3e98bRVqrFeOofcGeRPoNc38fiLmLDUiBskJeVrpm29Wo
-AkH6yhZWk1o8NvGMhK4DLsJYlsH6tZuOx9NITKzJuOOH6X1I5Ucs7NOSKnmu7g5g
-WTT5kCgF5QAe5JN8WQIDAQAB
------END PUBLIC KEY-----
diff --git a/datajoint/__init__.py b/datajoint/__init__.py
deleted file mode 100644
index d0303d2dd..000000000
--- a/datajoint/__init__.py
+++ /dev/null
@@ -1,49 +0,0 @@
-"""
-DataJoint for Python is a framework for building data piplines using MySQL databases
-to represent pipeline structure and bulk storage systems for large objects.
-DataJoint is built on the foundation of the relational data model and prescribes a
-consistent method for organizing, populating, and querying data.
-
-The DataJoint data model is described in https://arxiv.org/abs/1807.11104
-
-DataJoint is free software under the LGPL License. In addition, we request
-that any use of DataJoint leading to a publication be acknowledged in the publication.
-
-Please cite:
- http://biorxiv.org/content/early/2015/11/14/031658
- http://dx.doi.org/10.1101/031658
-"""
-
-__author__ = "DataJoint Contributors"
-__date__ = "November 7, 2020"
-__all__ = ['__author__', '__version__',
- 'config', 'conn', 'Connection',
- 'Schema', 'schema', 'VirtualModule', 'create_virtual_module',
- 'list_schemas', 'Table', 'FreeTable',
- 'Manual', 'Lookup', 'Imported', 'Computed', 'Part',
- 'Not', 'AndList', 'U', 'Diagram', 'Di', 'ERD',
- 'set_password', 'kill',
- 'MatCell', 'MatStruct', 'AttributeAdapter',
- 'errors', 'DataJointError', 'key', 'key_hash']
-
-from .version import __version__
-from .settings import config
-from .connection import conn, Connection
-from .schemas import Schema
-from .schemas import VirtualModule, list_schemas
-from .table import Table, FreeTable
-from .user_tables import Manual, Lookup, Imported, Computed, Part
-from .expression import Not, AndList, U
-from .diagram import Diagram
-from .admin import set_password, kill
-from .blob import MatCell, MatStruct
-from .fetch import key
-from .hash import key_hash
-from .attribute_adapter import AttributeAdapter
-from . import errors
-from .errors import DataJointError
-from .migrate import migrate_dj011_external_blob_storage_to_dj012
-
-ERD = Di = Diagram # Aliases for Diagram
-schema = Schema # Aliases for Schema
-create_virtual_module = VirtualModule # Aliases for VirtualModule
diff --git a/datajoint/admin.py b/datajoint/admin.py
deleted file mode 100644
index db2f61ccc..000000000
--- a/datajoint/admin.py
+++ /dev/null
@@ -1,98 +0,0 @@
-import pymysql
-from getpass import getpass
-from .connection import conn
-from .settings import config
-from .utils import user_choice
-
-
-def set_password(new_password=None, connection=None, update_config=None): # pragma: no cover
- connection = conn() if connection is None else connection
- if new_password is None:
- new_password = getpass('New password: ')
- confirm_password = getpass('Confirm password: ')
- if new_password != confirm_password:
- print('Failed to confirm the password! Aborting password change.')
- return
- connection.query("SET PASSWORD = PASSWORD('%s')" % new_password)
- print('Password updated.')
-
- if update_config or (update_config is None and user_choice('Update local setting?') == 'yes'):
- config['database.password'] = new_password
- config.save_local(verbose=True)
-
-
-def kill(restriction=None, connection=None, order_by=None): # pragma: no cover
- """
- view and kill database connections.
- :param restriction: restriction to be applied to processlist
- :param connection: a datajoint.Connection object. Default calls datajoint.conn()
- :param order_by: order by a single attribute or the list of attributes. defaults to 'id'.
-
- Restrictions are specified as strings and can involve any of the attributes of
- information_schema.processlist: ID, USER, HOST, DB, COMMAND, TIME, STATE, INFO.
-
- Examples:
- dj.kill('HOST LIKE "%compute%"') lists only connections from hosts containing "compute".
- dj.kill('TIME > 600') lists only connections in their current state for more than 10 minutes
- """
-
- if connection is None:
- connection = conn()
-
- if order_by is not None and not isinstance(order_by, str):
- order_by = ','.join(order_by)
-
- query = 'SELECT * FROM information_schema.processlist WHERE id <> CONNECTION_ID()' + (
- "" if restriction is None else ' AND (%s)' % restriction) + (
- ' ORDER BY %s' % (order_by or 'id'))
-
- while True:
- print(' ID USER HOST STATE TIME INFO')
- print('+--+ +----------+ +-----------+ +-----------+ +-----+')
- cur = ({k.lower(): v for k, v in elem.items()}
- for elem in connection.query(query, as_dict=True))
- for process in cur:
- try:
- print('{id:>4d} {user:<12s} {host:<12s} {state:<12s} {time:>7d} {info}'.format(**process))
- except TypeError:
- print(process)
- response = input('process to kill or "q" to quit > ')
- if response == 'q':
- break
- if response:
- try:
- pid = int(response)
- except ValueError:
- pass # ignore non-numeric input
- else:
- try:
- connection.query('kill %d' % pid)
- except pymysql.err.InternalError:
- print('Process not found')
-
-
-def kill_quick(restriction=None, connection=None):
- """
- Kill database connections without prompting. Returns number of terminated connections.
- :param restriction: restriction to be applied to processlist
- :param connection: a datajoint.Connection object. Default calls datajoint.conn()
-
- Restrictions are specified as strings and can involve any of the attributes of
- information_schema.processlist: ID, USER, HOST, DB, COMMAND, TIME, STATE, INFO.
-
- Examples:
- dj.kill('HOST LIKE "%compute%"') terminates connections from hosts containing "compute".
- """
- if connection is None:
- connection = conn()
-
- query = 'SELECT * FROM information_schema.processlist WHERE id <> CONNECTION_ID()' + (
- "" if restriction is None else ' AND (%s)' % restriction)
-
- cur = ({k.lower(): v for k, v in elem.items()}
- for elem in connection.query(query, as_dict=True))
- nkill = 0
- for process in cur:
- connection.query('kill %d' % process['id'])
- nkill += 1
- return nkill
diff --git a/datajoint/attribute_adapter.py b/datajoint/attribute_adapter.py
deleted file mode 100644
index dc1c45706..000000000
--- a/datajoint/attribute_adapter.py
+++ /dev/null
@@ -1,54 +0,0 @@
-import re
-from .errors import DataJointError, _support_adapted_types
-from .plugin import type_plugins
-
-
-class AttributeAdapter:
- """
- Base class for adapter objects for user-defined attribute types.
- """
- @property
- def attribute_type(self):
- """
- :return: a supported DataJoint attribute type to use; e.g. "longblob", "blob@store"
- """
- raise NotImplementedError('Undefined attribute adapter')
-
- def get(self, value):
- """
- convert value retrieved from the the attribute in a table into the adapted type
- :param value: value from the database
- :return: object of the adapted type
- """
- raise NotImplementedError('Undefined attribute adapter')
-
- def put(self, obj):
- """
- convert an object of the adapted type into a value that DataJoint can store in a table attribute
- :param obj: an object of the adapted type
- :return: value to store in the database
- """
- raise NotImplementedError('Undefined attribute adapter')
-
-
-def get_adapter(context, adapter_name):
- """
- Extract the AttributeAdapter object by its name from the context and validate.
- """
- if not _support_adapted_types():
- raise DataJointError('Support for Adapted Attribute types is disabled.')
- adapter_name = adapter_name.lstrip('<').rstrip('>')
- try:
- adapter = (context[adapter_name] if adapter_name in context
- else type_plugins[adapter_name]['object'].load())
- except KeyError:
- raise DataJointError(
- "Attribute adapter '{adapter_name}' is not defined.".format(adapter_name=adapter_name))
- if not isinstance(adapter, AttributeAdapter):
- raise DataJointError(
- "Attribute adapter '{adapter_name}' must be an instance of datajoint.AttributeAdapter".format(
- adapter_name=adapter_name))
- if not isinstance(adapter.attribute_type, str) or not re.match(r'^\w', adapter.attribute_type):
- raise DataJointError("Invalid attribute type {type} in attribute adapter '{adapter_name}'".format(
- type=adapter.attribute_type, adapter_name=adapter_name))
- return adapter
diff --git a/datajoint/autopopulate.py b/datajoint/autopopulate.py
deleted file mode 100644
index b60441599..000000000
--- a/datajoint/autopopulate.py
+++ /dev/null
@@ -1,277 +0,0 @@
-"""This module defines class dj.AutoPopulate"""
-import logging
-import datetime
-import traceback
-import random
-import inspect
-from tqdm import tqdm
-from .expression import QueryExpression, AndList
-from .errors import DataJointError, LostConnectionError
-import signal
-import multiprocessing as mp
-
-# noinspection PyExceptionInherit,PyCallingNonCallable
-
-logger = logging.getLogger(__name__)
-
-
-# --- helper functions for multiprocessing --
-
-def _initialize_populate(table, jobs, populate_kwargs):
- """
- Initialize the process for mulitprocessing.
- Saves the unpickled copy of the table to the current process and reconnects.
- """
- process = mp.current_process()
- process.table = table
- process.jobs = jobs
- process.populate_kwargs = populate_kwargs
- table.connection.connect() # reconnect
-
-
-def _call_populate1(key):
- """
- Call current process' table._populate1()
- :key - a dict specifying job to compute
- :return: key, error if error, otherwise None
- """
- process = mp.current_process()
- return process.table._populate1(key, process.jobs, **process.populate_kwargs)
-
-
-class AutoPopulate:
- """
- AutoPopulate is a mixin class that adds the method populate() to a Relation class.
- Auto-populated relations must inherit from both Relation and AutoPopulate,
- must define the property `key_source`, and must define the callback method `make`.
- """
- _key_source = None
- _allow_insert = False
-
- @property
- def key_source(self):
- """
- :return: the query expression that yields primary key values to be passed,
- sequentially, to the ``make`` method when populate() is called.
- The default value is the join of the parent tables references from the primary key.
- Subclasses may override they key_source to change the scope or the granularity
- of the make calls.
- """
- def _rename_attributes(table, props):
- return (table.proj(
- **{attr: ref for attr, ref in props['attr_map'].items() if attr != ref})
- if props['aliased'] else table.proj())
-
- if self._key_source is None:
- parents = self.target.parents(
- primary=True, as_objects=True, foreign_key_info=True)
- if not parents:
- raise DataJointError('A table must have dependencies '
- 'from its primary key for auto-populate to work')
- self._key_source = _rename_attributes(*parents[0])
- for q in parents[1:]:
- self._key_source *= _rename_attributes(*q)
- return self._key_source
-
- def make(self, key):
- """
- Derived classes must implement method `make` that fetches data from tables
- above them in the dependency hierarchy, restricting by the given key,
- computes secondary attributes, and inserts the new tuples into self.
- """
- raise NotImplementedError(
- 'Subclasses of AutoPopulate must implement the method `make`')
-
- @property
- def target(self):
- """
- :return: table to be populated.
- In the typical case, dj.AutoPopulate is mixed into a dj.Table class by
- inheritance and the target is self.
- """
- return self
-
- def _job_key(self, key):
- """
- :param key: they key returned for the job from the key source
- :return: the dict to use to generate the job reservation hash
- This method allows subclasses to control the job reservation granularity.
- """
- return key
-
- def _jobs_to_do(self, restrictions):
- """
- :return: the relation containing the keys to be computed (derived from self.key_source)
- """
- if self.restriction:
- raise DataJointError('Cannot call populate on a restricted table. '
- 'Instead, pass conditions to populate() as arguments.')
- todo = self.key_source
-
- # key_source is a QueryExpression subclass -- trigger instantiation
- if inspect.isclass(todo) and issubclass(todo, QueryExpression):
- todo = todo()
-
- if not isinstance(todo, QueryExpression):
- raise DataJointError('Invalid key_source value')
-
- try:
- # check if target lacks any attributes from the primary key of key_source
- raise DataJointError(
- 'The populate target lacks attribute %s '
- 'from the primary key of key_source' % next(
- name for name in todo.heading.primary_key
- if name not in self.target.heading))
- except StopIteration:
- pass
- return (todo & AndList(restrictions)).proj()
-
- def populate(self, *restrictions, suppress_errors=False, return_exception_objects=False,
- reserve_jobs=False, order="original", limit=None, max_calls=None,
- display_progress=False, processes=1, make_kwargs=None):
- """
- ``table.populate()`` calls ``table.make(key)`` for every primary key in
- ``self.key_source`` for which there is not already a tuple in table.
-
- :param restrictions: a list of restrictions each restrict
- (table.key_source - target.proj())
- :param suppress_errors: if True, do not terminate execution.
- :param return_exception_objects: return error objects instead of just error messages
- :param reserve_jobs: if True, reserve jobs to populate in asynchronous fashion
- :param order: "original"|"reverse"|"random" - the order of execution
- :param limit: if not None, check at most this many keys
- :param max_calls: if not None, populate at most this many keys
- :param display_progress: if True, report progress_bar
- :param processes: number of processes to use. When set to a large number, then
- uses as many as CPU cores
- :param make_kwargs: Keyword arguments which do not affect the result of computation
- to be passed down to each ``make()`` call. Computation arguments should be
- specified within the pipeline e.g. using a `dj.Lookup` table.
- :type make_kwargs: dict, optional
- """
- if self.connection.in_transaction:
- raise DataJointError('Populate cannot be called during a transaction.')
-
- valid_order = ['original', 'reverse', 'random']
- if order not in valid_order:
- raise DataJointError('The order argument must be one of %s' % str(valid_order))
- jobs = self.connection.schemas[self.target.database].jobs if reserve_jobs else None
-
- # define and set up signal handler for SIGTERM:
- if reserve_jobs:
- def handler(signum, frame):
- logger.info('Populate terminated by SIGTERM')
- raise SystemExit('SIGTERM received')
- old_handler = signal.signal(signal.SIGTERM, handler)
-
- keys = (self._jobs_to_do(restrictions) - self.target).fetch("KEY", limit=limit)
- if order == "reverse":
- keys.reverse()
- elif order == "random":
- random.shuffle(keys)
-
- logger.info('Found %d keys to populate' % len(keys))
-
- keys = keys[:max_calls]
- nkeys = len(keys)
-
- if processes > 1:
- processes = min(processes, nkeys, mp.cpu_count())
-
- error_list = []
- populate_kwargs = dict(
- suppress_errors=suppress_errors,
- return_exception_objects=return_exception_objects,
- make_kwargs=make_kwargs)
-
- if processes == 1:
- for key in tqdm(keys, desc=self.__class__.__name__) if display_progress else keys:
- error = self._populate1(key, jobs, **populate_kwargs)
- if error is not None:
- error_list.append(error)
- else:
- # spawn multiple processes
- self.connection.close() # disconnect parent process from MySQL server
- del self.connection._conn.ctx # SSLContext is not pickleable
- with mp.Pool(processes, _initialize_populate, (self, populate_kwargs)) as pool:
- if display_progress:
- with tqdm(desc="Processes: ", total=nkeys) as pbar:
- for error in pool.imap(_call_populate1, keys, chunksize=1):
- if error is not None:
- error_list.append(error)
- pbar.update()
- else:
- for error in pool.imap(_call_populate1, keys):
- if error is not None:
- error_list.append(error)
- self.connection.connect() # reconnect parent process to MySQL server
-
- # restore original signal handler:
- if reserve_jobs:
- signal.signal(signal.SIGTERM, old_handler)
-
- if suppress_errors:
- return error_list
-
- def _populate1(self, key, jobs, suppress_errors, return_exception_objects, make_kwargs=None):
- """
- populates table for one source key, calling self.make inside a transaction.
- :param jobs: the jobs table or None if not reserve_jobs
- :param key: dict specifying job to populate
- :param suppress_errors: bool if errors should be suppressed and returned
- :param return_exception_objects: if True, errors must be returned as objects
- :return: (key, error) when suppress_errors=True, otherwise None
- """
- make = self._make_tuples if hasattr(self, '_make_tuples') else self.make
-
- if jobs is None or jobs.reserve(self.target.table_name, self._job_key(key)):
- self.connection.start_transaction()
- if key in self.target: # already populated
- self.connection.cancel_transaction()
- if jobs is not None:
- jobs.complete(self.target.table_name, self._job_key(key))
- else:
- logger.info('Populating: ' + str(key))
- self.__class__._allow_insert = True
- try:
- make(dict(key), **(make_kwargs or {}))
- except (KeyboardInterrupt, SystemExit, Exception) as error:
- try:
- self.connection.cancel_transaction()
- except LostConnectionError:
- pass
- error_message = '{exception}{msg}'.format(
- exception=error.__class__.__name__,
- msg=': ' + str(error) if str(error) else '')
- if jobs is not None:
- # show error name and error message (if any)
- jobs.error(
- self.target.table_name, self._job_key(key),
- error_message=error_message, error_stack=traceback.format_exc())
- if not suppress_errors or isinstance(error, SystemExit):
- raise
- else:
- logger.error(error)
- return key, error if return_exception_objects else error_message
- else:
- self.connection.commit_transaction()
- if jobs is not None:
- jobs.complete(self.target.table_name, self._job_key(key))
- finally:
- self.__class__._allow_insert = False
-
- def progress(self, *restrictions, display=True):
- """
- Report the progress of populating the table.
- :return: (remaining, total) -- numbers of tuples to be populated
- """
- todo = self._jobs_to_do(restrictions)
- total = len(todo)
- remaining = len(todo - self.target)
- if display:
- print('%-20s' % self.__class__.__name__,
- 'Completed %d of %d (%2.1f%%) %s' % (
- total - remaining, total, 100 - 100 * remaining / (total+1e-12),
- datetime.datetime.strftime(datetime.datetime.now(),
- '%Y-%m-%d %H:%M:%S')), flush=True)
- return remaining, total
diff --git a/datajoint/blob.py b/datajoint/blob.py
deleted file mode 100644
index def068463..000000000
--- a/datajoint/blob.py
+++ /dev/null
@@ -1,474 +0,0 @@
-"""
-(De)serialization methods for basic datatypes and numpy.ndarrays with provisions for mutual
-compatibility with Matlab-based serialization implemented by mYm.
-"""
-
-import zlib
-from itertools import repeat
-import collections
-from decimal import Decimal
-import datetime
-import uuid
-import numpy as np
-from .errors import DataJointError
-from .settings import config
-
-
-mxClassID = dict((
- # see http://www.mathworks.com/help/techdoc/apiref/mxclassid.html
- ('mxUNKNOWN_CLASS', None),
- ('mxCELL_CLASS', None),
- ('mxSTRUCT_CLASS', None),
- ('mxLOGICAL_CLASS', np.dtype('bool')),
- ('mxCHAR_CLASS', np.dtype('c')),
- ('mxVOID_CLASS', np.dtype('O')),
- ('mxDOUBLE_CLASS', np.dtype('float64')),
- ('mxSINGLE_CLASS', np.dtype('float32')),
- ('mxINT8_CLASS', np.dtype('int8')),
- ('mxUINT8_CLASS', np.dtype('uint8')),
- ('mxINT16_CLASS', np.dtype('int16')),
- ('mxUINT16_CLASS', np.dtype('uint16')),
- ('mxINT32_CLASS', np.dtype('int32')),
- ('mxUINT32_CLASS', np.dtype('uint32')),
- ('mxINT64_CLASS', np.dtype('int64')),
- ('mxUINT64_CLASS', np.dtype('uint64')),
- ('mxFUNCTION_CLASS', None)))
-
-rev_class_id = {dtype: i for i, dtype in enumerate(mxClassID.values())}
-dtype_list = list(mxClassID.values())
-type_names = list(mxClassID)
-
-compression = {
- b'ZL123\0': zlib.decompress
-}
-
-bypass_serialization = False # runtime setting to bypass blob (en|de)code
-
-# runtime setting to read integers as 32-bit to read blobs created by the 32-bit
-# version of the mYm library for MATLAB
-use_32bit_dims = False
-
-
-def len_u64(obj):
- return np.uint64(len(obj)).tobytes()
-
-
-def len_u32(obj):
- return np.uint32(len(obj)).tobytes()
-
-
-class MatCell(np.ndarray):
- """ a numpy ndarray representing a Matlab cell array """
- pass
-
-
-class MatStruct(np.recarray):
- """ numpy.recarray representing a Matlab struct array """
- pass
-
-
-class Blob:
- def __init__(self, squeeze=False):
- self._squeeze = squeeze
- self._blob = None
- self._pos = 0
- self.protocol = None
-
- def set_dj0(self):
- if not config.get('enable_python_native_blobs'):
- raise DataJointError("""v0.12+ python native blobs disabled.
- See also: https://github.com/datajoint/datajoint-python#python-native-blobs""")
-
- self.protocol = b"dj0\0" # when using new blob features
-
- def squeeze(self, array, convert_to_scalar=True):
- """
- Simplify the input array - squeeze out all singleton dimensions.
- If convert_to_scalar, then convert zero-dimensional arrays to scalars
- """
- if not self._squeeze:
- return array
- array = array.squeeze()
- return array.item() if array.ndim == 0 and convert_to_scalar else array
-
- def unpack(self, blob):
- self._blob = blob
- try:
- # decompress
- prefix = next(p for p in compression if self._blob[self._pos:].startswith(p))
- except StopIteration:
- pass # assume uncompressed but could be unrecognized compression
- else:
- self._pos += len(prefix)
- blob_size = self.read_value()
- blob = compression[prefix](self._blob[self._pos:])
- assert len(blob) == blob_size
- self._blob = blob
- self._pos = 0
- blob_format = self.read_zero_terminated_string()
- if blob_format in ('mYm', 'dj0'):
- return self.read_blob(n_bytes=len(self._blob) - self._pos)
-
- def read_blob(self, n_bytes=None):
- start = self._pos
- data_structure_code = chr(self.read_value('uint8'))
- try:
- call = {
- # MATLAB-compatible, inherited from original mYm
- "A": self.read_array, # matlab-compatible numeric arrays and scalars with ndim==0
- "P": self.read_sparse_array, # matlab sparse array -- not supported yet
- "S": self.read_struct, # matlab struct array
- "C": self.read_cell_array, # matlab cell array
- # basic data types
- "\xFF": self.read_none, # None
- "\x01": self.read_tuple, # a Sequence (e.g. tuple)
- "\x02": self.read_list, # a MutableSequence (e.g. list)
- "\x03": self.read_set, # a Set
- "\x04": self.read_dict, # a Mapping (e.g. dict)
- "\x05": self.read_string, # a UTF8-encoded string
- "\x06": self.read_bytes, # a ByteString
- "\x0a": self.read_int, # unbounded scalar int
- "\x0b": self.read_bool, # scalar boolean
- "\x0c": self.read_complex, # scalar 128-bit complex number
- "\x0d": self.read_float, # scalar 64-bit float
- "F": self.read_recarray, # numpy array with fields, including recarrays
- "d": self.read_decimal, # a decimal
- "t": self.read_datetime, # date, time, or datetime
- "u": self.read_uuid, # UUID
- }[data_structure_code]
- except KeyError:
- raise DataJointError('Unknown data structure code "%s". Upgrade datajoint.' % data_structure_code)
- v = call()
- if n_bytes is not None and self._pos - start != n_bytes:
- raise DataJointError('Blob length check failed! Invalid blob')
- return v
-
- def pack_blob(self, obj):
- # original mYm-based serialization from datajoint-matlab
- if isinstance(obj, MatCell):
- return self.pack_cell_array(obj)
- if isinstance(obj, MatStruct):
- return self.pack_struct(obj)
- if isinstance(obj, np.ndarray) and obj.dtype.fields is None:
- return self.pack_array(obj)
-
- # blob types in the expanded dj0 blob format
- self.set_dj0()
- if not isinstance(obj, (np.ndarray, np.number)):
- # python built-in data types
- if isinstance(obj, bool):
- return self.pack_bool(obj)
- if isinstance(obj, int):
- return self.pack_int(obj)
- if isinstance(obj, complex):
- return self.pack_complex(obj)
- if isinstance(obj, float):
- return self.pack_float(obj)
- if isinstance(obj, np.ndarray) and obj.dtype.fields:
- return self.pack_recarray(np.array(obj))
- if isinstance(obj, np.number):
- return self.pack_array(np.array(obj))
- if isinstance(obj, (bool, np.bool_)):
- return self.pack_array(np.array(obj))
- if isinstance(obj, (float, int, complex)):
- return self.pack_array(np.array(obj))
- if isinstance(obj, (datetime.datetime, datetime.date, datetime.time)):
- return self.pack_datetime(obj)
- if isinstance(obj, Decimal):
- return self.pack_decimal(obj)
- if isinstance(obj, uuid.UUID):
- return self.pack_uuid(obj)
- if isinstance(obj, collections.Mapping):
- return self.pack_dict(obj)
- if isinstance(obj, str):
- return self.pack_string(obj)
- if isinstance(obj, collections.ByteString):
- return self.pack_bytes(obj)
- if isinstance(obj, collections.MutableSequence):
- return self.pack_list(obj)
- if isinstance(obj, collections.Sequence):
- return self.pack_tuple(obj)
- if isinstance(obj, collections.Set):
- return self.pack_set(obj)
- if obj is None:
- return self.pack_none()
- raise DataJointError("Packing object of type %s currently not supported!" % type(obj))
-
- def read_array(self):
- n_dims = int(self.read_value())
- shape = self.read_value(count=n_dims)
- n_elem = np.prod(shape, dtype=int)
- dtype_id, is_complex = self.read_value('uint32', 2)
- dtype = dtype_list[dtype_id]
-
- if type_names[dtype_id] == 'mxVOID_CLASS':
- data = np.array(
- list(self.read_blob(self.read_value()) for _ in range(n_elem)), dtype=np.dtype('O'))
- elif type_names[dtype_id] == 'mxCHAR_CLASS':
- # compensate for MATLAB packing of char arrays
- data = self.read_value(dtype, count=2 * n_elem)
- data = data[::2].astype('U1')
- if n_dims == 2 and shape[0] == 1 or n_dims == 1:
- compact = data.squeeze()
- data = compact if compact.shape == () else np.array(''.join(data.squeeze()))
- shape = (1,)
- else:
- data = self.read_value(dtype, count=n_elem)
- if is_complex:
- data = data + 1j * self.read_value(dtype, count=n_elem)
- return self.squeeze(data.reshape(shape, order="F"))
-
- def pack_array(self, array):
- """
- Serialize an np.ndarray into bytes. Scalars are encoded with ndim=0.
- """
- blob = b"A" + np.uint64(array.ndim).tobytes() + np.array(array.shape, dtype=np.uint64).tobytes()
- is_complex = np.iscomplexobj(array)
- if is_complex:
- array, imaginary = np.real(array), np.imag(array)
- type_id = (rev_class_id[array.dtype] if array.dtype.char != 'U'
- else rev_class_id[np.dtype('O')])
- if dtype_list[type_id] is None:
- raise DataJointError("Type %s is ambiguous or unknown" % array.dtype)
-
- blob += np.array([type_id, is_complex], dtype=np.uint32).tobytes()
- if type_names[type_id] == 'mxVOID_CLASS': # array of dtype('O')
- blob += b"".join(len_u64(it) + it for it in (self.pack_blob(e) for e in array.flatten(order="F")))
- self.set_dj0() # not supported by original mym
- elif type_names[type_id] == 'mxCHAR_CLASS': # array of dtype('c')
- blob += array.view(np.uint8).astype(np.uint16).tobytes() # convert to 16-bit chars for MATLAB
- else: # numeric arrays
- if array.ndim == 0: # not supported by original mym
- self.set_dj0()
- blob += array.tobytes(order="F")
- if is_complex:
- blob += imaginary.tobytes(order="F")
- return blob
-
- def read_recarray(self):
- """
- Serialize an np.ndarray with fields, including recarrays
- """
- n_fields = self.read_value('uint32')
- if not n_fields:
- return np.array(None) # empty array
- field_names = [self.read_zero_terminated_string() for _ in range(n_fields)]
- arrays = [self.read_blob() for _ in range(n_fields)]
- rec = np.empty(arrays[0].shape, np.dtype([(f, t.dtype) for f, t in zip(field_names, arrays)]))
- for f, t in zip(field_names, arrays):
- rec[f] = t
- return rec.view(np.recarray)
-
- def pack_recarray(self, array):
- """ Serialize a Matlab struct array """
- return (b"F" + len_u32(array.dtype) + # number of fields
- '\0'.join(array.dtype.names).encode() + b"\0" + # field names
- b"".join(self.pack_recarray(array[f]) if array[f].dtype.fields else self.pack_array(array[f])
- for f in array.dtype.names))
-
- def read_sparse_array(self):
- raise DataJointError('datajoint-python does not yet support sparse arrays. Issue (#590)')
-
- def read_int(self):
- return int.from_bytes(self.read_binary(self.read_value('uint16')), byteorder='little', signed=True)
-
- @staticmethod
- def pack_int(v):
- n_bytes = v.bit_length() // 8 + 1
- assert 0 < n_bytes <= 0xFFFF, 'Integers are limited to 65535 bytes'
- return b"\x0a" + np.uint16(n_bytes).tobytes() + v.to_bytes(n_bytes, byteorder='little', signed=True)
-
- def read_bool(self):
- return bool(self.read_value('bool'))
-
- @staticmethod
- def pack_bool(v):
- return b"\x0b" + np.array(v, dtype='bool').tobytes()
-
- def read_complex(self):
- return complex(self.read_value('complex128'))
-
- @staticmethod
- def pack_complex(v):
- return b"\x0c" + np.array(v, dtype='complex128').tobytes()
-
- def read_float(self):
- return float(self.read_value('float64'))
-
- @staticmethod
- def pack_float(v):
- return b"\x0d" + np.array(v, dtype='float64').tobytes()
-
- def read_decimal(self):
- return Decimal(self.read_string())
-
- @staticmethod
- def pack_decimal(d):
- s = str(d)
- return b"d" + len_u64(s) + s.encode()
-
- def read_string(self):
- return self.read_binary(self.read_value()).decode()
-
- @staticmethod
- def pack_string(s):
- blob = s.encode()
- return b"\5" + len_u64(blob) + blob
-
- def read_bytes(self):
- return self.read_binary(self.read_value())
-
- @staticmethod
- def pack_bytes(s):
- return b"\6" + len_u64(s) + s
-
- def read_none(self):
- pass
-
- @staticmethod
- def pack_none():
- return b"\xFF"
-
- def read_tuple(self):
- return tuple(self.read_blob(self.read_value()) for _ in range(self.read_value()))
-
- def pack_tuple(self, t):
- return b"\1" + len_u64(t) + b"".join(
- len_u64(it) + it for it in (self.pack_blob(i) for i in t))
-
- def read_list(self):
- return list(self.read_blob(self.read_value()) for _ in range(self.read_value()))
-
- def pack_list(self, t):
- return b"\2" + len_u64(t) + b"".join(
- len_u64(it) + it for it in (self.pack_blob(i) for i in t))
-
- def read_set(self):
- return set(self.read_blob(self.read_value()) for _ in range(self.read_value()))
-
- def pack_set(self, t):
- return b"\3" + len_u64(t) + b"".join(
- len_u64(it) + it for it in (self.pack_blob(i) for i in t))
-
- def read_dict(self):
- return dict((self.read_blob(self.read_value()), self.read_blob(self.read_value()))
- for _ in range(self.read_value()))
-
- def pack_dict(self, d):
- return b"\4" + len_u64(d) + b"".join(
- b"".join((len_u64(it) + it) for it in packed)
- for packed in (map(self.pack_blob, pair) for pair in d.items()))
-
- def read_struct(self):
- """deserialize matlab stuct"""
- n_dims = self.read_value()
- shape = self.read_value(count=n_dims)
- n_elem = np.prod(shape, dtype=int)
- n_fields = self.read_value('uint32')
- if not n_fields:
- return np.array(None) # empty array
- field_names = [self.read_zero_terminated_string() for _ in range(n_fields)]
- raw_data = [
- tuple(self.read_blob(n_bytes=int(self.read_value())) for _ in range(n_fields))
- for __ in range(n_elem)]
- data = np.array(raw_data, dtype=list(zip(field_names, repeat(object))))
- return self.squeeze(data.reshape(shape, order="F"), convert_to_scalar=False).view(MatStruct)
-
- def pack_struct(self, array):
- """ Serialize a Matlab struct array """
- return (b"S" + np.array((array.ndim,) + array.shape, dtype=np.uint64).tobytes() + # dimensionality
- len_u32(array.dtype.names) + # number of fields
- "\0".join(array.dtype.names).encode() + b"\0" + # field names
- b"".join(len_u64(it) + it for it in (
- self.pack_blob(e) for rec in array.flatten(order="F") for e in rec))) # values
-
- def read_cell_array(self):
- """ deserialize MATLAB cell array """
- n_dims = self.read_value()
- shape = self.read_value(count=n_dims)
- n_elem = int(np.prod(shape))
- result = [self.read_blob(n_bytes=self.read_value()) for _ in range(n_elem)]
- return (self.squeeze(np.array(result).reshape(shape, order="F"), convert_to_scalar=False)).view(MatCell)
-
- def pack_cell_array(self, array):
- return (b"C" + np.array((array.ndim,) + array.shape, dtype=np.uint64).tobytes() +
- b"".join(len_u64(it) + it for it in (self.pack_blob(e) for e in array.flatten(order="F"))))
-
- def read_datetime(self):
- """ deserialize datetime.date, .time, or .datetime """
- date, time = self.read_value('int32'), self.read_value('int64')
- date = datetime.date(
- year=date // 10000,
- month=(date // 100) % 100,
- day=date % 100) if date >= 0 else None
- time = datetime.time(
- hour=(time // 10000000000) % 100,
- minute=(time // 100000000) % 100,
- second=(time // 1000000) % 100,
- microsecond=time % 1000000) if time >= 0 else None
- return time and date and datetime.datetime.combine(date, time) or time or date
-
- @staticmethod
- def pack_datetime(d):
- if isinstance(d, datetime.datetime):
- date, time = d.date(), d.time()
- elif isinstance(d, datetime.date):
- date, time = d, None
- else:
- date, time = None, d
- return b"t" + (
- np.int32(-1 if date is None else (date.year*100 + date.month)*100 + date.day).tobytes() +
- np.int64(-1 if time is None else
- ((time.hour*100 + time.minute)*100 + time.second)*1000000 + time.microsecond).tobytes())
-
- def read_uuid(self):
- q = self.read_binary(16)
- return uuid.UUID(bytes=q)
-
- @staticmethod
- def pack_uuid(obj):
- return b"u" + obj.bytes
-
- def read_zero_terminated_string(self):
- target = self._blob.find(b'\0', self._pos)
- data = self._blob[self._pos:target].decode()
- self._pos = target + 1
- return data
-
- def read_value(self, dtype=None, count=1):
- if dtype is None:
- dtype = 'uint32' if use_32bit_dims else 'uint64'
- data = np.frombuffer(self._blob, dtype=dtype, count=count, offset=self._pos)
- self._pos += data.dtype.itemsize * data.size
- return data[0] if count == 1 else data
-
- def read_binary(self, size):
- self._pos += int(size)
- return self._blob[self._pos-int(size):self._pos]
-
- def pack(self, obj, compress):
- self.protocol = b"mYm\0" # will be replaced with dj0 if new features are used
- blob = self.pack_blob(obj) # this may reset the protocol and must precede protocol evaluation
- blob = self.protocol + blob
- if compress and len(blob) > 1000:
- compressed = b'ZL123\0' + len_u64(blob) + zlib.compress(blob)
- if len(compressed) < len(blob):
- blob = compressed
- return blob
-
-
-def pack(obj, compress=True):
- if bypass_serialization:
- # provide a way to move blobs quickly without de/serialization
- assert isinstance(obj, bytes) and obj.startswith((b'ZL123\0', b'mYm\0', b'dj0\0'))
- return obj
- return Blob().pack(obj, compress=compress)
-
-
-def unpack(blob, squeeze=False):
- if bypass_serialization:
- # provide a way to move blobs quickly without de/serialization
- assert isinstance(blob, bytes) and blob.startswith((b'ZL123\0', b'mYm\0', b'dj0\0'))
- return blob
- if blob is not None:
- return Blob(squeeze=squeeze).unpack(blob)
diff --git a/datajoint/condition.py b/datajoint/condition.py
deleted file mode 100644
index fed138cf1..000000000
--- a/datajoint/condition.py
+++ /dev/null
@@ -1,225 +0,0 @@
-""" methods for generating SQL WHERE clauses from datajoint restriction conditions """
-
-import inspect
-import collections
-import re
-import uuid
-import datetime
-import decimal
-import numpy
-import pandas
-from .errors import DataJointError
-
-
-class PromiscuousOperand:
- """
- A container for an operand to ignore join compatibility
- """
- def __init__(self, operand):
- self.operand = operand
-
-
-class AndList(list):
- """
- A list of conditions to by applied to a query expression by logical conjunction: the
- conditions are AND-ed. All other collections (lists, sets, other entity sets, etc) are
- applied by logical disjunction (OR).
-
- Example:
- expr2 = expr & dj.AndList((cond1, cond2, cond3))
- is equivalent to
- expr2 = expr & cond1 & cond2 & cond3
- """
- def append(self, restriction):
- if isinstance(restriction, AndList):
- # extend to reduce nesting
- self.extend(restriction)
- else:
- super().append(restriction)
-
-
-class Not:
- """ invert restriction """
- def __init__(self, restriction):
- self.restriction = restriction
-
-
-def assert_join_compatibility(expr1, expr2):
- """
- Determine if expressions expr1 and expr2 are join-compatible. To be join-compatible,
- the matching attributes in the two expressions must be in the primary key of one or the
- other expression.
- Raises an exception if not compatible.
-
- :param expr1: A QueryExpression object
- :param expr2: A QueryExpression object
- """
- from .expression import QueryExpression, U
-
- for rel in (expr1, expr2):
- if not isinstance(rel, (U, QueryExpression)):
- raise DataJointError(
- 'Object %r is not a QueryExpression and cannot be joined.' % rel)
- if not isinstance(expr1, U) and not isinstance(expr2, U): # dj.U is always compatible
- try:
- raise DataJointError(
- "Cannot join query expressions on dependent attribute `%s`" % next(
- r for r in set(expr1.heading.secondary_attributes).intersection(
- expr2.heading.secondary_attributes)))
- except StopIteration:
- pass # all ok
-
-
-def make_condition(query_expression, condition, columns):
- """
- Translate the input condition into the equivalent SQL condition (a string)
-
- :param query_expression: a dj.QueryExpression object to apply condition
- :param condition: any valid restriction object.
- :param columns: a set passed by reference to collect all column names used in the
- condition.
- :return: an SQL condition string or a boolean value.
- """
- from .expression import QueryExpression, Aggregation, U
-
- def prep_value(k, v):
- """prepare value v for inclusion as a string in an SQL condition"""
- if query_expression.heading[k].uuid:
- if not isinstance(v, uuid.UUID):
- try:
- v = uuid.UUID(v)
- except (AttributeError, ValueError):
- raise DataJointError(
- 'Badly formed UUID {v} in restriction by `{k}`'.format(k=k, v=v))
- return "X'%s'" % v.bytes.hex()
- if isinstance(v, (datetime.date, datetime.datetime, datetime.time, decimal.Decimal)):
- return '"%s"' % v
- if isinstance(v, str):
- return '"%s"' % v.replace('%', '%%')
- return '%r' % v
-
- negate = False
- while isinstance(condition, Not):
- negate = not negate
- condition = condition.restriction
- template = "NOT (%s)" if negate else "%s"
-
- # restrict by string
- if isinstance(condition, str):
- columns.update(extract_column_names(condition))
- return template % condition.strip().replace("%", "%%") # escape %, see issue #376
-
- # restrict by AndList
- if isinstance(condition, AndList):
- # omit all conditions that evaluate to True
- items = [item for item in (make_condition(query_expression, cond, columns)
- for cond in condition)
- if item is not True]
- if any(item is False for item in items):
- return negate # if any item is False, the whole thing is False
- if not items:
- return not negate # and empty AndList is True
- return template % ('(' + ') AND ('.join(items) + ')')
-
- # restriction by dj.U evaluates to True
- if isinstance(condition, U):
- return not negate
-
- # restrict by boolean
- if isinstance(condition, bool):
- return negate != condition
-
- # restrict by a mapping/dict -- convert to an AndList of string equality conditions
- if isinstance(condition, collections.abc.Mapping):
- common_attributes = set(condition).intersection(query_expression.heading.names)
- if not common_attributes:
- return not negate # no matching attributes -> evaluates to True
- columns.update(common_attributes)
- return template % ('(' + ') AND ('.join(
- '`%s`%s' % (k, ' IS NULL' if condition[k] is None
- else f'={prep_value(k, condition[k])}')
- for k in common_attributes) + ')')
-
- # restrict by a numpy record -- convert to an AndList of string equality conditions
- if isinstance(condition, numpy.void):
- common_attributes = set(condition.dtype.fields).intersection(
- query_expression.heading.names)
- if not common_attributes:
- return not negate # no matching attributes -> evaluate to True
- columns.update(common_attributes)
- return template % ('(' + ') AND ('.join(
- '`%s`=%s' % (k, prep_value(k, condition[k])) for k in common_attributes) + ')')
-
- # restrict by a QueryExpression subclass -- trigger instantiation and move on
- if inspect.isclass(condition) and issubclass(condition, QueryExpression):
- condition = condition()
-
- # restrict by another expression (aka semijoin and antijoin)
- check_compatibility = True
- if isinstance(condition, PromiscuousOperand):
- condition = condition.operand
- check_compatibility = False
-
- if isinstance(condition, QueryExpression):
- if check_compatibility:
- assert_join_compatibility(query_expression, condition)
- common_attributes = [q for q in condition.heading.names
- if q in query_expression.heading.names]
- columns.update(common_attributes)
- if isinstance(condition, Aggregation):
- condition = condition.make_subquery()
- return (
- # without common attributes, any non-empty set matches everything
- (not negate if condition else negate) if not common_attributes
- else '({fields}) {not_}in ({subquery})'.format(
- fields='`' + '`,`'.join(common_attributes) + '`',
- not_="not " if negate else "",
- subquery=condition.make_sql(common_attributes)))
-
- # restrict by pandas.DataFrames
- if isinstance(condition, pandas.DataFrame):
- condition = condition.to_records() # convert to numpy.recarray and move on
-
- # if iterable (but not a string, a QueryExpression, or an AndList), treat as an OrList
- try:
- or_list = [make_condition(query_expression, q, columns) for q in condition]
- except TypeError:
- raise DataJointError('Invalid restriction type %r' % condition)
- else:
- or_list = [item for item in or_list if item is not False] # ignore False conditions
- if any(item is True for item in or_list): # if any item is True, entirely True
- return not negate
- return template % ('(%s)' % ' OR '.join(or_list)) if or_list else negate
-
-
-def extract_column_names(sql_expression):
- """
- extract all presumed column names from an sql expression such as the WHERE clause,
- for example.
-
- :param sql_expression: a string containing an SQL expression
- :return: set of extracted column names
- This may be MySQL-specific for now.
- """
- assert isinstance(sql_expression, str)
- result = set()
- s = sql_expression # for terseness
- # remove escaped quotes
- s = re.sub(r'(\\\")|(\\\')', '', s)
- # remove quoted text
- s = re.sub(r"'[^']*'", "", s)
- s = re.sub(r'"[^"]*"', '', s)
- # find all tokens in back quotes and remove them
- result.update(re.findall(r"`([a-z][a-z_0-9]*)`", s))
- s = re.sub(r"`[a-z][a-z_0-9]*`", '', s)
- # remove space before parentheses
- s = re.sub(r"\s*\(", "(", s)
- # remove tokens followed by ( since they must be functions
- s = re.sub(r"(\b[a-z][a-z_0-9]*)\(", "(", s)
- remaining_tokens = set(re.findall(r"\b[a-z][a-z_0-9]*\b", s))
- # update result removing reserved words
- result.update(remaining_tokens - {"is", "in", "between", "like", "and", "or", "null",
- "not", "interval", "second", "minute", "hour", "day",
- "month", "week", "year"
- })
- return result
diff --git a/datajoint/connection.py b/datajoint/connection.py
deleted file mode 100644
index 3daac4bac..000000000
--- a/datajoint/connection.py
+++ /dev/null
@@ -1,383 +0,0 @@
-"""
-This module contains the Connection class that manages the connection to the database,
- and the `conn` function that provides access to a persistent connection in datajoint.
-"""
-import warnings
-from contextlib import contextmanager
-import pymysql as client
-import logging
-from getpass import getpass
-import re
-import pathlib
-
-from .settings import config
-from . import errors
-from .dependencies import Dependencies
-from .blob import pack, unpack
-from .hash import uuid_from_buffer
-from .plugin import connection_plugins
-
-logger = logging.getLogger(__name__)
-query_log_max_length = 300
-
-
-def get_host_hook(host_input):
- if '://' in host_input:
- plugin_name = host_input.split('://')[0]
- try:
- return connection_plugins[plugin_name]['object'].load().get_host(host_input)
- except KeyError:
- raise errors.DataJointError(
- "Connection plugin '{}' not found.".format(plugin_name))
- else:
- return host_input
-
-
-def connect_host_hook(connection_obj):
- if '://' in connection_obj.conn_info['host_input']:
- plugin_name = connection_obj.conn_info['host_input'].split('://')[0]
- try:
- connection_plugins[plugin_name]['object'].load().connect_host(connection_obj)
- except KeyError:
- raise errors.DataJointError(
- "Connection plugin '{}' not found.".format(plugin_name))
- else:
- connection_obj.connect()
-
-
-def translate_query_error(client_error, query):
- """
- Take client error and original query and return the corresponding DataJoint exception.
- :param client_error: the exception raised by the client interface
- :param query: sql query with placeholders
- :return: an instance of the corresponding subclass of datajoint.errors.DataJointError
- """
- logger.debug('type: {}, args: {}'.format(type(client_error), client_error.args))
-
- err, *args = client_error.args
-
- # Loss of connection errors
- if err in (0, "(0, '')"):
- return errors.LostConnectionError('Server connection lost due to an interface error.', *args)
- if err == 2006:
- return errors.LostConnectionError("Connection timed out", *args)
- if err == 2013:
- return errors.LostConnectionError("Server connection lost", *args)
- # Access errors
- if err in (1044, 1142):
- return errors.AccessError('Insufficient privileges.', args[0], query)
- # Integrity errors
- if err == 1062:
- return errors.DuplicateError(*args)
- if err == 1451:
- return errors.IntegrityError(*args)
- if err == 1452:
- return errors.IntegrityError(*args)
- # Syntax errors
- if err == 1064:
- return errors.QuerySyntaxError(args[0], query)
- # Existence errors
- if err == 1146:
- return errors.MissingTableError(args[0], query)
- if err == 1364:
- return errors.MissingAttributeError(*args)
- if err == 1054:
- return errors.UnknownAttributeError(*args)
- # all the other errors are re-raised in original form
- return client_error
-
-
-def conn(host=None, user=None, password=None, *, init_fun=None, reset=False, use_tls=None):
- """
- Returns a persistent connection object to be shared by multiple modules.
- If the connection is not yet established or reset=True, a new connection is set up.
- If connection information is not provided, it is taken from config which takes the
- information from dj_local_conf.json. If the password is not specified in that file
- datajoint prompts for the password.
-
- :param host: hostname
- :param user: mysql user
- :param password: mysql password
- :param init_fun: initialization function
- :param reset: whether the connection should be reset or not
- :param use_tls: TLS encryption option. Valid options are: True (required),
- False (required no TLS), None (TLS prefered, default),
- dict (Manually specify values per
- https://dev.mysql.com/doc/refman/5.7/en/connection-options.html
- #encrypted-connection-options).
- """
- if not hasattr(conn, 'connection') or reset:
- host = host if host is not None else config['database.host']
- user = user if user is not None else config['database.user']
- password = password if password is not None else config['database.password']
- if user is None: # pragma: no cover
- user = input("Please enter DataJoint username: ")
- if password is None: # pragma: no cover
- password = getpass(prompt="Please enter DataJoint password: ")
- init_fun = init_fun if init_fun is not None else config['connection.init_function']
- use_tls = use_tls if use_tls is not None else config['database.use_tls']
- conn.connection = Connection(host, user, password, None, init_fun, use_tls)
- return conn.connection
-
-
-class EmulatedCursor:
- """acts like a cursor"""
- def __init__(self, data):
- self._data = data
- self._iter = iter(self._data)
-
- def __iter__(self):
- return self
-
- def __next__(self):
- return next(self._iter)
-
- def fetchall(self):
- return self._data
-
- def fetchone(self):
- return next(self._iter)
-
- @property
- def rowcount(self):
- return len(self._data)
-
-
-class Connection:
- """
- A dj.Connection object manages a connection to a database server.
- It also catalogues modules, schemas, tables, and their dependencies (foreign keys).
-
- Most of the parameters below should be set in the local configuration file.
-
- :param host: host name, may include port number as hostname:port, in which case it overrides the value in port
- :param user: user name
- :param password: password
- :param port: port number
- :param init_fun: connection initialization function (SQL)
- :param use_tls: TLS encryption option
- """
-
- def __init__(self, host, user, password, port=None, init_fun=None, use_tls=None):
- host_input, host = (host, get_host_hook(host))
- if ':' in host:
- # the port in the hostname overrides the port argument
- host, port = host.split(':')
- port = int(port)
- elif port is None:
- port = config['database.port']
- self.conn_info = dict(host=host, port=port, user=user, passwd=password)
- if use_tls is not False:
- self.conn_info['ssl'] = use_tls if isinstance(use_tls, dict) else {'ssl': {}}
- self.conn_info['ssl_input'] = use_tls
- self.conn_info['host_input'] = host_input
- self.init_fun = init_fun
- print("Connecting {user}@{host}:{port}".format(**self.conn_info))
- self._conn = None
- self._query_cache = None
- connect_host_hook(self)
- if self.is_connected:
- logger.info("Connected {user}@{host}:{port}".format(**self.conn_info))
- self.connection_id = self.query('SELECT connection_id()').fetchone()[0]
- else:
- raise errors.LostConnectionError('Connection failed.')
- self._in_transaction = False
- self.schemas = dict()
- self.dependencies = Dependencies(self)
-
- def __eq__(self, other):
- return self.conn_info == other.conn_info
-
- def __repr__(self):
- connected = "connected" if self.is_connected else "disconnected"
- return "DataJoint connection ({connected}) {user}@{host}:{port}".format(
- connected=connected, **self.conn_info)
-
- def connect(self):
- """ Connect to the database server."""
- with warnings.catch_warnings():
- warnings.filterwarnings('ignore', '.*deprecated.*')
- try:
- self._conn = client.connect(
- init_command=self.init_fun,
- sql_mode="NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,"
- "STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION,ONLY_FULL_GROUP_BY",
- charset=config['connection.charset'],
- **{k: v for k, v in self.conn_info.items()
- if k not in ['ssl_input', 'host_input']})
- except client.err.InternalError:
- self._conn = client.connect(
- init_command=self.init_fun,
- sql_mode="NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,"
- "STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION,ONLY_FULL_GROUP_BY",
- charset=config['connection.charset'],
- **{k: v for k, v in self.conn_info.items()
- if not(k in ['ssl_input', 'host_input'] or
- k == 'ssl' and self.conn_info['ssl_input'] is None)})
- self._conn.autocommit(True)
-
- def set_query_cache(self, query_cache=None):
- """
- When query_cache is not None, the connection switches into the query caching mode, which entails:
- 1. Only SELECT queries are allowed.
- 2. The results of queries are cached under the path indicated by dj.config['query_cache']
- 3. query_cache is a string that differentiates different cache states.
- :param query_cache: a string to initialize the hash for query results
- """
- self._query_cache = query_cache
-
- def purge_query_cache(self):
- """ Purges all query cache. """
- if 'query_cache' in config and isinstance(config['query_cache'], str) and \
- pathlib.Path(config['query_cache']).is_dir():
- path_iter = pathlib.Path(config['query_cache']).glob('**/*')
- for path in path_iter:
- path.unlink()
-
- def close(self):
- self._conn.close()
-
- def register(self, schema):
- self.schemas[schema.database] = schema
- self.dependencies.clear()
-
- def ping(self):
- """ Ping the connection or raises an exception if the connection is closed. """
- self._conn.ping(reconnect=False)
-
- @property
- def is_connected(self):
- """ Return true if the object is connected to the database server. """
- try:
- self.ping()
- except:
- return False
- return True
-
- @staticmethod
- def _execute_query(cursor, query, args, suppress_warnings):
- try:
- with warnings.catch_warnings():
- if suppress_warnings:
- # suppress all warnings arising from underlying SQL library
- warnings.simplefilter("ignore")
- cursor.execute(query, args)
- except client.err.Error as err:
- raise translate_query_error(err, query)
-
- def query(self, query, args=(), *, as_dict=False, suppress_warnings=True, reconnect=None):
- """
- Execute the specified query and return the tuple generator (cursor).
- :param query: SQL query
- :param args: additional arguments for the client.cursor
- :param as_dict: If as_dict is set to True, the returned cursor objects returns
- query results as dictionary.
- :param suppress_warnings: If True, suppress all warnings arising from underlying query library
- :param reconnect: when None, get from config, when True, attempt to reconnect if disconnected
- """
- # check cache first:
- use_query_cache = bool(self._query_cache)
- if use_query_cache and not re.match(r"\s*(SELECT|SHOW)", query):
- raise errors.DataJointError("Only SELECT queries are allowed when query caching is on.")
- if use_query_cache:
- if not config['query_cache']:
- raise errors.DataJointError("Provide filepath dj.config['query_cache'] when using query caching.")
- hash_ = uuid_from_buffer((str(self._query_cache) + re.sub(r'`\$\w+`', '', query)).encode() + pack(args))
- cache_path = pathlib.Path(config['query_cache']) / str(hash_)
- try:
- buffer = cache_path.read_bytes()
- except FileNotFoundError:
- pass # proceed to query the database
- else:
- return EmulatedCursor(unpack(buffer))
-
- if reconnect is None:
- reconnect = config['database.reconnect']
- logger.debug("Executing SQL:" + query[:query_log_max_length])
- cursor_class = client.cursors.DictCursor if as_dict else client.cursors.Cursor
- cursor = self._conn.cursor(cursor=cursor_class)
- try:
- self._execute_query(cursor, query, args, suppress_warnings)
- except errors.LostConnectionError:
- if not reconnect:
- raise
- warnings.warn("MySQL server has gone away. Reconnecting to the server.")
- connect_host_hook(self)
- if self._in_transaction:
- self.cancel_transaction()
- raise errors.LostConnectionError("Connection was lost during a transaction.")
- logger.debug("Re-executing")
- cursor = self._conn.cursor(cursor=cursor_class)
- self._execute_query(cursor, query, args, suppress_warnings)
-
- if use_query_cache:
- data = cursor.fetchall()
- cache_path.write_bytes(pack(data))
- return EmulatedCursor(data)
-
- return cursor
-
- def get_user(self):
- """
- :return: the user name and host name provided by the client to the server.
- """
- return self.query('SELECT user()').fetchone()[0]
-
- # ---------- transaction processing
- @property
- def in_transaction(self):
- """
- :return: True if there is an open transaction.
- """
- self._in_transaction = self._in_transaction and self.is_connected
- return self._in_transaction
-
- def start_transaction(self):
- """
- Starts a transaction error.
- """
- if self.in_transaction:
- raise errors.DataJointError("Nested connections are not supported.")
- self.query('START TRANSACTION WITH CONSISTENT SNAPSHOT')
- self._in_transaction = True
- logger.info("Transaction started")
-
- def cancel_transaction(self):
- """
- Cancels the current transaction and rolls back all changes made during the transaction.
- """
- self.query('ROLLBACK')
- self._in_transaction = False
- logger.info("Transaction cancelled. Rolling back ...")
-
- def commit_transaction(self):
- """
- Commit all changes made during the transaction and close it.
-
- """
- self.query('COMMIT')
- self._in_transaction = False
- logger.info("Transaction committed and closed.")
-
- # -------- context manager for transactions
- @property
- @contextmanager
- def transaction(self):
- """
- Context manager for transactions. Opens an transaction and closes it after the with statement.
- If an error is caught during the transaction, the commits are automatically rolled back.
- All errors are raised again.
-
- Example:
- >>> import datajoint as dj
- >>> with dj.conn().transaction as conn:
- >>> # transaction is open here
- """
- try:
- self.start_transaction()
- yield self
- except:
- self.cancel_transaction()
- raise
- else:
- self.commit_transaction()
diff --git a/datajoint/declare.py b/datajoint/declare.py
deleted file mode 100644
index 93e7592de..000000000
--- a/datajoint/declare.py
+++ /dev/null
@@ -1,468 +0,0 @@
-"""
-This module hosts functions to convert DataJoint table definitions into mysql table definitions, and to
-declare the corresponding mysql tables.
-"""
-import re
-import pyparsing as pp
-import logging
-import warnings
-from .errors import DataJointError, _support_filepath_types, FILEPATH_FEATURE_SWITCH
-from .attribute_adapter import get_adapter
-
-UUID_DATA_TYPE = 'binary(16)'
-MAX_TABLE_NAME_LENGTH = 64
-CONSTANT_LITERALS = {'CURRENT_TIMESTAMP', 'NULL'} # SQL literals to be used without quotes (case insensitive)
-EXTERNAL_TABLE_ROOT = '~external'
-
-TYPE_PATTERN = {k: re.compile(v, re.I) for k, v in dict(
- INTEGER=r'((tiny|small|medium|big|)int|integer)(\s*\(.+\))?(\s+unsigned)?(\s+auto_increment)?|serial$',
- DECIMAL=r'(decimal|numeric)(\s*\(.+\))?(\s+unsigned)?$',
- FLOAT=r'(double|float|real)(\s*\(.+\))?(\s+unsigned)?$',
- STRING=r'(var)?char\s*\(.+\)$',
- ENUM=r'enum\s*\(.+\)$',
- BOOL=r'bool(ean)?$', # aliased to tinyint(1)
- TEMPORAL=r'(date|datetime|time|timestamp|year)(\s*\(.+\))?$',
- INTERNAL_BLOB=r'(tiny|small|medium|long|)blob$',
- EXTERNAL_BLOB=r'blob@(?P[a-z]\w*)$',
- INTERNAL_ATTACH=r'attach$',
- EXTERNAL_ATTACH=r'attach@(?P[a-z]\w*)$',
- FILEPATH=r'filepath@(?P[a-z]\w*)$',
- UUID=r'uuid$',
- ADAPTED=r'<.+>$'
-).items()}
-
-# custom types are stored in attribute comment
-SPECIAL_TYPES = {'UUID', 'INTERNAL_ATTACH', 'EXTERNAL_ATTACH', 'EXTERNAL_BLOB', 'FILEPATH', 'ADAPTED'}
-NATIVE_TYPES = set(TYPE_PATTERN) - SPECIAL_TYPES
-EXTERNAL_TYPES = {'EXTERNAL_ATTACH', 'EXTERNAL_BLOB', 'FILEPATH'} # data referenced by a UUID in external tables
-SERIALIZED_TYPES = {'EXTERNAL_ATTACH', 'INTERNAL_ATTACH', 'EXTERNAL_BLOB', 'INTERNAL_BLOB'} # requires packing data
-
-assert set().union(SPECIAL_TYPES, EXTERNAL_TYPES, SERIALIZED_TYPES) <= set(TYPE_PATTERN)
-
-
-def match_type(attribute_type):
- try:
- return next(category for category, pattern in TYPE_PATTERN.items() if pattern.match(attribute_type))
- except StopIteration:
- raise DataJointError("Unsupported attribute type {type}".format(type=attribute_type))
-
-
-logger = logging.getLogger(__name__)
-
-
-def build_foreign_key_parser_old():
- # old-style foreign key parser. Superseded by expression-based syntax. See issue #436
- # This will be deprecated in a future release.
- left = pp.Literal('(').suppress()
- right = pp.Literal(')').suppress()
- attribute_name = pp.Word(pp.srange('[a-z]'), pp.srange('[a-z0-9_]'))
- new_attrs = pp.Optional(left + pp.delimitedList(attribute_name) + right).setResultsName('new_attrs')
- arrow = pp.Literal('->').suppress()
- lbracket = pp.Literal('[').suppress()
- rbracket = pp.Literal(']').suppress()
- option = pp.Word(pp.srange('[a-zA-Z]'))
- options = pp.Optional(lbracket + pp.delimitedList(option) + rbracket).setResultsName('options')
- ref_table = pp.Word(pp.alphas, pp.alphanums + '._').setResultsName('ref_table')
- ref_attrs = pp.Optional(left + pp.delimitedList(attribute_name) + right).setResultsName('ref_attrs')
- return new_attrs + arrow + options + ref_table + ref_attrs
-
-
-def build_foreign_key_parser():
- arrow = pp.Literal('->').suppress()
- lbracket = pp.Literal('[').suppress()
- rbracket = pp.Literal(']').suppress()
- option = pp.Word(pp.srange('[a-zA-Z]'))
- options = pp.Optional(lbracket + pp.delimitedList(option) + rbracket).setResultsName('options')
- ref_table = pp.restOfLine.setResultsName('ref_table')
- return arrow + options + ref_table
-
-
-def build_attribute_parser():
- quoted = pp.QuotedString('"') ^ pp.QuotedString("'")
- colon = pp.Literal(':').suppress()
- attribute_name = pp.Word(pp.srange('[a-z]'), pp.srange('[a-z0-9_]')).setResultsName('name')
- data_type = (pp.Combine(pp.Word(pp.alphas) + pp.SkipTo("#", ignore=quoted))
- ^ pp.QuotedString('<', endQuoteChar='>', unquoteResults=False)).setResultsName('type')
- default = pp.Literal('=').suppress() + pp.SkipTo(colon, ignore=quoted).setResultsName('default')
- comment = pp.Literal('#').suppress() + pp.restOfLine.setResultsName('comment')
- return attribute_name + pp.Optional(default) + colon + data_type + comment
-
-
-def build_index_parser():
- left = pp.Literal('(').suppress()
- right = pp.Literal(')').suppress()
- unique = pp.Optional(pp.CaselessKeyword('unique')).setResultsName('unique')
- index = pp.CaselessKeyword('index').suppress()
- attribute_name = pp.Word(pp.srange('[a-z]'), pp.srange('[a-z0-9_]'))
- return unique + index + left + pp.delimitedList(attribute_name).setResultsName('attr_list') + right
-
-
-foreign_key_parser_old = build_foreign_key_parser_old()
-foreign_key_parser = build_foreign_key_parser()
-attribute_parser = build_attribute_parser()
-index_parser = build_index_parser()
-
-
-def is_foreign_key(line):
- """
- :param line: a line from the table definition
- :return: true if the line appears to be a foreign key definition
- """
- arrow_position = line.find('->')
- return arrow_position >= 0 and not any(c in line[:arrow_position] for c in '"#\'')
-
-
-def compile_foreign_key(line, context, attributes, primary_key, attr_sql, foreign_key_sql, index_sql):
- """
- :param line: a line from a table definition
- :param context: namespace containing referenced objects
- :param attributes: list of attribute names already in the declaration -- to be updated by this function
- :param primary_key: None if the current foreign key is made from the dependent section. Otherwise it is the list
- of primary key attributes thus far -- to be updated by the function
- :param attr_sql: list of sql statements defining attributes -- to be updated by this function.
- :param foreign_key_sql: list of sql statements specifying foreign key constraints -- to be updated by this function.
- :param index_sql: list of INDEX declaration statements, duplicate or redundant indexes are ok.
- """
- # Parse and validate
- from .table import Table
- from .expression import QueryExpression
-
- obsolete = False # See issue #436. Old style to be deprecated in a future release
- try:
- result = foreign_key_parser.parseString(line)
- except pp.ParseException:
- try:
- result = foreign_key_parser_old.parseString(line)
- except pp.ParseBaseException as err:
- raise DataJointError('Parsing error in line "%s". %s.' % (line, err))
- else:
- obsolete = True
- try:
- ref = eval(result.ref_table, context)
- except NameError if obsolete else Exception:
- raise DataJointError('Foreign key reference %s could not be resolved' % result.ref_table)
-
- options = [opt.upper() for opt in result.options]
- for opt in options: # check for invalid options
- if opt not in {'NULLABLE', 'UNIQUE'}:
- raise DataJointError('Invalid foreign key option "{opt}"'.format(opt=opt))
- is_nullable = 'NULLABLE' in options
- is_unique = 'UNIQUE' in options
- if is_nullable and primary_key is not None:
- raise DataJointError('Primary dependencies cannot be nullable in line "{line}"'.format(line=line))
-
- if obsolete:
- warnings.warn(
- 'Line "{line}" uses obsolete syntax that will no longer be supported in datajoint 0.14. '
- 'For details, see issue #780 https://github.com/datajoint/datajoint-python/issues/780'.format(line=line))
- if not isinstance(ref, type) or not issubclass(ref, Table):
- raise DataJointError('Foreign key reference %r must be a valid query' % result.ref_table)
-
- if isinstance(ref, type) and issubclass(ref, Table):
- ref = ref()
-
- # check that dependency is of a supported type
- if (not isinstance(ref, QueryExpression) or len(ref.restriction) or
- len(ref.support) != 1 or not isinstance(ref.support[0], str)):
- raise DataJointError('Dependency "%s" is not supported (yet). Use a base table or its projection.' %
- result.ref_table)
-
- if obsolete:
- # for backward compatibility with old-style dependency declarations. See issue #436
- if not isinstance(ref, Table):
- DataJointError('Dependency "%s" is not supported. Check documentation.' % result.ref_table)
- if not all(r in ref.primary_key for r in result.ref_attrs):
- raise DataJointError('Invalid foreign key attributes in "%s"' % line)
- try:
- raise DataJointError('Duplicate attributes "{attr}" in "{line}"'.format(
- attr=next(attr for attr in result.new_attrs if attr in attributes), line=line))
- except StopIteration:
- pass # the normal outcome
-
- # Match the primary attributes of the referenced table to local attributes
- new_attrs = list(result.new_attrs)
- ref_attrs = list(result.ref_attrs)
-
- # special case, the renamed attribute is implicit
- if new_attrs and not ref_attrs:
- if len(new_attrs) != 1:
- raise DataJointError('Renamed foreign key must be mapped to the primary key in "%s"' % line)
- if len(ref.primary_key) == 1:
- # if the primary key has one attribute, allow implicit renaming
- ref_attrs = ref.primary_key
- else:
- # if only one primary key attribute remains, then allow implicit renaming
- ref_attrs = [attr for attr in ref.primary_key if attr not in attributes]
- if len(ref_attrs) != 1:
- raise DataJointError('Could not resolve which primary key attribute should be referenced in "%s"' % line)
-
- if len(new_attrs) != len(ref_attrs):
- raise DataJointError('Mismatched attributes in foreign key "%s"' % line)
-
- if ref_attrs:
- # convert to projected dependency
- ref = ref.proj(**dict(zip(new_attrs, ref_attrs)))
-
- # declare new foreign key attributes
- for attr in ref.primary_key:
- if attr not in attributes:
- attributes.append(attr)
- if primary_key is not None:
- primary_key.append(attr)
- attr_sql.append(
- ref.heading[attr].sql.replace('NOT NULL ', '', int(is_nullable)))
-
- # declare the foreign key
- foreign_key_sql.append(
- 'FOREIGN KEY (`{fk}`) REFERENCES {ref} (`{pk}`) ON UPDATE CASCADE ON DELETE RESTRICT'.format(
- fk='`,`'.join(ref.primary_key),
- pk='`,`'.join(ref.heading[name].original_name for name in ref.primary_key),
- ref=ref.support[0]))
-
- # declare unique index
- if is_unique:
- index_sql.append('UNIQUE INDEX ({attrs})'.format(attrs=','.join("`%s`" % attr for attr in ref.primary_key)))
-
-
-def prepare_declare(definition, context):
- # split definition into lines
- definition = re.split(r'\s*\n\s*', definition.strip())
- # check for optional table comment
- table_comment = definition.pop(0)[1:].strip() if definition[0].startswith('#') else ''
- if table_comment.startswith(':'):
- raise DataJointError('Table comment must not start with a colon ":"')
- in_key = True # parse primary keys
- primary_key = []
- attributes = []
- attribute_sql = []
- foreign_key_sql = []
- index_sql = []
- external_stores = []
-
- for line in definition:
- if not line or line.startswith('#'): # ignore additional comments
- pass
- elif line.startswith('---') or line.startswith('___'):
- in_key = False # start parsing dependent attributes
- elif is_foreign_key(line):
- compile_foreign_key(line, context, attributes,
- primary_key if in_key else None,
- attribute_sql, foreign_key_sql, index_sql)
- elif re.match(r'^(unique\s+)?index[^:]*$', line, re.I): # index
- compile_index(line, index_sql)
- else:
- name, sql, store = compile_attribute(line, in_key, foreign_key_sql, context)
- if store:
- external_stores.append(store)
- if in_key and name not in primary_key:
- primary_key.append(name)
- if name not in attributes:
- attributes.append(name)
- attribute_sql.append(sql)
-
- return table_comment, primary_key, attribute_sql, foreign_key_sql, index_sql, external_stores
-
-
-def declare(full_table_name, definition, context):
- """
- Parse declaration and generate the SQL CREATE TABLE code
- :param full_table_name: full name of the table
- :param definition: DataJoint table definition
- :param context: dictionary of objects that might be referred to in the table
- :return: SQL CREATE TABLE statement, list of external stores used
- """
- table_name = full_table_name.strip('`').split('.')[1]
- if len(table_name) > MAX_TABLE_NAME_LENGTH:
- raise DataJointError(
- 'Table name `{name}` exceeds the max length of {max_length}'.format(
- name=table_name,
- max_length=MAX_TABLE_NAME_LENGTH))
-
- table_comment, primary_key, attribute_sql, foreign_key_sql, index_sql, external_stores = prepare_declare(
- definition, context)
-
- if not primary_key:
- raise DataJointError('Table must have a primary key')
-
- return (
- 'CREATE TABLE IF NOT EXISTS %s (\n' % full_table_name +
- ',\n'.join(attribute_sql + ['PRIMARY KEY (`' + '`,`'.join(primary_key) + '`)'] + foreign_key_sql + index_sql) +
- '\n) ENGINE=InnoDB, COMMENT "%s"' % table_comment), external_stores
-
-
-def _make_attribute_alter(new, old, primary_key):
- """
- :param new: new attribute declarations
- :param old: old attribute declarations
- :param primary_key: primary key attributes
- :return: list of SQL ALTER commands
- """
- # parse attribute names
- name_regexp = re.compile(r"^`(?P\w+)`")
- original_regexp = re.compile(r'COMMENT "{\s*(?P\w+)\s*}')
- matched = ((name_regexp.match(d), original_regexp.search(d)) for d in new)
- new_names = dict((d.group('name'), n and n.group('name')) for d, n in matched)
- old_names = [name_regexp.search(d).group('name') for d in old]
-
- # verify that original names are only used once
- renamed = set()
- for v in new_names.values():
- if v:
- if v in renamed:
- raise DataJointError('Alter attempted to rename attribute {%s} twice.' % v)
- renamed.add(v)
-
- # verify that all renamed attributes existed in the old definition
- try:
- raise DataJointError(
- "Attribute {} does not exist in the original definition".format(
- next(attr for attr in renamed if attr not in old_names)))
- except StopIteration:
- pass
-
- # dropping attributes
- to_drop = [n for n in old_names if n not in renamed and n not in new_names]
- sql = ['DROP `%s`' % n for n in to_drop]
- old_names = [name for name in old_names if name not in to_drop]
-
- # add or change attributes in order
- prev = None
- for new_def, (new_name, old_name) in zip(new, new_names.items()):
- if new_name not in primary_key:
- after = None # if None, then must include the AFTER clause
- if prev:
- try:
- idx = old_names.index(old_name or new_name)
- except ValueError:
- after = prev[0]
- else:
- if idx >= 1 and old_names[idx - 1] != (prev[1] or prev[0]):
- after = prev[0]
- if new_def not in old or after:
- sql.append('{command} {new_def} {after}'.format(
- command=("ADD" if (old_name or new_name) not in old_names else
- "MODIFY" if not old_name else
- "CHANGE `%s`" % old_name),
- new_def=new_def,
- after="" if after is None else "AFTER `%s`" % after))
- prev = new_name, old_name
-
- return sql
-
-
-def alter(definition, old_definition, context):
- """
- :param definition: new table definition
- :param old_definition: current table definition
- :param context: the context in which to evaluate foreign key definitions
- :return: string SQL ALTER command, list of new stores used for external storage
- """
- table_comment, primary_key, attribute_sql, foreign_key_sql, index_sql, external_stores = prepare_declare(
- definition, context)
- table_comment_, primary_key_, attribute_sql_, foreign_key_sql_, index_sql_, external_stores_ = prepare_declare(
- old_definition, context)
-
- # analyze differences between declarations
- sql = list()
- if primary_key != primary_key_:
- raise NotImplementedError('table.alter cannot alter the primary key (yet).')
- if foreign_key_sql != foreign_key_sql_:
- raise NotImplementedError('table.alter cannot alter foreign keys (yet).')
- if index_sql != index_sql_:
- raise NotImplementedError('table.alter cannot alter indexes (yet)')
- if attribute_sql != attribute_sql_:
- sql.extend(_make_attribute_alter(attribute_sql, attribute_sql_, primary_key))
- if table_comment != table_comment_:
- sql.append('COMMENT="%s"' % table_comment)
- return sql, [e for e in external_stores if e not in external_stores_]
-
-
-def compile_index(line, index_sql):
- match = index_parser.parseString(line)
- index_sql.append('{unique} index ({attrs})'.format(
- unique=match.unique,
- attrs=','.join('`%s`' % a for a in match.attr_list)))
-
-
-def substitute_special_type(match, category, foreign_key_sql, context):
- """
- :param match: dict containing with keys "type" and "comment" -- will be modified in place
- :param category: attribute type category from TYPE_PATTERN
- :param foreign_key_sql: list of foreign key declarations to add to
- :param context: context for looking up user-defined attribute_type adapters
- """
- if category == 'UUID':
- match['type'] = UUID_DATA_TYPE
- elif category == 'INTERNAL_ATTACH':
- match['type'] = 'LONGBLOB'
- elif category in EXTERNAL_TYPES:
- if category == 'FILEPATH' and not _support_filepath_types():
- raise DataJointError("""
- The filepath data type is disabled until complete validation.
- To turn it on as experimental feature, set the environment variable
- {env} = TRUE or upgrade datajoint.
- """.format(env=FILEPATH_FEATURE_SWITCH))
- match['store'] = match['type'].split('@', 1)[1]
- match['type'] = UUID_DATA_TYPE
- foreign_key_sql.append(
- "FOREIGN KEY (`{name}`) REFERENCES `{{database}}`.`{external_table_root}_{store}` (`hash`) "
- "ON UPDATE RESTRICT ON DELETE RESTRICT".format(external_table_root=EXTERNAL_TABLE_ROOT, **match))
- elif category == 'ADAPTED':
- adapter = get_adapter(context, match['type'])
- match['type'] = adapter.attribute_type
- category = match_type(match['type'])
- if category in SPECIAL_TYPES:
- # recursive redefinition from user-defined datatypes.
- substitute_special_type(match, category, foreign_key_sql, context)
- else:
- assert False, 'Unknown special type'
-
-
-def compile_attribute(line, in_key, foreign_key_sql, context):
- """
- Convert attribute definition from DataJoint format to SQL
- :param line: attribution line
- :param in_key: set to True if attribute is in primary key set
- :param foreign_key_sql: the list of foreign key declarations to add to
- :param context: context in which to look up user-defined attribute type adapterss
- :returns: (name, sql, is_external) -- attribute name and sql code for its declaration
- """
- try:
- match = attribute_parser.parseString(line + '#', parseAll=True)
- except pp.ParseException as err:
- raise DataJointError('Declaration error in position {pos} in line:\n {line}\n{msg}'.format(
- line=err.args[0], pos=err.args[1], msg=err.args[2]))
- match['comment'] = match['comment'].rstrip('#')
- if 'default' not in match:
- match['default'] = ''
- match = {k: v.strip() for k, v in match.items()}
- match['nullable'] = match['default'].lower() == 'null'
-
- if match['nullable']:
- if in_key:
- raise DataJointError('Primary key attributes cannot be nullable in line "%s"' % line)
- match['default'] = 'DEFAULT NULL' # nullable attributes default to null
- else:
- if match['default']:
- quote = (match['default'].split('(')[0].upper() not in CONSTANT_LITERALS
- and match['default'][0] not in '"\'')
- match['default'] = 'NOT NULL DEFAULT ' + ('"%s"' if quote else "%s") % match['default']
- else:
- match['default'] = 'NOT NULL'
-
- match['comment'] = match['comment'].replace('"', '\\"') # escape double quotes in comment
-
- if match['comment'].startswith(':'):
- raise DataJointError('An attribute comment must not start with a colon in comment "{comment}"'.format(**match))
-
- category = match_type(match['type'])
- if category in SPECIAL_TYPES:
- match['comment'] = ':{type}:{comment}'.format(**match) # insert custom type into comment
- substitute_special_type(match, category, foreign_key_sql, context)
-
- if category in SERIALIZED_TYPES and match['default'] not in {'DEFAULT NULL', 'NOT NULL'}:
- raise DataJointError(
- 'The default value for a blob or attachment attributes can only be NULL in:\n{line}'.format(line=line))
-
- sql = ('`{name}` {type} {default}' + (' COMMENT "{comment}"' if match['comment'] else '')).format(**match)
- return match['name'], sql, match.get('store')
diff --git a/datajoint/dependencies.py b/datajoint/dependencies.py
deleted file mode 100644
index e5e94225d..000000000
--- a/datajoint/dependencies.py
+++ /dev/null
@@ -1,158 +0,0 @@
-import networkx as nx
-import itertools
-import re
-from collections import defaultdict
-from .errors import DataJointError
-
-
-def unite_master_parts(lst):
- """
- re-order a list of table names so that part tables immediately follow their master tables without breaking
- the topological order.
- Without this correction, a simple topological sort may insert other descendants between master and parts.
- The input list must be topologically sorted.
- :example:
- unite_master_parts(
- ['`s`.`a`', '`s`.`a__q`', '`s`.`b`', '`s`.`c`', '`s`.`c__q`', '`s`.`b__q`', '`s`.`d`', '`s`.`a__r`']) ->
- ['`s`.`a`', '`s`.`a__q`', '`s`.`a__r`', '`s`.`b`', '`s`.`b__q`', '`s`.`c`', '`s`.`c__q`', '`s`.`d`']
- """
- for i in range(2, len(lst)):
- name = lst[i]
- match = re.match(r'(?P`\w+`.`#?\w+)__\w+`', name)
- if match: # name is a part table
- master = match.group('master')
- for j in range(i-1, -1, -1):
- if lst[j] == master + '`' or lst[j].startswith(master + '__'):
- # move from the ith position to the (j+1)th position
- lst[j+1:i+1] = [name] + lst[j+1:i]
- break
- return lst
-
-
-class Dependencies(nx.DiGraph):
- """
- The graph of dependencies (foreign keys) between loaded tables.
-
- Note: the 'connection' argument should normally be supplied;
- Empty use is permitted to facilitate use of networkx algorithms which
- internally create objects with the expectation of empty constructors.
- See also: https://github.com/datajoint/datajoint-python/pull/443
- """
- def __init__(self, connection=None):
- self._conn = connection
- self._node_alias_count = itertools.count()
- self._loaded = False
- super().__init__(self)
-
- def clear(self):
- self._loaded = False
- super().clear()
-
- def load(self, force=True):
- """
- Load dependencies for all loaded schemas.
- This method gets called before any operation that requires dependencies: delete, drop, populate, progress.
- """
- # reload from scratch to prevent duplication of renamed edges
- if self._loaded and not force:
- return
-
- self.clear()
-
- # load primary key info
- keys = self._conn.query("""
- SELECT
- concat('`', table_schema, '`.`', table_name, '`') as tab, column_name
- FROM information_schema.key_column_usage
- WHERE table_name not LIKE "~%%" AND table_schema in ('{schemas}') AND constraint_name="PRIMARY"
- """.format(schemas="','".join(self._conn.schemas)))
- pks = defaultdict(set)
- for key in keys:
- pks[key[0]].add(key[1])
-
- # add nodes to the graph
- for n, pk in pks.items():
- self.add_node(n, primary_key=pk)
-
- # load foreign keys
- keys = ({k.lower(): v for k, v in elem.items()} for elem in self._conn.query("""
- SELECT constraint_name,
- concat('`', table_schema, '`.`', table_name, '`') as referencing_table,
- concat('`', referenced_table_schema, '`.`', referenced_table_name, '`') as referenced_table,
- column_name, referenced_column_name
- FROM information_schema.key_column_usage
- WHERE referenced_table_name NOT LIKE "~%%" AND (referenced_table_schema in ('{schemas}') OR
- referenced_table_schema is not NULL AND table_schema in ('{schemas}'))
- """.format(schemas="','".join(self._conn.schemas)), as_dict=True))
- fks = defaultdict(lambda: dict(attr_map=dict()))
- for key in keys:
- d = fks[(key['constraint_name'], key['referencing_table'], key['referenced_table'])]
- d['referencing_table'] = key['referencing_table']
- d['referenced_table'] = key['referenced_table']
- d['attr_map'][key['column_name']] = key['referenced_column_name']
-
- # add edges to the graph
- for fk in fks.values():
- props = dict(
- primary=set(fk['attr_map']) <= set(pks[fk['referencing_table']]),
- attr_map=fk['attr_map'],
- aliased=any(k != v for k, v in fk['attr_map'].items()),
- multi=set(fk['attr_map']) != set(pks[fk['referencing_table']]))
- if not props['aliased']:
- self.add_edge(fk['referenced_table'], fk['referencing_table'], **props)
- else:
- # for aliased dependencies, add an extra node in the format '1', '2', etc
- alias_node = '%d' % next(self._node_alias_count)
- self.add_node(alias_node)
- self.add_edge(fk['referenced_table'], alias_node, **props)
- self.add_edge(alias_node, fk['referencing_table'], **props)
-
- if not nx.is_directed_acyclic_graph(self): # pragma: no cover
- raise DataJointError('DataJoint can only work with acyclic dependencies')
- self._loaded = True
-
- def parents(self, table_name, primary=None):
- """
- :param table_name: `schema`.`table`
- :param primary: if None, then all parents are returned. If True, then only foreign keys composed of
- primary key attributes are considered. If False, the only foreign keys including at least one non-primary
- attribute are considered.
- :return: dict of tables referenced by the foreign keys of table
- """
- self.load(force=False)
- return {p[0]: p[2] for p in self.in_edges(table_name, data=True)
- if primary is None or p[2]['primary'] == primary}
-
- def children(self, table_name, primary=None):
- """
- :param table_name: `schema`.`table`
- :param primary: if None, then all children are returned. If True, then only foreign keys composed of
- primary key attributes are considered. If False, the only foreign keys including at least one non-primary
- attribute are considered.
- :return: dict of tables referencing the table through foreign keys
- """
- self.load(force=False)
- return {p[1]: p[2] for p in self.out_edges(table_name, data=True)
- if primary is None or p[2]['primary'] == primary}
-
- def descendants(self, full_table_name):
- """
- :param full_table_name: In form `schema`.`table_name`
- :return: all dependent tables sorted in topological order. Self is included.
- """
- self.load(force=False)
- nodes = self.subgraph(
- nx.algorithms.dag.descendants(self, full_table_name))
- return unite_master_parts([full_table_name] + list(
- nx.algorithms.dag.topological_sort(nodes)))
-
- def ancestors(self, full_table_name):
- """
- :param full_table_name: In form `schema`.`table_name`
- :return: all dependent tables sorted in topological order. Self is included.
- """
- self.load(force=False)
- nodes = self.subgraph(
- nx.algorithms.dag.ancestors(self, full_table_name))
- return list(reversed(unite_master_parts(list(
- nx.algorithms.dag.topological_sort(nodes)) + [full_table_name])))
diff --git a/datajoint/diagram.py b/datajoint/diagram.py
deleted file mode 100644
index fa03c123a..000000000
--- a/datajoint/diagram.py
+++ /dev/null
@@ -1,353 +0,0 @@
-import networkx as nx
-import re
-import functools
-import io
-import warnings
-import inspect
-from .table import Table
-from .dependencies import unite_master_parts
-
-try:
- from matplotlib import pyplot as plt
- plot_active = True
-except:
- plot_active = False
-
-try:
- from networkx.drawing.nx_pydot import pydot_layout
- diagram_active = True
-except:
- diagram_active = False
-
-from .user_tables import Manual, Imported, Computed, Lookup, Part
-from .errors import DataJointError
-from .table import lookup_class_name
-
-
-user_table_classes = (Manual, Lookup, Computed, Imported, Part)
-
-
-class _AliasNode:
- """
- special class to indicate aliased foreign keys
- """
- pass
-
-
-def _get_tier(table_name):
- if not table_name.startswith('`'):
- return _AliasNode
- else:
- try:
- return next(tier for tier in user_table_classes
- if re.fullmatch(tier.tier_regexp, table_name.split('`')[-2]))
- except StopIteration:
- return None
-
-
-if not diagram_active:
- class Diagram:
- """
- Entity relationship diagram, currently disabled due to the lack of required packages: matplotlib and pygraphviz.
-
- To enable Diagram feature, please install both matplotlib and pygraphviz. For instructions on how to install
- these two packages, refer to http://docs.datajoint.io/setup/Install-and-connect.html#python and
- http://tutorials.datajoint.io/setting-up/datajoint-python.html
- """
-
- def __init__(self, *args, **kwargs):
- warnings.warn('Please install matplotlib and pygraphviz libraries to enable the Diagram feature.')
-
-else:
- class Diagram(nx.DiGraph):
- """
- Entity relationship diagram.
-
- Usage:
-
- >>> diag = Diagram(source)
-
- source can be a base relation object, a base relation class, a schema, or a module that has a schema.
-
- >>> diag.draw()
-
- draws the diagram using pyplot
-
- diag1 + diag2 - combines the two diagrams.
- diag + n - expands n levels of successors
- diag - n - expands n levels of predecessors
- Thus dj.Diagram(schema.Table)+1-1 defines the diagram of immediate ancestors and descendants of schema.Table
-
- Note that diagram + 1 - 1 may differ from diagram - 1 + 1 and so forth.
- Only those tables that are loaded in the connection object are displayed
- """
- def __init__(self, source, context=None):
-
- if isinstance(source, Diagram):
- # copy constructor
- self.nodes_to_show = set(source.nodes_to_show)
- self.context = source.context
- super().__init__(source)
- return
-
- # get the caller's context
- if context is None:
- frame = inspect.currentframe().f_back
- self.context = dict(frame.f_globals, **frame.f_locals)
- del frame
- else:
- self.context = context
-
- # find connection in the source
- try:
- connection = source.connection
- except AttributeError:
- try:
- connection = source.schema.connection
- except AttributeError:
- raise DataJointError('Could not find database connection in %s' % repr(source[0]))
-
- # initialize graph from dependencies
- connection.dependencies.load()
- super().__init__(connection.dependencies)
-
- # Enumerate nodes from all the items in the list
- self.nodes_to_show = set()
- try:
- self.nodes_to_show.add(source.full_table_name)
- except AttributeError:
- try:
- database = source.database
- except AttributeError:
- try:
- database = source.schema.database
- except AttributeError:
- raise DataJointError('Cannot plot Diagram for %s' % repr(source))
- for node in self:
- if node.startswith('`%s`' % database):
- self.nodes_to_show.add(node)
-
- @classmethod
- def from_sequence(cls, sequence):
- """
- The join Diagram for all objects in sequence
- :param sequence: a sequence (e.g. list, tuple)
- :return: Diagram(arg1) + ... + Diagram(argn)
- """
- return functools.reduce(lambda x, y: x+y, map(Diagram, sequence))
-
- def add_parts(self):
- """
- Adds to the diagram the part tables of tables already included in the diagram
- :return:
- """
- def is_part(part, master):
- """
- :param part: `database`.`table_name`
- :param master: `database`.`table_name`
- :return: True if part is part of master.
- """
- part = [s.strip('`') for s in part.split('.')]
- master = [s.strip('`') for s in master.split('.')]
- return master[0] == part[0] and master[1] + '__' == part[1][:len(master[1])+2]
-
- self = Diagram(self) # copy
- self.nodes_to_show.update(n for n in self.nodes() if any(is_part(n, m) for m in self.nodes_to_show))
- return self
-
- def topological_sort(self):
- """ :return: list of nodes in topological order """
- return unite_master_parts(list(nx.algorithms.dag.topological_sort(
- nx.DiGraph(self).subgraph(self.nodes_to_show))))
-
- def __add__(self, arg):
- """
- :param arg: either another Diagram or a positive integer.
- :return: Union of the diagrams when arg is another Diagram
- or an expansion downstream when arg is a positive integer.
- """
- self = Diagram(self) # copy
- try:
- self.nodes_to_show.update(arg.nodes_to_show)
- except AttributeError:
- try:
- self.nodes_to_show.add(arg.full_table_name)
- except AttributeError:
- for i in range(arg):
- new = nx.algorithms.boundary.node_boundary(self, self.nodes_to_show)
- if not new:
- break
- # add nodes referenced by aliased nodes
- new.update(nx.algorithms.boundary.node_boundary(self, (a for a in new if a.isdigit())))
- self.nodes_to_show.update(new)
- return self
-
- def __sub__(self, arg):
- """
- :param arg: either another Diagram or a positive integer.
- :return: Difference of the diagrams when arg is another Diagram or
- an expansion upstream when arg is a positive integer.
- """
- self = Diagram(self) # copy
- try:
- self.nodes_to_show.difference_update(arg.nodes_to_show)
- except AttributeError:
- try:
- self.nodes_to_show.remove(arg.full_table_name)
- except AttributeError:
- for i in range(arg):
- graph = nx.DiGraph(self).reverse()
- new = nx.algorithms.boundary.node_boundary(graph, self.nodes_to_show)
- if not new:
- break
- # add nodes referenced by aliased nodes
- new.update(nx.algorithms.boundary.node_boundary(graph, (a for a in new if a.isdigit())))
- self.nodes_to_show.update(new)
- return self
-
- def __mul__(self, arg):
- """
- Intersection of two diagrams
- :param arg: another Diagram
- :return: a new Diagram comprising nodes that are present in both operands.
- """
- self = Diagram(self) # copy
- self.nodes_to_show.intersection_update(arg.nodes_to_show)
- return self
-
- def _make_graph(self):
- """
- Make the self.graph - a graph object ready for drawing
- """
- # mark "distinguished" tables, i.e. those that introduce new primary key
- # attributes
- for name in self.nodes_to_show:
- foreign_attributes = set(
- attr for p in self.in_edges(name, data=True)
- for attr in p[2]['attr_map'] if p[2]['primary'])
- self.nodes[name]['distinguished'] = (
- 'primary_key' in self.nodes[name] and
- foreign_attributes < self.nodes[name]['primary_key'])
- # include aliased nodes that are sandwiched between two displayed nodes
- gaps = set(nx.algorithms.boundary.node_boundary(
- self, self.nodes_to_show)).intersection(
- nx.algorithms.boundary.node_boundary(nx.DiGraph(self).reverse(),
- self.nodes_to_show))
- nodes = self.nodes_to_show.union(a for a in gaps if a.isdigit)
- # construct subgraph and rename nodes to class names
- graph = nx.DiGraph(nx.DiGraph(self).subgraph(nodes))
- nx.set_node_attributes(graph, name='node_type', values={n: _get_tier(n)
- for n in graph})
- # relabel nodes to class names
- mapping = {node: lookup_class_name(node, self.context) or node
- for node in graph.nodes()}
- new_names = [mapping.values()]
- if len(new_names) > len(set(new_names)):
- raise DataJointError(
- 'Some classes have identical names. The Diagram cannot be plotted.')
- nx.relabel_nodes(graph, mapping, copy=False)
- return graph
-
- def make_dot(self):
-
- graph = self._make_graph()
- graph.nodes()
-
- scale = 1.2 # scaling factor for fonts and boxes
- label_props = { # http://matplotlib.org/examples/color/named_colors.html
- None: dict(shape='circle', color="#FFFF0040", fontcolor='yellow', fontsize=round(scale*8),
- size=0.4*scale, fixed=False),
- _AliasNode: dict(shape='circle', color="#FF880080", fontcolor='#FF880080', fontsize=round(scale*0),
- size=0.05*scale, fixed=True),
- Manual: dict(shape='box', color="#00FF0030", fontcolor='darkgreen', fontsize=round(scale*10),
- size=0.4*scale, fixed=False),
- Lookup: dict(shape='plaintext', color='#00000020', fontcolor='black', fontsize=round(scale*8),
- size=0.4*scale, fixed=False),
- Computed: dict(shape='ellipse', color='#FF000020', fontcolor='#7F0000A0', fontsize=round(scale*10),
- size=0.3*scale, fixed=True),
- Imported: dict(shape='ellipse', color='#00007F40', fontcolor='#00007FA0', fontsize=round(scale*10),
- size=0.4*scale, fixed=False),
- Part: dict(shape='plaintext', color='#0000000', fontcolor='black', fontsize=round(scale*8),
- size=0.1*scale, fixed=False)}
- node_props = {node: label_props[d['node_type']] for node, d in dict(graph.nodes(data=True)).items()}
-
- dot = nx.drawing.nx_pydot.to_pydot(graph)
- for node in dot.get_nodes():
- node.set_shape('circle')
- name = node.get_name().strip('"')
- props = node_props[name]
- node.set_fontsize(props['fontsize'])
- node.set_fontcolor(props['fontcolor'])
- node.set_shape(props['shape'])
- node.set_fontname('arial')
- node.set_fixedsize('shape' if props['fixed'] else False)
- node.set_width(props['size'])
- node.set_height(props['size'])
- if name.split('.')[0] in self.context:
- cls = eval(name, self.context)
- assert(issubclass(cls, Table))
- description = cls().describe(context=self.context, printout=False).split('\n')
- description = (
- '-'*30 if q.startswith('---') else q.replace('->', '→') if '->' in q else q.split(':')[0]
- for q in description if not q.startswith('#'))
- node.set_tooltip('
'.join(description))
- node.set_label("<"+name+">" if node.get('distinguished') == 'True' else name)
- node.set_color(props['color'])
- node.set_style('filled')
-
- for edge in dot.get_edges():
- # see https://graphviz.org/doc/info/attrs.html
- src = edge.get_source().strip('"')
- dest = edge.get_destination().strip('"')
- props = graph.get_edge_data(src, dest)
- edge.set_color('#00000040')
- edge.set_style('solid' if props['primary'] else 'dashed')
- master_part = graph.nodes[dest]['node_type'] is Part and dest.startswith(src+'.')
- edge.set_weight(3 if master_part else 1)
- edge.set_arrowhead('none')
- edge.set_penwidth(.75 if props['multi'] else 2)
-
- return dot
-
- def make_svg(self):
- from IPython.display import SVG
- return SVG(self.make_dot().create_svg())
-
- def make_png(self):
- return io.BytesIO(self.make_dot().create_png())
-
- def make_image(self):
- if plot_active:
- return plt.imread(self.make_png())
- else:
- raise DataJointError("pyplot was not imported")
-
- def _repr_svg_(self):
- return self.make_svg()._repr_svg_()
-
- def draw(self):
- if plot_active:
- plt.imshow(self.make_image())
- plt.gca().axis('off')
- plt.show()
- else:
- raise DataJointError("pyplot was not imported")
-
- def save(self, filename, format=None):
- if format is None:
- if filename.lower().endswith('.png'):
- format = 'png'
- elif filename.lower().endswith('.svg'):
- format = 'svg'
- if format.lower() == 'png':
- with open(filename, 'wb') as f:
- f.write(self.make_png().getbuffer().tobytes())
- elif format.lower() == 'svg':
- with open(filename, 'w') as f:
- f.write(self.make_svg().data)
- else:
- raise DataJointError('Unsupported file format')
-
- @staticmethod
- def _layout(graph, **kwargs):
- return pydot_layout(graph, prog='dot', **kwargs)
diff --git a/datajoint/errors.py b/datajoint/errors.py
deleted file mode 100644
index fe0ffc539..000000000
--- a/datajoint/errors.py
+++ /dev/null
@@ -1,140 +0,0 @@
-"""
-Exception classes for the DataJoint library
-"""
-
-import os
-
-
-# --- Unverified Plugin Check ---
-class PluginWarning(Exception):
- pass
-
-
-# --- Top Level ---
-class DataJointError(Exception):
- """
- Base class for errors specific to DataJoint internal operation.
- """
- def __init__(self, *args):
- from .plugin import connection_plugins, type_plugins
- self.__cause__ = PluginWarning(
- 'Unverified DataJoint plugin detected.') if any([any(
- [not plugins[k]['verified'] for k in plugins])
- for plugins in [connection_plugins, type_plugins]
- if plugins]) else None
-
- def suggest(self, *args):
- """
- regenerate the exception with additional arguments
- :param args: addition arguments
- :return: a new exception of the same type with the additional arguments
- """
- return self.__class__(*(self.args + args))
-
-
-# --- Second Level ---
-class LostConnectionError(DataJointError):
- """
- Loss of server connection
- """
-
-
-class QueryError(DataJointError):
- """
- Errors arising from queries to the database
- """
-
-
-# --- Third Level: QueryErrors ---
-class QuerySyntaxError(QueryError):
- """
- Errors arising from incorrect query syntax
- """
-
-
-class AccessError(QueryError):
- """
- User access error: insufficient privileges.
- """
-
-
-class MissingTableError(DataJointError):
- """
- Query on a table that has not been declared
- """
-
-
-class DuplicateError(QueryError):
- """
- An integrity error caused by a duplicate entry into a unique key
- """
-
-
-class IntegrityError(QueryError):
- """
- An integrity error triggered by foreign key constraints
- """
-
-
-class UnknownAttributeError(QueryError):
- """
- User requests an attribute name not found in query heading
- """
-
-
-class MissingAttributeError(QueryError):
- """
- An error arising when a required attribute value is not provided in INSERT
- """
-
-
-class MissingExternalFile(DataJointError):
- """
- Error raised when an external file managed by DataJoint is no longer accessible
- """
-
-
-class BucketInaccessible(DataJointError):
- """
- Error raised when a S3 bucket is inaccessible
- """
-
-
-# environment variables to control availability of experimental features
-
-ADAPTED_TYPE_SWITCH = "DJ_SUPPORT_ADAPTED_TYPES"
-FILEPATH_FEATURE_SWITCH = "DJ_SUPPORT_FILEPATH_MANAGEMENT"
-
-
-def _switch_adapted_types(on):
- """
- Enable (on=True) or disable (on=False) support for AttributeAdapter
- """
- if on:
- os.environ[ADAPTED_TYPE_SWITCH] = "TRUE"
- else:
- del os.environ[ADAPTED_TYPE_SWITCH]
-
-
-def _support_adapted_types():
- """
- check if support for AttributeAdapter is enabled
- """
- return os.getenv(ADAPTED_TYPE_SWITCH, "FALSE").upper() == "TRUE"
-
-
-def _switch_filepath_types(on):
- """
- Enable (on=True) or disable (on=False) support for AttributeAdapter
- """
- if on:
- os.environ[FILEPATH_FEATURE_SWITCH] = "TRUE"
- else:
- del os.environ[FILEPATH_FEATURE_SWITCH]
-
-
-def _support_filepath_types():
- """
- check if support for AttributeAdapter is enabled
- """
- return os.getenv(FILEPATH_FEATURE_SWITCH, "FALSE").upper() == "TRUE"
diff --git a/datajoint/expression.py b/datajoint/expression.py
deleted file mode 100644
index 624f1122b..000000000
--- a/datajoint/expression.py
+++ /dev/null
@@ -1,779 +0,0 @@
-from itertools import count
-import logging
-import inspect
-import copy
-import re
-from .settings import config
-from .errors import DataJointError
-from .fetch import Fetch, Fetch1
-from .preview import preview, repr_html
-from .condition import AndList, Not, \
- make_condition, assert_join_compatibility, extract_column_names, PromiscuousOperand
-from .declare import CONSTANT_LITERALS
-
-logger = logging.getLogger(__name__)
-
-
-class QueryExpression:
- """
- QueryExpression implements query operators to derive new entity set from its input.
- A QueryExpression object generates a SELECT statement in SQL.
- QueryExpression operators are restrict, join, proj, aggr, and union.
-
- A QueryExpression object has a support, a restriction (an AndList), and heading.
- Property `heading` (type dj.Heading) contains information about the attributes.
- It is loaded from the database and updated by proj.
-
- Property `support` is the list of table names or other QueryExpressions to be joined.
-
- The restriction is applied first without having access to the attributes generated by the projection.
- Then projection is applied by selecting modifying the heading attribute.
-
- Application of operators does not always lead to the creation of a subquery.
- A subquery is generated when:
- 1. A restriction is applied on any computed or renamed attributes
- 2. A projection is applied remapping remapped attributes
- 3. Subclasses: Join, Aggregation, and Union have additional specific rules.
- """
- _restriction = None
- _restriction_attributes = None
- _left = [] # list of booleans True for left joins, False for inner joins
- _original_heading = None # heading before projections
-
- # subclasses or instantiators must provide values
- _connection = None
- _heading = None
- _support = None
-
- # If the query will be using distinct
- _distinct = False
-
- @property
- def connection(self):
- """ a dj.Connection object """
- assert self._connection is not None
- return self._connection
-
- @property
- def support(self):
- """ A list of table names or subqueries to from the FROM clause """
- assert self._support is not None
- return self._support
-
- @property
- def heading(self):
- """ a dj.Heading object, reflects the effects of the projection operator .proj """
- return self._heading
-
- @property
- def original_heading(self):
- """ a dj.Heading object reflecting the attributes before projection """
- return self._original_heading or self.heading
-
- @property
- def restriction(self):
- """ a AndList object of restrictions applied to input to produce the result """
- if self._restriction is None:
- self._restriction = AndList()
- return self._restriction
-
- @property
- def restriction_attributes(self):
- """ the set of attribute names invoked in the WHERE clause """
- if self._restriction_attributes is None:
- self._restriction_attributes = set()
- return self._restriction_attributes
-
- @property
- def primary_key(self):
- return self.heading.primary_key
-
- _subquery_alias_count = count() # count for alias names used in the FROM clause
-
- def from_clause(self):
- support = ('(' + src.make_sql() + ') as `$%x`' % next(
- self._subquery_alias_count) if isinstance(src, QueryExpression)
- else src for src in self.support)
- clause = next(support)
- for s, left in zip(support, self._left):
- clause += ' NATURAL{left} JOIN {clause}'.format(
- left=" LEFT" if left else "",
- clause=s)
- return clause
-
- def where_clause(self):
- return '' if not self.restriction else ' WHERE (%s)' % ')AND('.join(
- str(s) for s in self.restriction)
-
- def make_sql(self, fields=None):
- """
- Make the SQL SELECT statement.
- :param fields: used to explicitly set the select attributes
- """
- return 'SELECT {distinct}{fields} FROM {from_}{where}'.format(
- distinct="DISTINCT " if self._distinct else "",
- fields=self.heading.as_sql(fields or self.heading.names),
- from_=self.from_clause(), where=self.where_clause())
-
- # --------- query operators -----------
- def make_subquery(self):
- """ create a new SELECT statement where self is the FROM clause """
- result = QueryExpression()
- result._connection = self.connection
- result._support = [self]
- result._heading = self.heading.make_subquery_heading()
- return result
-
- def restrict(self, restriction):
- """
- Produces a new expression with the new restriction applied.
- rel.restrict(restriction) is equivalent to rel & restriction.
- rel.restrict(Not(restriction)) is equivalent to rel - restriction
- The primary key of the result is unaffected.
- Successive restrictions are combined as logical AND: r & a & b is equivalent to r & AndList((a, b))
- Any QueryExpression, collection, or sequence other than an AndList are treated as OrLists
- (logical disjunction of conditions)
- Inverse restriction is accomplished by either using the subtraction operator or the Not class.
-
- The expressions in each row equivalent:
-
- rel & True rel
- rel & False the empty entity set
- rel & 'TRUE' rel
- rel & 'FALSE' the empty entity set
- rel - cond rel & Not(cond)
- rel - 'TRUE' rel & False
- rel - 'FALSE' rel
- rel & AndList((cond1,cond2)) rel & cond1 & cond2
- rel & AndList() rel
- rel & [cond1, cond2] rel & OrList((cond1, cond2))
- rel & [] rel & False
- rel & None rel & False
- rel & any_empty_entity_set rel & False
- rel - AndList((cond1,cond2)) rel & [Not(cond1), Not(cond2)]
- rel - [cond1, cond2] rel & Not(cond1) & Not(cond2)
- rel - AndList() rel & False
- rel - [] rel
- rel - None rel
- rel - any_empty_entity_set rel
-
- When arg is another QueryExpression, the restriction rel & arg restricts rel to elements that match at least
- one element in arg (hence arg is treated as an OrList).
- Conversely, rel - arg restricts rel to elements that do not match any elements in arg.
- Two elements match when their common attributes have equal values or when they have no common attributes.
- All shared attributes must be in the primary key of either rel or arg or both or an error will be raised.
-
- QueryExpression.restrict is the only access point that modifies restrictions. All other operators must
- ultimately call restrict()
-
- :param restriction: a sequence or an array (treated as OR list), another QueryExpression, an SQL condition
- string, or an AndList.
- """
- attributes = set()
- new_condition = make_condition(self, restriction, attributes)
- if new_condition is True:
- return self # restriction has no effect, return the same object
- # check that all attributes in condition are present in the query
- try:
- raise DataJointError("Attribute `%s` is not found in query." % next(
- attr for attr in attributes if attr not in self.heading.names))
- except StopIteration:
- pass # all ok
- # If the new condition uses any new attributes, a subquery is required.
- # However, Aggregation's HAVING statement works fine with aliased attributes.
- need_subquery = isinstance(self, Union) or (
- not isinstance(self, Aggregation) and self.heading.new_attributes)
- if need_subquery:
- result = self.make_subquery()
- else:
- result = copy.copy(self)
- result._restriction = AndList(self.restriction) # copy to preserve the original
- result.restriction.append(new_condition)
- result.restriction_attributes.update(attributes)
- return result
-
- def restrict_in_place(self, restriction):
- self.__dict__.update(self.restrict(restriction).__dict__)
-
- def __and__(self, restriction):
- """
- Restriction operator e.g. ``q1 & q2``.
- :return: a restricted copy of the input argument
- See QueryExpression.restrict for more detail.
- """
- return self.restrict(restriction)
-
- def __xor__(self, restriction):
- """
- Permissive restriction operator ignoring compatibility check e.g. ``q1 ^ q2``.
- """
- if inspect.isclass(restriction) and issubclass(restriction, QueryExpression):
- restriction = restriction()
- if isinstance(restriction, Not):
- return self.restrict(Not(PromiscuousOperand(restriction.restriction)))
- return self.restrict(PromiscuousOperand(restriction))
-
- def __sub__(self, restriction):
- """
- Inverted restriction e.g. ``q1 - q2``.
- :return: a restricted copy of the input argument
- See QueryExpression.restrict for more detail.
- """
- return self.restrict(Not(restriction))
-
- def __neg__(self):
- """
- Convert between restriction and inverted restriction e.g. ``-q1``.
- :return: target restriction
- See QueryExpression.restrict for more detail.
- """
- if isinstance(self, Not):
- return self.restriction
- return Not(self)
-
- def __mul__(self, other):
- """
- join of query expressions `self` and `other` e.g. ``q1 * q2``.
- """
- return self.join(other)
-
- def __matmul__(self, other):
- """
- Permissive join of query expressions `self` and `other` ignoring compatibility check
- e.g. ``q1 @ q2``.
- """
- if inspect.isclass(other) and issubclass(other, QueryExpression):
- other = other() # instantiate
- return self.join(other, semantic_check=False)
-
- def join(self, other, semantic_check=True, left=False):
- """
- create the joined QueryExpression.
- a * b is short for A.join(B)
- a @ b is short for A.join(B, semantic_check=False)
- Additionally, left=True will retain the rows of self, effectively performing a left join.
- """
- # trigger subqueries if joining on renamed attributes
- if isinstance(other, U):
- return other * self
- if inspect.isclass(other) and issubclass(other, QueryExpression):
- other = other() # instantiate
- if not isinstance(other, QueryExpression):
- raise DataJointError("The argument of join must be a QueryExpression")
- if semantic_check:
- assert_join_compatibility(self, other)
- join_attributes = set(n for n in self.heading.names if n in other.heading.names)
- # needs subquery if self's FROM clause has common attributes with other's FROM clause
- need_subquery1 = need_subquery2 = bool(
- (set(self.original_heading.names) & set(other.original_heading.names))
- - join_attributes)
- # need subquery if any of the join attributes are derived
- need_subquery1 = (need_subquery1 or isinstance(self, Aggregation) or
- any(n in self.heading.new_attributes for n in join_attributes)
- or isinstance(self, Union))
- need_subquery2 = (need_subquery2 or isinstance(other, Aggregation) or
- any(n in other.heading.new_attributes for n in join_attributes)
- or isinstance(self, Union))
- if need_subquery1:
- self = self.make_subquery()
- if need_subquery2:
- other = other.make_subquery()
- result = QueryExpression()
- result._connection = self.connection
- result._support = self.support + other.support
- result._left = self._left + [left] + other._left
- result._heading = self.heading.join(other.heading)
- result._restriction = AndList(self.restriction)
- result._restriction.append(other.restriction)
- result._original_heading = self.original_heading.join(other.original_heading)
- assert len(result.support) == len(result._left) + 1
- return result
-
- def __add__(self, other):
- """union e.g. ``q1 + q2``."""
- return Union.create(self, other)
-
- def proj(self, *attributes, **named_attributes):
- """
- Projection operator.
- :param attributes: attributes to be included in the result. (The primary key is already included).
- :param named_attributes: new attributes computed or renamed from existing attributes.
- :return: the projected expression.
- Primary key attributes cannot be excluded but may be renamed.
- If the attribute list contains an Ellipsis ..., then all secondary attributes are included too
- Prefixing an attribute name with a dash '-attr' removes the attribute from the list if present.
- Keyword arguments can be used to rename attributes as in name='attr', duplicate them as in name='(attr)', or
- self.proj(...) or self.proj(Ellipsis) -- include all attributes (return self)
- self.proj() -- include only primary key
- self.proj('attr1', 'attr2') -- include primary key and attributes attr1 and attr2
- self.proj(..., '-attr1', '-attr2') -- include all attributes except attr1 and attr2
- self.proj(name1='attr1') -- include primary key and 'attr1' renamed as name1
- self.proj('attr1', dup='(attr1)') -- include primary key and attribute attr1 twice, with the duplicate 'dup'
- self.proj(k='abs(attr1)') adds the new attribute k with the value computed as an expression (SQL syntax)
- from other attributes available before the projection.
- Each attribute name can only be used once.
- """
- # new attributes in parentheses are included again with the new name without removing original
- duplication_pattern = re.compile(fr'^\s*\(\s*(?!{"|".join(CONSTANT_LITERALS)})(?P[a-zA-Z_]\w*)\s*\)\s*$')
- # attributes without parentheses renamed
- rename_pattern = re.compile(fr'^\s*(?!{"|".join(CONSTANT_LITERALS)})(?P[a-zA-Z_]\w*)\s*$')
- replicate_map = {k: m.group('name')
- for k, m in ((k, duplication_pattern.match(v)) for k, v in named_attributes.items()) if m}
- rename_map = {k: m.group('name')
- for k, m in ((k, rename_pattern.match(v)) for k, v in named_attributes.items()) if m}
- compute_map = {k: v for k, v in named_attributes.items()
- if not duplication_pattern.match(v) and not rename_pattern.match(v)}
- attributes = set(attributes)
- # include primary key
- attributes.update((k for k in self.primary_key if k not in rename_map.values()))
- # include all secondary attributes with Ellipsis
- if Ellipsis in attributes:
- attributes.discard(Ellipsis)
- attributes.update((a for a in self.heading.secondary_attributes
- if a not in attributes and a not in rename_map.values()))
- try:
- raise DataJointError("%s is not a valid data type for an attribute in .proj" % next(
- a for a in attributes if not isinstance(a, str)))
- except StopIteration:
- pass # normal case
- # remove excluded attributes, specified as `-attr'
- excluded = set(a for a in attributes if a.strip().startswith('-'))
- attributes.difference_update(excluded)
- excluded = set(a.lstrip('-').strip() for a in excluded)
- attributes.difference_update(excluded)
- try:
- raise DataJointError("Cannot exclude primary key attribute %s", next(
- a for a in excluded if a in self.primary_key))
- except StopIteration:
- pass # all ok
- # check that all attributes exist in heading
- try:
- raise DataJointError(
- 'Attribute `%s` not found.' % next(a for a in attributes if a not in self.heading.names))
- except StopIteration:
- pass # all ok
-
- # check that all mentioned names are present in heading
- mentions = attributes.union(replicate_map.values()).union(rename_map.values())
- try:
- raise DataJointError("Attribute '%s' not found." % next(a for a in mentions if not self.heading.names))
- except StopIteration:
- pass # all ok
-
- # check that newly created attributes do not clash with any other selected attributes
- try:
- raise DataJointError("Attribute `%s` already exists" % next(
- a for a in rename_map if a in attributes.union(compute_map).union(replicate_map)))
- except StopIteration:
- pass # all ok
- try:
- raise DataJointError("Attribute `%s` already exists" % next(
- a for a in compute_map if a in attributes.union(rename_map).union(replicate_map)))
- except StopIteration:
- pass # all ok
- try:
- raise DataJointError("Attribute `%s` already exists" % next(
- a for a in replicate_map if a in attributes.union(rename_map).union(compute_map)))
- except StopIteration:
- pass # all ok
-
- # need a subquery if the projection remaps any remapped attributes
- used = set(q for v in compute_map.values() for q in extract_column_names(v))
- used.update(rename_map.values())
- used.update(replicate_map.values())
- used.intersection_update(self.heading.names)
- need_subquery = isinstance(self, Union) or any(
- self.heading[name].attribute_expression is not None for name in used)
- if not need_subquery and self.restriction:
- # need a subquery if the restriction applies to attributes that have been renamed
- need_subquery = any(name in self.restriction_attributes for name in self.heading.new_attributes)
-
- result = self.make_subquery() if need_subquery else copy.copy(self)
- result._original_heading = result.original_heading
- result._heading = result.heading.select(
- attributes, rename_map=dict(**rename_map, **replicate_map), compute_map=compute_map)
- return result
-
- def aggr(self, group, *attributes, keep_all_rows=False, **named_attributes):
- """
- Aggregation of the type U('attr1','attr2').aggr(group, computation="QueryExpression")
- has the primary key ('attr1','attr2') and performs aggregation computations for all matching elements of `group`.
- :param group: The query expression to be aggregated.
- :param keep_all_rows: True=keep all the rows from self. False=keep only rows that match entries in group.
- :param named_attributes: computations of the form new_attribute="sql expression on attributes of group"
- :return: The derived query expression
- """
- if Ellipsis in attributes:
- # expand ellipsis to include only attributes from the left table
- attributes = set(attributes)
- attributes.discard(Ellipsis)
- attributes.update(self.heading.secondary_attributes)
- return Aggregation.create(
- self, group=group, keep_all_rows=keep_all_rows).proj(*attributes, **named_attributes)
-
- aggregate = aggr # alias for aggr
-
- # ---------- Fetch operators --------------------
- @property
- def fetch1(self):
- return Fetch1(self)
-
- @property
- def fetch(self):
- return Fetch(self)
-
- def head(self, limit=25, **fetch_kwargs):
- """
- shortcut to fetch the first few entries from query expression.
- Equivalent to fetch(order_by="KEY", limit=25)
- :param limit: number of entries
- :param fetch_kwargs: kwargs for fetch
- :return: query result
- """
- return self.fetch(order_by="KEY", limit=limit, **fetch_kwargs)
-
- def tail(self, limit=25, **fetch_kwargs):
- """
- shortcut to fetch the last few entries from query expression.
- Equivalent to fetch(order_by="KEY DESC", limit=25)[::-1]
- :param limit: number of entries
- :param fetch_kwargs: kwargs for fetch
- :return: query result
- """
- return self.fetch(order_by="KEY DESC", limit=limit, **fetch_kwargs)[::-1]
-
- def __len__(self):
- """:return: number of elements in the result set e.g. ``len(q1)``."""
- return self.connection.query(
- 'SELECT {select_} FROM {from_}{where}'.format(
- select_=('count(*)' if any(self._left)
- else 'count(DISTINCT {fields})'.format(fields=self.heading.as_sql(
- self.primary_key, include_aliases=False))),
- from_=self.from_clause(),
- where=self.where_clause())).fetchone()[0]
-
- def __bool__(self):
- """
- :return: True if the result is not empty. Equivalent to len(self) > 0 but often
- faster e.g. ``bool(q1)``.
- """
- return bool(self.connection.query(
- 'SELECT EXISTS(SELECT 1 FROM {from_}{where})'.format(
- from_=self.from_clause(),
- where=self.where_clause())).fetchone()[0])
-
- def __contains__(self, item):
- """
- returns True if the restriction in item matches any entries in self
- e.g. ``restriction in q1``.
- :param item: any restriction
- (item in query_expression) is equivalent to bool(query_expression & item) but may be
- executed more efficiently.
- """
- return bool(self & item) # May be optimized e.g. using an EXISTS query
-
- def __iter__(self):
- """
- returns an iterator-compatible QueryExpression object e.g. ``iter(q1)``.
-
- :param self: iterator-compatible QueryExpression object
- """
- self._iter_only_key = all(v.in_key for v in self.heading.attributes.values())
- self._iter_keys = self.fetch('KEY')
- return self
-
- def __next__(self):
- """
- returns the next record on an iterator-compatible QueryExpression object
- e.g. ``next(q1)``.
-
- :param self: A query expression
- :type self: :class:`QueryExpression`
- :rtype: dict
- """
- try:
- key = self._iter_keys.pop(0)
- except AttributeError:
- # self._iter_keys is missing because __iter__ has not been called.
- raise TypeError("A QueryExpression object is not an iterator. "
- "Use iter(obj) to create an iterator.")
- except IndexError:
- raise StopIteration
- else:
- if self._iter_only_key:
- return key
- else:
- try:
- return (self & key).fetch1()
- except DataJointError:
- # The data may have been deleted since the moment the keys were fetched
- # -- move on to next entry.
- return next(self)
-
- def cursor(self, offset=0, limit=None, order_by=None, as_dict=False):
- """
- See expression.fetch() for input description.
- :return: query cursor
- """
- if offset and limit is None:
- raise DataJointError('limit is required when offset is set')
- sql = self.make_sql()
- if order_by is not None:
- sql += ' ORDER BY ' + ', '.join(order_by)
- if limit is not None:
- sql += ' LIMIT %d' % limit + (' OFFSET %d' % offset if offset else "")
- logger.debug(sql)
- return self.connection.query(sql, as_dict=as_dict)
-
- def __repr__(self):
- """
- returns the string representation of a QueryExpression object e.g. ``str(q1)``.
-
- :param self: A query expression
- :type self: :class:`QueryExpression`
- :rtype: str
- """
- return super().__repr__() if config['loglevel'].lower() == 'debug' else self.preview()
-
- def preview(self, limit=None, width=None):
- """ :return: a string of preview of the contents of the query. """
- return preview(self, limit, width)
-
- def _repr_html_(self):
- """ :return: HTML to display table in Jupyter notebook. """
- return repr_html(self)
-
-
-class Aggregation(QueryExpression):
- """
- Aggregation.create(arg, group, comp1='calc1', ..., compn='calcn') yields an entity set
- with primary key from arg.
- The computed arguments comp1, ..., compn use aggregation calculations on the attributes of
- group or simple projections and calculations on the attributes of arg.
- Aggregation is used QueryExpression.aggr and U.aggr.
- Aggregation is a private class in DataJoint, not exposed to users.
- """
- _left_restrict = None # the pre-GROUP BY conditions for the WHERE clause
- _subquery_alias_count = count()
-
- @classmethod
- def create(cls, arg, group, keep_all_rows=False):
- if inspect.isclass(group) and issubclass(group, QueryExpression):
- group = group() # instantiate if a class
- assert isinstance(group, QueryExpression)
- if keep_all_rows and len(group.support) > 1 or group.heading.new_attributes:
- group = group.make_subquery() # subquery if left joining a join
- join = arg.join(group, left=keep_all_rows) # reuse the join logic
- result = cls()
- result._connection = join.connection
- result._heading = join.heading.set_primary_key(arg.primary_key) # use left operand's primary key
- result._support = join.support
- result._left = join._left
- result._left_restrict = join.restriction # WHERE clause applied before GROUP BY
- result._grouping_attributes = result.primary_key
-
- return result
-
- def where_clause(self):
- return '' if not self._left_restrict else ' WHERE (%s)' % ')AND('.join(
- str(s) for s in self._left_restrict)
-
- def make_sql(self, fields=None):
- fields = self.heading.as_sql(fields or self.heading.names)
- assert self._grouping_attributes or not self.restriction
- distinct = set(self.heading.names) == set(self.primary_key)
- return 'SELECT {distinct}{fields} FROM {from_}{where}{group_by}'.format(
- distinct="DISTINCT " if distinct else "",
- fields=fields,
- from_=self.from_clause(),
- where=self.where_clause(),
- group_by="" if not self.primary_key else (
- " GROUP BY `%s`" % '`,`'.join(self._grouping_attributes) +
- ("" if not self.restriction else ' HAVING (%s)' % ')AND('.join(self.restriction))))
-
- def __len__(self):
- return self.connection.query(
- 'SELECT count(1) FROM ({subquery}) `${alias:x}`'.format(
- subquery=self.make_sql(),
- alias=next(self._subquery_alias_count))).fetchone()[0]
-
- def __bool__(self):
- return bool(self.connection.query(
- 'SELECT EXISTS({sql})'.format(sql=self.make_sql())))
-
-
-class Union(QueryExpression):
- """
- Union is the private DataJoint class that implements the union operator.
- """
- __count = count()
-
- @classmethod
- def create(cls, arg1, arg2):
- if inspect.isclass(arg2) and issubclass(arg2, QueryExpression):
- arg2 = arg2() # instantiate if a class
- if not isinstance(arg2, QueryExpression):
- raise DataJointError(
- "A QueryExpression can only be unioned with another QueryExpression")
- if arg1.connection != arg2.connection:
- raise DataJointError(
- "Cannot operate on QueryExpressions originating from different connections.")
- if set(arg1.primary_key) != set(arg2.primary_key):
- raise DataJointError("The operands of a union must share the same primary key.")
- if set(arg1.heading.secondary_attributes) & set(arg2.heading.secondary_attributes):
- raise DataJointError(
- "The operands of a union must not share any secondary attributes.")
- result = cls()
- result._connection = arg1.connection
- result._heading = arg1.heading.join(arg2.heading)
- result._support = [arg1, arg2]
- return result
-
- def make_sql(self):
- arg1, arg2 = self._support
- if not arg1.heading.secondary_attributes and not arg2.heading.secondary_attributes:
- # no secondary attributes: use UNION DISTINCT
- fields = arg1.primary_key
- return ("SELECT * FROM (({sql1}) UNION ({sql2})) as `_u{alias}`".format(
- sql1=arg1.make_sql() if isinstance(arg1, Union) else arg1.make_sql(fields),
- sql2=arg2.make_sql() if isinstance(arg2, Union) else arg2.make_sql(fields),
- alias=next(self.__count)
- ))
- # with secondary attributes, use union of left join with antijoin
- fields = self.heading.names
- sql1 = arg1.join(arg2, left=True).make_sql(fields)
- sql2 = (arg2 - arg1).proj(
- ..., **{k: 'NULL' for k in arg1.heading.secondary_attributes}).make_sql(fields)
- return "({sql1}) UNION ({sql2})".format(sql1=sql1, sql2=sql2)
-
- def from_clause(self):
- """ The union does not use a FROM clause """
- assert False
-
- def where_clause(self):
- """ The union does not use a WHERE clause """
- assert False
-
- def __len__(self):
- return self.connection.query(
- 'SELECT count(1) FROM ({subquery}) `${alias:x}`'.format(
- subquery=self.make_sql(),
- alias=next(QueryExpression._subquery_alias_count))).fetchone()[0]
-
- def __bool__(self):
- return bool(self.connection.query(
- 'SELECT EXISTS({sql})'.format(sql=self.make_sql())))
-
-
-class U:
- """
- dj.U objects are the universal sets representing all possible values of their attributes.
- dj.U objects cannot be queried on their own but are useful for forming some queries.
- dj.U('attr1', ..., 'attrn') represents the universal set with the primary key attributes attr1 ... attrn.
- The universal set is the set of all possible combinations of values of the attributes.
- Without any attributes, dj.U() represents the set with one element that has no attributes.
-
- Restriction:
-
- dj.U can be used to enumerate unique combinations of values of attributes from other expressions.
-
- The following expression yields all unique combinations of contrast and brightness found in the `stimulus` set:
-
- >>> dj.U('contrast', 'brightness') & stimulus
-
- Aggregation:
-
- In aggregation, dj.U is used for summary calculation over an entire set:
-
- The following expression yields one element with one attribute `s` containing the total number of elements in
- query expression `expr`:
-
- >>> dj.U().aggr(expr, n='count(*)')
-
- The following expressions both yield one element containing the number `n` of distinct values of attribute `attr` in
- query expressio `expr`.
-
- >>> dj.U().aggr(expr, n='count(distinct attr)')
- >>> dj.U().aggr(dj.U('attr').aggr(expr), 'n=count(*)')
-
- The following expression yields one element and one attribute `s` containing the sum of values of attribute `attr`
- over entire result set of expression `expr`:
-
- >>> dj.U().aggr(expr, s='sum(attr)')
-
- The following expression yields the set of all unique combinations of attributes `attr1`, `attr2` and the number of
- their occurrences in the result set of query expression `expr`.
-
- >>> dj.U(attr1,attr2).aggr(expr, n='count(*)')
-
- Joins:
-
- If expression `expr` has attributes 'attr1' and 'attr2', then expr * dj.U('attr1','attr2') yields the same result
- as `expr` but `attr1` and `attr2` are promoted to the the primary key. This is useful for producing a join on
- non-primary key attributes.
- For example, if `attr` is in both expr1 and expr2 but not in their primary keys, then expr1 * expr2 will throw
- an error because in most cases, it does not make sense to join on non-primary key attributes and users must first
- rename `attr` in one of the operands. The expression dj.U('attr') * rel1 * rel2 overrides this constraint.
- """
-
- def __init__(self, *primary_key):
- self._primary_key = primary_key
-
- @property
- def primary_key(self):
- return self._primary_key
-
- def __and__(self, other):
- if inspect.isclass(other) and issubclass(other, QueryExpression):
- other = other() # instantiate if a class
- if not isinstance(other, QueryExpression):
- raise DataJointError('Set U can only be restricted with a QueryExpression.')
- result = copy.copy(other)
- result._distinct = True
- result._heading = result.heading.set_primary_key(self.primary_key)
- result = result.proj()
- return result
-
- def join(self, other, left=False):
- """
- Joining U with a query expression has the effect of promoting the attributes of U to
- the primary key of the other query expression.
-
- :param other: the other query expression to join with.
- :param left: ignored. dj.U always acts as if left=False
- :return: a copy of the other query expression with the primary key extended.
- """
- if inspect.isclass(other) and issubclass(other, QueryExpression):
- other = other() # instantiate if a class
- if not isinstance(other, QueryExpression):
- raise DataJointError('Set U can only be joined with a QueryExpression.')
- try:
- raise DataJointError(
- 'Attribute `%s` not found' % next(k for k in self.primary_key
- if k not in other.heading.names))
- except StopIteration:
- pass # all ok
- result = copy.copy(other)
- result._heading = result.heading.set_primary_key(
- other.primary_key + [k for k in self.primary_key
- if k not in other.primary_key])
- return result
-
- def __mul__(self, other):
- """ shorthand for join """
- return self.join(other)
-
- def aggr(self, group, **named_attributes):
- """
- Aggregation of the type U('attr1','attr2').aggr(group, computation="QueryExpression")
- has the primary key ('attr1','attr2') and performs aggregation computations for all matching elements of `group`.
- :param group: The query expression to be aggregated.
- :param named_attributes: computations of the form new_attribute="sql expression on attributes of group"
- :return: The derived query expression
- """
- if named_attributes.get('keep_all_rows', False):
- raise DataJointError(
- 'Cannot set keep_all_rows=True when aggregating on a universal set.')
- return Aggregation.create(self, group=group, keep_all_rows=False).proj(**named_attributes)
-
- aggregate = aggr # alias for aggr
diff --git a/datajoint/external.py b/datajoint/external.py
deleted file mode 100644
index 62120e306..000000000
--- a/datajoint/external.py
+++ /dev/null
@@ -1,393 +0,0 @@
-from pathlib import Path, PurePosixPath, PureWindowsPath
-from collections.abc import Mapping
-from tqdm import tqdm
-from .settings import config
-from .errors import DataJointError, MissingExternalFile
-from .hash import uuid_from_buffer, uuid_from_file
-from .table import Table, FreeTable
-from .heading import Heading
-from .declare import EXTERNAL_TABLE_ROOT
-from . import s3
-from .utils import safe_write, safe_copy
-
-CACHE_SUBFOLDING = (2, 2) # (2, 2) means "0123456789abcd" will be saved as "01/23/0123456789abcd"
-SUPPORT_MIGRATED_BLOBS = True # support blobs migrated from datajoint 0.11.*
-
-
-def subfold(name, folds):
- """
- subfolding for external storage: e.g. subfold('aBCdefg', (2, 3)) --> ['ab','cde']
- """
- return (name[:folds[0]].lower(),) + subfold(name[folds[0]:], folds[1:]) if folds else ()
-
-
-class ExternalTable(Table):
- """
- The table tracking externally stored objects.
- Declare as ExternalTable(connection, database)
- """
- def __init__(self, connection, store, database):
- self.store = store
- self.spec = config.get_store_spec(store)
- self._s3 = None
- self.database = database
- self._connection = connection
- self._heading = Heading(table_info=dict(
- conn=connection,
- database=database,
- table_name=self.table_name,
- context=None))
- self._support = [self.full_table_name]
- if not self.is_declared:
- self.declare()
- self._s3 = None
- if self.spec['protocol'] == 'file' and not Path(self.spec['location']).is_dir():
- raise FileNotFoundError('Inaccessible local directory %s' %
- self.spec['location']) from None
-
- @property
- def definition(self):
- return """
- # external storage tracking
- hash : uuid # hash of contents (blob), of filename + contents (attach), or relative filepath (filepath)
- ---
- size :bigint unsigned # size of object in bytes
- attachment_name=null : varchar(255) # the filename of an attachment
- filepath=null : varchar(1000) # relative filepath or attachment filename
- contents_hash=null : uuid # used for the filepath datatype
- timestamp=CURRENT_TIMESTAMP :timestamp # automatic timestamp
- """
-
- @property
- def table_name(self):
- return '{external_table_root}_{store}'.format(external_table_root=EXTERNAL_TABLE_ROOT, store=self.store)
-
- @property
- def s3(self):
- if self._s3 is None:
- self._s3 = s3.Folder(**self.spec)
- return self._s3
-
- # - low-level operations - private
-
- def _make_external_filepath(self, relative_filepath):
- """resolve the complete external path based on the relative path"""
- # Strip root
- if self.spec['protocol'] == 's3':
- posix_path = PurePosixPath(PureWindowsPath(self.spec['location']))
- location_path = Path(
- *posix_path.parts[1:]) if len(
- self.spec['location']) > 0 and any(
- case in posix_path.parts[0] for case in (
- '\\', ':')) else Path(posix_path)
- return PurePosixPath(location_path, relative_filepath)
- # Preserve root
- elif self.spec['protocol'] == 'file':
- return PurePosixPath(Path(self.spec['location']), relative_filepath)
- else:
- assert False
-
- def _make_uuid_path(self, uuid, suffix=''):
- """create external path based on the uuid hash"""
- return self._make_external_filepath(PurePosixPath(
- self.database, '/'.join(subfold(uuid.hex, self.spec['subfolding'])), uuid.hex).with_suffix(suffix))
-
- def _upload_file(self, local_path, external_path, metadata=None):
- if self.spec['protocol'] == 's3':
- self.s3.fput(local_path, external_path, metadata)
- elif self.spec['protocol'] == 'file':
- safe_copy(local_path, external_path, overwrite=True)
- else:
- assert False
-
- def _download_file(self, external_path, download_path):
- if self.spec['protocol'] == 's3':
- self.s3.fget(external_path, download_path)
- elif self.spec['protocol'] == 'file':
- safe_copy(external_path, download_path)
- else:
- assert False
-
- def _upload_buffer(self, buffer, external_path):
- if self.spec['protocol'] == 's3':
- self.s3.put(external_path, buffer)
- elif self.spec['protocol'] == 'file':
- safe_write(external_path, buffer)
- else:
- assert False
-
- def _download_buffer(self, external_path):
- if self.spec['protocol'] == 's3':
- return self.s3.get(external_path)
- if self.spec['protocol'] == 'file':
- return Path(external_path).read_bytes()
- assert False
-
- def _remove_external_file(self, external_path):
- if self.spec['protocol'] == 's3':
- self.s3.remove_object(external_path)
- elif self.spec['protocol'] == 'file':
- try:
- Path(external_path).unlink()
- except FileNotFoundError:
- pass
-
- def exists(self, external_filepath):
- """
- :return: True if the external file is accessible
- """
- if self.spec['protocol'] == 's3':
- return self.s3.exists(external_filepath)
- if self.spec['protocol'] == 'file':
- return Path(external_filepath).is_file()
- assert False
-
- # --- BLOBS ----
-
- def put(self, blob):
- """
- put a binary string (blob) in external store
- """
- uuid = uuid_from_buffer(blob)
- self._upload_buffer(blob, self._make_uuid_path(uuid))
- # insert tracking info
- self.connection.query(
- "INSERT INTO {tab} (hash, size) VALUES (%s, {size}) ON DUPLICATE KEY "
- "UPDATE timestamp=CURRENT_TIMESTAMP".format(
- tab=self.full_table_name, size=len(blob)), args=(uuid.bytes,))
- return uuid
-
- def get(self, uuid):
- """
- get an object from external store.
- """
- if uuid is None:
- return None
- # attempt to get object from cache
- blob = None
- cache_folder = config.get('cache', None)
- if cache_folder:
- try:
- cache_path = Path(cache_folder, *subfold(uuid.hex, CACHE_SUBFOLDING))
- cache_file = Path(cache_path, uuid.hex)
- blob = cache_file.read_bytes()
- except FileNotFoundError:
- pass # not cached
- # download blob from external store
- if blob is None:
- try:
- blob = self._download_buffer(self._make_uuid_path(uuid))
- except MissingExternalFile:
- if not SUPPORT_MIGRATED_BLOBS:
- raise
- # blobs migrated from datajoint 0.11 are stored at explicitly defined filepaths
- relative_filepath, contents_hash = (self & {'hash': uuid}).fetch1('filepath', 'contents_hash')
- if relative_filepath is None:
- raise
- blob = self._download_buffer(self._make_external_filepath(relative_filepath))
- if cache_folder:
- cache_path.mkdir(parents=True, exist_ok=True)
- safe_write(cache_path / uuid.hex, blob)
- return blob
-
- # --- ATTACHMENTS ---
-
- def upload_attachment(self, local_path):
- attachment_name = Path(local_path).name
- uuid = uuid_from_file(local_path, init_string=attachment_name + '\0')
- external_path = self._make_uuid_path(uuid, '.' + attachment_name)
- self._upload_file(local_path, external_path)
- # insert tracking info
- self.connection.query("""
- INSERT INTO {tab} (hash, size, attachment_name)
- VALUES (%s, {size}, "{attachment_name}")
- ON DUPLICATE KEY UPDATE timestamp=CURRENT_TIMESTAMP""".format(
- tab=self.full_table_name,
- size=Path(local_path).stat().st_size,
- attachment_name=attachment_name), args=[uuid.bytes])
- return uuid
-
- def get_attachment_name(self, uuid):
- return (self & {'hash': uuid}).fetch1('attachment_name')
-
- def download_attachment(self, uuid, attachment_name, download_path):
- """ save attachment from memory buffer into the save_path """
- external_path = self._make_uuid_path(uuid, '.' + attachment_name)
- self._download_file(external_path, download_path)
-
- # --- FILEPATH ---
-
- def upload_filepath(self, local_filepath):
- """
- Raise exception if an external entry already exists with a different contents checksum.
- Otherwise, copy (with overwrite) file to remote and
- If an external entry exists with the same checksum, then no copying should occur
- """
- local_filepath = Path(local_filepath)
- try:
- relative_filepath = str(local_filepath.relative_to(self.spec['stage']).as_posix())
- except ValueError:
- raise DataJointError('The path {path} is not in stage {stage}'.format(
- path=local_filepath.parent, **self.spec))
- uuid = uuid_from_buffer(init_string=relative_filepath) # hash relative path, not contents
- contents_hash = uuid_from_file(local_filepath)
-
- # check if the remote file already exists and verify that it matches
- check_hash = (self & {'hash': uuid}).fetch('contents_hash')
- if check_hash:
- # the tracking entry exists, check that it's the same file as before
- if contents_hash != check_hash[0]:
- raise DataJointError(
- "A different version of '{file}' has already been placed.".format(file=relative_filepath))
- else:
- # upload the file and create its tracking entry
- self._upload_file(local_filepath, self._make_external_filepath(relative_filepath),
- metadata={'contents_hash': str(contents_hash)})
- self.connection.query(
- "INSERT INTO {tab} (hash, size, filepath, contents_hash) VALUES (%s, {size}, '{filepath}', %s)".format(
- tab=self.full_table_name, size=Path(local_filepath).stat().st_size,
- filepath=relative_filepath), args=(uuid.bytes, contents_hash.bytes))
- return uuid
-
- def download_filepath(self, filepath_hash):
- """
- sync a file from external store to the local stage
- :param filepath_hash: The hash (UUID) of the relative_path
- :return: hash (UUID) of the contents of the downloaded file or Nones
- """
- if filepath_hash is not None:
- relative_filepath, contents_hash = (self & {'hash': filepath_hash}).fetch1('filepath', 'contents_hash')
- external_path = self._make_external_filepath(relative_filepath)
- local_filepath = Path(self.spec['stage']).absolute() / relative_filepath
- file_exists = Path(local_filepath).is_file() and uuid_from_file(local_filepath) == contents_hash
- if not file_exists:
- self._download_file(external_path, local_filepath)
- checksum = uuid_from_file(local_filepath)
- if checksum != contents_hash: # this should never happen without outside interference
- raise DataJointError("'{file}' downloaded but did not pass checksum'".format(file=local_filepath))
- return str(local_filepath), contents_hash
-
- # --- UTILITIES ---
-
- @property
- def references(self):
- """
- :return: generator of referencing table names and their referencing columns
- """
- return ({k.lower(): v for k, v in elem.items()} for elem in self.connection.query("""
- SELECT concat('`', table_schema, '`.`', table_name, '`') as referencing_table, column_name
- FROM information_schema.key_column_usage
- WHERE referenced_table_name="{tab}" and referenced_table_schema="{db}"
- """.format(tab=self.table_name, db=self.database), as_dict=True))
-
- def fetch_external_paths(self, **fetch_kwargs):
- """
- generate complete external filepaths from the query.
- Each element is a tuple: (uuid, path)
- :param fetch_kwargs: keyword arguments to pass to fetch
- """
- fetch_kwargs.update(as_dict=True)
- paths = []
- for item in self.fetch('hash', 'attachment_name', 'filepath', **fetch_kwargs):
- if item['attachment_name']:
- # attachments
- path = self._make_uuid_path(item['hash'], '.' + item['attachment_name'])
- elif item['filepath']:
- # external filepaths
- path = self._make_external_filepath(item['filepath'])
- else:
- # blobs
- path = self._make_uuid_path(item['hash'])
- paths.append((item['hash'], path))
- return paths
-
- def unused(self):
- """
- query expression for unused hashes
- :return: self restricted to elements that are not in use by any tables in the schema
- """
- return self - [FreeTable(self.connection, ref['referencing_table']).proj(hash=ref['column_name'])
- for ref in self.references]
-
- def used(self):
- """
- query expression for used hashes
- :return: self restricted to elements that in use by tables in the schema
- """
- return self & [FreeTable(self.connection, ref['referencing_table']).proj(hash=ref['column_name'])
- for ref in self.references]
-
- def delete(self, *, delete_external_files=None, limit=None, display_progress=True, errors_as_string=True):
- """
- :param delete_external_files: True or False. If False, only the tracking info is removed from the
- external store table but the external files remain intact. If True, then the external files
- themselves are deleted too.
- :param errors_as_string: If True any errors returned when deleting from external files will be strings
- :param limit: (integer) limit the number of items to delete
- :param display_progress: if True, display progress as files are cleaned up
- :return: if deleting external files, returns errors
- """
- if delete_external_files not in (True, False):
- raise DataJointError(
- "The delete_external_files argument must be set to either "
- "True or False in delete()")
-
- if not delete_external_files:
- self.unused().delete_quick()
- else:
- items = self.unused().fetch_external_paths(limit=limit)
- if display_progress:
- items = tqdm(items)
- # delete items one by one, close to transaction-safe
- error_list = []
- for uuid, external_path in items:
- row = (self & {'hash': uuid}).fetch()
- if row.size:
- try:
- (self & {'hash': uuid}).delete_quick()
- except Exception:
- pass # if delete failed, do not remove the external file
- else:
- try:
- self._remove_external_file(external_path)
- except Exception as error:
- # adding row back into table after failed delete
- self.insert1(row[0], skip_duplicates=True)
- error_list.append((uuid, external_path,
- str(error) if errors_as_string else error))
- return error_list
-
-
-class ExternalMapping(Mapping):
- """
- The external manager contains all the tables for all external stores for a given schema
- :Example:
- e = ExternalMapping(schema)
- external_table = e[store]
- """
- def __init__(self, schema):
- self.schema = schema
- self._tables = {}
-
- def __repr__(self):
- return ("External file tables for schema `{schema}`:\n ".format(schema=self.schema.database)
- + "\n ".join('"{store}" {protocol}:{location}'.format(
- store=k, **v.spec) for k, v in self.items()))
-
- def __getitem__(self, store):
- """
- Triggers the creation of an external table.
- Should only be used when ready to save or read from external storage.
- :param store: the name of the store
- :return: the ExternalTable object for the store
- """
- if store not in self._tables:
- self._tables[store] = ExternalTable(
- connection=self.schema.connection, store=store, database=self.schema.database)
- return self._tables[store]
-
- def __len__(self):
- return len(self._tables)
-
- def __iter__(self):
- return iter(self._tables)
diff --git a/datajoint/fetch.py b/datajoint/fetch.py
deleted file mode 100644
index caf17d5ac..000000000
--- a/datajoint/fetch.py
+++ /dev/null
@@ -1,264 +0,0 @@
-from functools import partial
-from pathlib import Path
-import warnings
-import pandas
-import itertools
-import re
-import numpy as np
-import uuid
-import numbers
-from . import blob, hash
-from .errors import DataJointError
-from .settings import config
-from .utils import safe_write
-
-
-class key:
- """
- object that allows requesting the primary key as an argument in expression.fetch()
- The string "KEY" can be used instead of the class key
- """
- pass
-
-
-def is_key(attr):
- return attr is key or attr == 'KEY'
-
-
-def to_dicts(recarray):
- """convert record array to a dictionaries"""
- for rec in recarray:
- yield dict(zip(recarray.dtype.names, rec.tolist()))
-
-
-def _get(connection, attr, data, squeeze, download_path):
- """
- This function is called for every attribute
-
- :param connection: a dj.Connection object
- :param attr: attribute name from the table's heading
- :param data: literal value fetched from the table
- :param squeeze: if True squeeze blobs
- :param download_path: for fetches that download data, e.g. attachments
- :return: unpacked data
- """
- if data is None:
- return
-
- extern = connection.schemas[attr.database].external[attr.store] if attr.is_external else None
-
- # apply attribute adapter if present
- adapt = attr.adapter.get if attr.adapter else lambda x: x
-
- if attr.is_filepath:
- return adapt(extern.download_filepath(uuid.UUID(bytes=data))[0])
-
- if attr.is_attachment:
- # Steps:
- # 1. get the attachment filename
- # 2. check if the file already exists at download_path, verify checksum
- # 3. if exists and checksum passes then return the local filepath
- # 4. Otherwise, download the remote file and return the new filepath
- _uuid = uuid.UUID(bytes=data) if attr.is_external else None
- attachment_name = (extern.get_attachment_name(_uuid) if attr.is_external
- else data.split(b"\0", 1)[0].decode())
- local_filepath = Path(download_path) / attachment_name
- if local_filepath.is_file():
- attachment_checksum = _uuid if attr.is_external else hash.uuid_from_buffer(data)
- if attachment_checksum == hash.uuid_from_file(local_filepath, init_string=attachment_name + '\0'):
- return adapt(str(local_filepath)) # checksum passed, no need to download again
- # generate the next available alias filename
- for n in itertools.count():
- f = local_filepath.parent / (local_filepath.stem + '_%04x' % n + local_filepath.suffix)
- if not f.is_file():
- local_filepath = f
- break
- if attachment_checksum == hash.uuid_from_file(f, init_string=attachment_name + '\0'):
- return adapt(str(f)) # checksum passed, no need to download again
- # Save attachment
- if attr.is_external:
- extern.download_attachment(_uuid, attachment_name, local_filepath)
- else:
- # write from buffer
- safe_write(local_filepath, data.split(b"\0", 1)[1])
- return adapt(str(local_filepath)) # download file from remote store
-
- return adapt(uuid.UUID(bytes=data) if attr.uuid else (
- blob.unpack(extern.get(uuid.UUID(bytes=data)) if attr.is_external else data, squeeze=squeeze)
- if attr.is_blob else data))
-
-
-def _flatten_attribute_list(primary_key, attrs):
- """
- :param primary_key: list of attributes in primary key
- :param attrs: list of attribute names, which may include "KEY", "KEY DESC" or "KEY ASC"
- :return: generator of attributes where "KEY" is replaces with its component attributes
- """
- for a in attrs:
- if re.match(r'^\s*KEY(\s+[aA][Ss][Cc])?\s*$', a):
- yield from primary_key
- elif re.match(r'^\s*KEY\s+[Dd][Ee][Ss][Cc]\s*$', a):
- yield from (q + ' DESC' for q in primary_key)
- else:
- yield a
-
-
-class Fetch:
- """
- A fetch object that handles retrieving elements from the table expression.
- :param expression: the QueryExpression object to fetch from.
- """
-
- def __init__(self, expression):
- self._expression = expression
-
- def __call__(self, *attrs, offset=None, limit=None, order_by=None, format=None, as_dict=None,
- squeeze=False, download_path='.'):
- """
- Fetches the expression results from the database into an np.array or list of dictionaries and
- unpacks blob attributes.
- :param attrs: zero or more attributes to fetch. If not provided, the call will return
- all attributes of this relation. If provided, returns tuples with an entry for each attribute.
- :param offset: the number of tuples to skip in the returned result
- :param limit: the maximum number of tuples to return
- :param order_by: a single attribute or the list of attributes to order the results.
- No ordering should be assumed if order_by=None.
- To reverse the order, add DESC to the attribute name or names: e.g. ("age DESC", "frequency")
- To order by primary key, use "KEY" or "KEY DESC"
- :param format: Effective when as_dict=None and when attrs is empty
- None: default from config['fetch_format'] or 'array' if not configured
- "array": use numpy.key_array
- "frame": output pandas.DataFrame. .
- :param as_dict: returns a list of dictionaries instead of a record array.
- Defaults to False for .fetch() and to True for .fetch('KEY')
- :param squeeze: if True, remove extra dimensions from arrays
- :param download_path: for fetches that download data, e.g. attachments
- :return: the contents of the relation in the form of a structured numpy.array or a dict list
- """
- if order_by is not None:
- # if 'order_by' passed in a string, make into list
- if isinstance(order_by, str):
- order_by = [order_by]
- # expand "KEY" or "KEY DESC"
- order_by = list(_flatten_attribute_list(self._expression.primary_key, order_by))
-
- attrs_as_dict = as_dict and attrs
- if attrs_as_dict:
- # absorb KEY into attrs and prepare to return attributes as dict (issue #595)
- if any(is_key(k) for k in attrs):
- attrs = list(self._expression.primary_key) + [
- a for a in attrs if a not in self._expression.primary_key]
- if as_dict is None:
- as_dict = bool(attrs) # default to True for "KEY" and False otherwise
- # format should not be specified with attrs or is_dict=True
- if format is not None and (as_dict or attrs):
- raise DataJointError('Cannot specify output format when as_dict=True or '
- 'when attributes are selected to be fetched separately.')
- if format not in {None, "array", "frame"}:
- raise DataJointError(
- 'Fetch output format must be in '
- '{{"array", "frame"}} but "{}" was given'.format(format))
-
- if not (attrs or as_dict) and format is None:
- format = config['fetch_format'] # default to array
- if format not in {"array", "frame"}:
- raise DataJointError(
- 'Invalid entry "{}" in datajoint.config["fetch_format"]: '
- 'use "array" or "frame"'.format(format))
-
- if limit is None and offset is not None:
- warnings.warn('Offset set, but no limit. Setting limit to a large number. '
- 'Consider setting a limit explicitly.')
- limit = 8000000000 # just a very large number to effect no limit
-
- get = partial(_get, self._expression.connection,
- squeeze=squeeze, download_path=download_path)
- if attrs: # a list of attributes provided
- attributes = [a for a in attrs if not is_key(a)]
- ret = self._expression.proj(*attributes)
- ret = ret.fetch(
- offset=offset, limit=limit, order_by=order_by,
- as_dict=False, squeeze=squeeze, download_path=download_path,
- format='array')
- if attrs_as_dict:
- ret = [{k: v for k, v in zip(ret.dtype.names, x) if k in attrs} for x in ret]
- else:
- return_values = [list(
- (to_dicts if as_dict else lambda x: x)(ret[self._expression.primary_key]))
- if is_key(attribute) else ret[attribute]
- for attribute in attrs]
- ret = return_values[0] if len(attrs) == 1 else return_values
- else: # fetch all attributes as a numpy.record_array or pandas.DataFrame
- cur = self._expression.cursor(
- as_dict=as_dict, limit=limit, offset=offset, order_by=order_by)
- heading = self._expression.heading
- if as_dict:
- ret = [dict((name, get(heading[name], d[name]))
- for name in heading.names) for d in cur]
- else:
- ret = list(cur.fetchall())
- record_type = (heading.as_dtype if not ret else np.dtype(
- [(name, type(value)) # use the first element to determine blob type
- if heading[name].is_blob and isinstance(value, numbers.Number)
- else (name, heading.as_dtype[name])
- for value, name in zip(ret[0], heading.as_dtype.names)]))
- try:
- ret = np.array(ret, dtype=record_type)
- except Exception as e:
- raise e
- for name in heading:
- # unpack blobs and externals
- ret[name] = list(map(partial(get, heading[name]), ret[name]))
- if format == "frame":
- ret = pandas.DataFrame(ret).set_index(heading.primary_key)
- return ret
-
-
-class Fetch1:
- """
- Fetch object for fetching the result of a query yielding one row.
- :param expression: a query expression to fetch from.
- """
- def __init__(self, expression):
- self._expression = expression
-
- def __call__(self, *attrs, squeeze=False, download_path='.'):
- """
- Fetches the result of a query expression that yields one entry.
-
- If no attributes are specified, returns the result as a dict.
- If attributes are specified returns the corresponding results as a tuple.
-
- Examples:
- d = rel.fetch1() # as a dictionary
- a, b = rel.fetch1('a', 'b') # as a tuple
-
- :params *attrs: attributes to return when expanding into a tuple.
- If attrs is empty, the return result is a dict
- :param squeeze: When true, remove extra dimensions from arrays in attributes
- :param download_path: for fetches that download data, e.g. attachments
- :return: the one tuple in the relation in the form of a dict
- """
- heading = self._expression.heading
-
- if not attrs: # fetch all attributes, return as ordered dict
- cur = self._expression.cursor(as_dict=True)
- ret = cur.fetchone()
- if not ret or cur.fetchone():
- raise DataJointError('fetch1 requires exactly one tuple in the input set.')
- ret = dict((name, _get(self._expression.connection, heading[name], ret[name],
- squeeze=squeeze, download_path=download_path))
- for name in heading.names)
- else: # fetch some attributes, return as tuple
- attributes = [a for a in attrs if not is_key(a)]
- result = self._expression.proj(*attributes).fetch(
- squeeze=squeeze, download_path=download_path, format="array")
- if len(result) != 1:
- raise DataJointError(
- 'fetch1 should only return one tuple. %d tuples found' % len(result))
- return_values = tuple(
- next(to_dicts(result[self._expression.primary_key]))
- if is_key(attribute) else result[attribute][0]
- for attribute in attrs)
- ret = return_values[0] if len(attrs) == 1 else return_values
- return ret
diff --git a/datajoint/hash.py b/datajoint/hash.py
deleted file mode 100644
index 67ec103ae..000000000
--- a/datajoint/hash.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import hashlib
-import uuid
-import io
-from pathlib import Path
-
-
-def key_hash(mapping):
- """
- 32-byte hash of the mapping's key values sorted by the key name.
- This is often used to convert a long primary key value into a shorter hash.
- For example, the JobTable in datajoint.jobs uses this function to hash the primary key of autopopulated tables.
- """
- hashed = hashlib.md5()
- for k, v in sorted(mapping.items()):
- hashed.update(str(v).encode())
- return hashed.hexdigest()
-
-
-def uuid_from_stream(stream, *, init_string=""):
- """
- :return: 16-byte digest of stream data
- :stream: stream object or open file handle
- :init_string: string to initialize the checksum
- """
- hashed = hashlib.md5(init_string.encode())
- chunk = True
- chunk_size = 1 << 14
- while chunk:
- chunk = stream.read(chunk_size)
- hashed.update(chunk)
- return uuid.UUID(bytes=hashed.digest())
-
-
-def uuid_from_buffer(buffer=b"", *, init_string=""):
- return uuid_from_stream(io.BytesIO(buffer), init_string=init_string)
-
-
-def uuid_from_file(filepath, *, init_string=""):
- return uuid_from_stream(Path(filepath).open("rb"), init_string=init_string)
diff --git a/datajoint/heading.py b/datajoint/heading.py
deleted file mode 100644
index 076a2204e..000000000
--- a/datajoint/heading.py
+++ /dev/null
@@ -1,368 +0,0 @@
-import numpy as np
-from collections import namedtuple, defaultdict
-from itertools import chain
-import re
-import logging
-from .errors import DataJointError, _support_filepath_types, FILEPATH_FEATURE_SWITCH
-from .declare import UUID_DATA_TYPE, SPECIAL_TYPES, TYPE_PATTERN, EXTERNAL_TYPES, NATIVE_TYPES
-from .attribute_adapter import get_adapter, AttributeAdapter
-
-
-logger = logging.getLogger(__name__)
-
-default_attribute_properties = dict( # these default values are set in computed attributes
- name=None, type='expression', in_key=False, nullable=False, default=None, comment='calculated attribute',
- autoincrement=False, numeric=None, string=None, uuid=False, is_blob=False, is_attachment=False, is_filepath=False,
- is_external=False, adapter=None,
- store=None, unsupported=False, attribute_expression=None, database=None, dtype=object)
-
-
-class Attribute(namedtuple('_Attribute', default_attribute_properties)):
- """
- Properties of a table column (attribute)
- """
- def todict(self):
- """Convert namedtuple to dict."""
- return dict((name, self[i]) for i, name in enumerate(self._fields))
-
- @property
- def sql_type(self):
- """ :return: datatype (as string) in database. In most cases, it is the same as self.type """
- return UUID_DATA_TYPE if self.uuid else self.type
-
- @property
- def sql_comment(self):
- """ :return: full comment for the SQL declaration. Includes custom type specification """
- return (':uuid:' if self.uuid else '') + self.comment
-
- @property
- def sql(self):
- """
- Convert primary key attribute tuple into its SQL CREATE TABLE clause.
- Default values are not reflected.
- This is used for declaring foreign keys in referencing tables
- :return: SQL code for attribute declaration
- """
- return '`{name}` {type} NOT NULL COMMENT "{comment}"'.format(
- name=self.name, type=self.sql_type, comment=self.sql_comment)
-
- @property
- def original_name(self):
- if self.attribute_expression is None:
- return self.name
- assert self.attribute_expression.startswith('`')
- return self.attribute_expression.strip('`')
-
-
-class Heading:
- """
- Local class for relations' headings.
- Heading contains the property attributes, which is an dict in which the keys are
- the attribute names and the values are Attributes.
- """
-
- def __init__(self, attribute_specs=None, table_info=None):
- """
- :param attribute_specs: a list of dicts with the same keys as Attribute
- :param table_info: a dict with information to load the heading from the database
- """
- self.indexes = None
- self.table_info = table_info
- self._table_status = None
- self._attributes = None if attribute_specs is None else dict(
- (q['name'], Attribute(**q)) for q in attribute_specs)
-
- def __len__(self):
- return 0 if self.attributes is None else len(self.attributes)
-
- @property
- def table_status(self):
- if self.table_info is None:
- return None
- if self._table_status is None:
- self._init_from_database()
- return self._table_status
-
- @property
- def attributes(self):
- if self._attributes is None:
- self._init_from_database() # lazy loading from database
- return self._attributes
-
- @property
- def names(self):
- return [k for k in self.attributes]
-
- @property
- def primary_key(self):
- return [k for k, v in self.attributes.items() if v.in_key]
-
- @property
- def secondary_attributes(self):
- return [k for k, v in self.attributes.items() if not v.in_key]
-
- @property
- def blobs(self):
- return [k for k, v in self.attributes.items() if v.is_blob]
-
- @property
- def non_blobs(self):
- return [k for k, v in self.attributes.items() if not v.is_blob and not v.is_attachment and not v.is_filepath]
-
- @property
- def new_attributes(self):
- return [k for k, v in self.attributes.items() if v.attribute_expression is not None]
-
- def __getitem__(self, name):
- """shortcut to the attribute"""
- return self.attributes[name]
-
- def __repr__(self):
- """
- :return: heading representation in DataJoint declaration format but without foreign key expansion
- """
- in_key = True
- ret = ''
- if self._table_status is not None:
- ret += '# ' + self.table_status['comment'] + '\n'
- for v in self.attributes.values():
- if in_key and not v.in_key:
- ret += '---\n'
- in_key = False
- ret += '%-20s : %-28s # %s\n' % (
- v.name if v.default is None else '%s=%s' % (v.name, v.default),
- '%s%s' % (v.type, 'auto_increment' if v.autoincrement else ''), v.comment)
- return ret
-
- @property
- def has_autoincrement(self):
- return any(e.autoincrement for e in self.attributes.values())
-
- @property
- def as_dtype(self):
- """
- represent the heading as a numpy dtype
- """
- return np.dtype(dict(
- names=self.names,
- formats=[v.dtype for v in self.attributes.values()]))
-
- def as_sql(self, fields, include_aliases=True):
- """
- represent heading as the SQL SELECT clause.
- """
- return ','.join(
- '`%s`' % name if self.attributes[name].attribute_expression is None
- else self.attributes[name].attribute_expression + (' as `%s`' % name if include_aliases else '')
- for name in fields)
-
- def __iter__(self):
- return iter(self.attributes)
-
- def _init_from_database(self):
- """ initialize heading from an existing database table. """
- conn, database, table_name, context = (
- self.table_info[k] for k in ('conn', 'database', 'table_name', 'context'))
- info = conn.query('SHOW TABLE STATUS FROM `{database}` WHERE name="{table_name}"'.format(
- table_name=table_name, database=database), as_dict=True).fetchone()
- if info is None:
- if table_name == '~log':
- logger.warning('Could not create the ~log table')
- return
- raise DataJointError('The table `{database}`.`{table_name}` is not defined.'.format(
- table_name=table_name, database=database))
- self._table_status = {k.lower(): v for k, v in info.items()}
- cur = conn.query(
- 'SHOW FULL COLUMNS FROM `{table_name}` IN `{database}`'.format(
- table_name=table_name, database=database), as_dict=True)
-
- attributes = cur.fetchall()
-
- rename_map = {
- 'Field': 'name',
- 'Type': 'type',
- 'Null': 'nullable',
- 'Default': 'default',
- 'Key': 'in_key',
- 'Comment': 'comment'}
-
- fields_to_drop = ('Privileges', 'Collation')
-
- # rename and drop attributes
- attributes = [{rename_map[k] if k in rename_map else k: v
- for k, v in x.items() if k not in fields_to_drop}
- for x in attributes]
- numeric_types = {
- ('float', False): np.float64,
- ('float', True): np.float64,
- ('double', False): np.float64,
- ('double', True): np.float64,
- ('tinyint', False): np.int64,
- ('tinyint', True): np.int64,
- ('smallint', False): np.int64,
- ('smallint', True): np.int64,
- ('mediumint', False): np.int64,
- ('mediumint', True): np.int64,
- ('int', False): np.int64,
- ('int', True): np.int64,
- ('bigint', False): np.int64,
- ('bigint', True): np.uint64}
-
- sql_literals = ['CURRENT_TIMESTAMP']
-
- # additional attribute properties
- for attr in attributes:
-
- attr.update(
- in_key=(attr['in_key'] == 'PRI'),
- database=database,
- nullable=attr['nullable'] == 'YES',
- autoincrement=bool(re.search(r'auto_increment', attr['Extra'], flags=re.I)),
- numeric=any(TYPE_PATTERN[t].match(attr['type']) for t in ('DECIMAL', 'INTEGER', 'FLOAT')),
- string=any(TYPE_PATTERN[t].match(attr['type']) for t in ('ENUM', 'TEMPORAL', 'STRING')),
- is_blob=bool(TYPE_PATTERN['INTERNAL_BLOB'].match(attr['type'])),
- uuid=False, is_attachment=False, is_filepath=False, adapter=None,
- store=None, is_external=False, attribute_expression=None)
-
- if any(TYPE_PATTERN[t].match(attr['type']) for t in ('INTEGER', 'FLOAT')):
- attr['type'] = re.sub(r'\(\d+\)', '', attr['type'], count=1) # strip size off integers and floats
- attr['unsupported'] = not any((attr['is_blob'], attr['numeric'], attr['numeric']))
- attr.pop('Extra')
-
- # process custom DataJoint types
- special = re.match(r':(?P[^:]+):(?P.*)', attr['comment'])
- if special:
- special = special.groupdict()
- attr.update(special)
- # process adapted attribute types
- if special and TYPE_PATTERN['ADAPTED'].match(attr['type']):
- assert context is not None, 'Declaration context is not set'
- adapter_name = special['type']
- try:
- attr.update(adapter=get_adapter(context, adapter_name))
- except DataJointError:
- # if no adapter, then delay the error until the first invocation
- attr.update(adapter=AttributeAdapter())
- else:
- attr.update(type=attr['adapter'].attribute_type)
- if not any(r.match(attr['type']) for r in TYPE_PATTERN.values()):
- raise DataJointError(
- "Invalid attribute type '{type}' in adapter object <{adapter_name}>.".format(
- adapter_name=adapter_name, **attr))
- special = not any(TYPE_PATTERN[c].match(attr['type']) for c in NATIVE_TYPES)
-
- if special:
- try:
- category = next(c for c in SPECIAL_TYPES if TYPE_PATTERN[c].match(attr['type']))
- except StopIteration:
- if attr['type'].startswith('external'):
- url = "https://docs.datajoint.io/python/admin/5-blob-config.html" \
- "#migration-between-datajoint-v0-11-and-v0-12"
- raise DataJointError('Legacy datatype `{type}`. Migrate your external stores to '
- 'datajoint 0.12: {url}'.format(url=url, **attr))
- raise DataJointError('Unknown attribute type `{type}`'.format(**attr))
- if category == 'FILEPATH' and not _support_filepath_types():
- raise DataJointError("""
- The filepath data type is disabled until complete validation.
- To turn it on as experimental feature, set the environment variable
- {env} = TRUE or upgrade datajoint.
- """.format(env=FILEPATH_FEATURE_SWITCH))
- attr.update(
- unsupported=False,
- is_attachment=category in ('INTERNAL_ATTACH', 'EXTERNAL_ATTACH'),
- is_filepath=category == 'FILEPATH',
- # INTERNAL_BLOB is not a custom type but is included for completeness
- is_blob=category in ('INTERNAL_BLOB', 'EXTERNAL_BLOB'),
- uuid=category == 'UUID',
- is_external=category in EXTERNAL_TYPES,
- store=attr['type'].split('@')[1] if category in EXTERNAL_TYPES else None)
-
- if attr['in_key'] and any((attr['is_blob'], attr['is_attachment'], attr['is_filepath'])):
- raise DataJointError('Blob, attachment, or filepath attributes are not allowed in the primary key')
-
- if attr['string'] and attr['default'] is not None and attr['default'] not in sql_literals:
- attr['default'] = '"%s"' % attr['default']
-
- if attr['nullable']: # nullable fields always default to null
- attr['default'] = 'null'
-
- # fill out dtype. All floats and non-nullable integers are turned into specific dtypes
- attr['dtype'] = object
- if attr['numeric'] and not attr['adapter']:
- is_integer = TYPE_PATTERN['INTEGER'].match(attr['type'])
- is_float = TYPE_PATTERN['FLOAT'].match(attr['type'])
- if is_integer and not attr['nullable'] or is_float:
- is_unsigned = bool(re.match('sunsigned', attr['type'], flags=re.I))
- t = re.sub(r'\(.*\)', '', attr['type']) # remove parentheses
- t = re.sub(r' unsigned$', '', t) # remove unsigned
- assert (t, is_unsigned) in numeric_types, 'dtype not found for type %s' % t
- attr['dtype'] = numeric_types[(t, is_unsigned)]
-
- if attr['adapter']:
- # restore adapted type name
- attr['type'] = adapter_name
-
- self._attributes = dict(((q['name'], Attribute(**q)) for q in attributes))
-
- # Read and tabulate secondary indexes
- keys = defaultdict(dict)
- for item in conn.query('SHOW KEYS FROM `{db}`.`{tab}`'.format(db=database, tab=table_name), as_dict=True):
- if item['Key_name'] != 'PRIMARY':
- keys[item['Key_name']][item['Seq_in_index']] = dict(
- column=item['Column_name'],
- unique=(item['Non_unique'] == 0),
- nullable=item['Null'].lower() == 'yes')
- self.indexes = {
- tuple(item[k]['column'] for k in sorted(item.keys())):
- dict(unique=item[1]['unique'],
- nullable=any(v['nullable'] for v in item.values()))
- for item in keys.values()}
-
- def select(self, select_list, rename_map=None, compute_map=None):
- """
- derive a new heading by selecting, renaming, or computing attributes.
- In relational algebra these operators are known as project, rename, and extend.
- :param select_list: the full list of existing attributes to include
- :param rename_map: dictionary of renamed attributes: keys=new names, values=old names
- :param compute_map: a direction of computed attributes
- This low-level method performs no error checking.
- """
- rename_map = rename_map or {}
- compute_map = compute_map or {}
- copy_attrs = list()
- for name in self.attributes:
- if name in select_list:
- copy_attrs.append(self.attributes[name].todict())
- copy_attrs.extend((
- dict(self.attributes[old_name].todict(), name=new_name, attribute_expression='`%s`' % old_name)
- for new_name, old_name in rename_map.items() if old_name == name))
- compute_attrs = (dict(default_attribute_properties, name=new_name, attribute_expression=expr)
- for new_name, expr in compute_map.items())
- return Heading(chain(copy_attrs, compute_attrs))
-
- def join(self, other):
- """
- Join two headings into a new one.
- It assumes that self and other are headings that share no common dependent attributes.
- """
- return Heading(
- [self.attributes[name].todict() for name in self.primary_key] +
- [other.attributes[name].todict() for name in other.primary_key if name not in self.primary_key] +
- [self.attributes[name].todict() for name in self.secondary_attributes if name not in other.primary_key] +
- [other.attributes[name].todict() for name in other.secondary_attributes if name not in self.primary_key])
-
- def set_primary_key(self, primary_key):
- """
- Create a new heading with the specified primary key.
- This low-level method performs no error checking.
- """
- return Heading(chain(
- (dict(self.attributes[name].todict(), in_key=True) for name in primary_key),
- (dict(self.attributes[name].todict(), in_key=False) for name in self.names if name not in primary_key)))
-
- def make_subquery_heading(self):
- """
- Create a new heading with removed attribute sql_expressions.
- Used by subqueries, which resolve the sql_expressions.
- """
- return Heading(dict(v.todict(), attribute_expression=None) for v in self.attributes.values())
diff --git a/datajoint/jobs.py b/datajoint/jobs.py
deleted file mode 100644
index 571270931..000000000
--- a/datajoint/jobs.py
+++ /dev/null
@@ -1,119 +0,0 @@
-import os
-from .hash import key_hash
-import platform
-from .table import Table
-from .settings import config
-from .errors import DuplicateError
-from .heading import Heading
-
-ERROR_MESSAGE_LENGTH = 2047
-TRUNCATION_APPENDIX = '...truncated'
-
-
-class JobTable(Table):
- """
- A base relation with no definition. Allows reserving jobs
- """
- def __init__(self, conn, database):
- self.database = database
- self._connection = conn
- self._heading = Heading(table_info=dict(
- conn=conn,
- database=database,
- table_name=self.table_name,
- context=None
- ))
- self._support = [self.full_table_name]
-
- self._definition = """ # job reservation table for `{database}`
- table_name :varchar(255) # className of the table
- key_hash :char(32) # key hash
- ---
- status :enum('reserved','error','ignore') # if tuple is missing, the job is available
- key=null :blob # structure containing the key
- error_message="" :varchar({error_message_length}) # error message returned if failed
- error_stack=null :mediumblob # error stack if failed
- user="" :varchar(255) # database user
- host="" :varchar(255) # system hostname
- pid=0 :int unsigned # system process id
- connection_id = 0 : bigint unsigned # connection_id()
- timestamp=CURRENT_TIMESTAMP :timestamp # automatic timestamp
- """.format(database=database, error_message_length=ERROR_MESSAGE_LENGTH)
- if not self.is_declared:
- self.declare()
- self._user = self.connection.get_user()
-
- @property
- def definition(self):
- return self._definition
-
- @property
- def table_name(self):
- return '~jobs'
-
- def delete(self):
- """bypass interactive prompts and dependencies"""
- self.delete_quick()
-
- def drop(self):
- """bypass interactive prompts and dependencies"""
- self.drop_quick()
-
- def reserve(self, table_name, key):
- """
- Reserve a job for computation. When a job is reserved, the job table contains an entry for the
- job key, identified by its hash. When jobs are completed, the entry is removed.
- :param table_name: `database`.`table_name`
- :param key: the dict of the job's primary key
- :return: True if reserved job successfully. False = the jobs is already taken
- """
- job = dict(
- table_name=table_name,
- key_hash=key_hash(key),
- status='reserved',
- host=platform.node(),
- pid=os.getpid(),
- connection_id=self.connection.connection_id,
- key=key,
- user=self._user)
- try:
- with config(enable_python_native_blobs=True):
- self.insert1(job, ignore_extra_fields=True)
- except DuplicateError:
- return False
- return True
-
- def complete(self, table_name, key):
- """
- Log a completed job. When a job is completed, its reservation entry is deleted.
- :param table_name: `database`.`table_name`
- :param key: the dict of the job's primary key
- """
- job_key = dict(table_name=table_name, key_hash=key_hash(key))
- (self & job_key).delete_quick()
-
- def error(self, table_name, key, error_message, error_stack=None):
- """
- Log an error message. The job reservation is replaced with an error entry.
- if an error occurs, leave an entry describing the problem
- :param table_name: `database`.`table_name`
- :param key: the dict of the job's primary key
- :param error_message: string error message
- :param error_stack: stack trace
- """
- if len(error_message) > ERROR_MESSAGE_LENGTH:
- error_message = error_message[:ERROR_MESSAGE_LENGTH-len(TRUNCATION_APPENDIX)] + TRUNCATION_APPENDIX
- with config(enable_python_native_blobs=True):
- self.insert1(
- dict(
- table_name=table_name,
- key_hash=key_hash(key),
- status="error",
- host=platform.node(),
- pid=os.getpid(),
- connection_id=self.connection.connection_id,
- user=self._user,
- key=key,
- error_message=error_message,
- error_stack=error_stack),
- replace=True, ignore_extra_fields=True)
diff --git a/datajoint/migrate.py b/datajoint/migrate.py
deleted file mode 100644
index 445bc317c..000000000
--- a/datajoint/migrate.py
+++ /dev/null
@@ -1,157 +0,0 @@
-import datajoint as dj
-from pathlib import Path
-import re
-from .utils import user_choice
-
-
-def migrate_dj011_external_blob_storage_to_dj012(migration_schema, store):
- """
- Utility function to migrate external blob data from 0.11 to 0.12.
- :param migration_schema: string of target schema to be migrated
- :param store: string of target dj.config['store'] to be migrated
- """
- if not isinstance(migration_schema, str):
- raise ValueError(
- 'Expected type {} for migration_schema, not {}.'.format(
- str, type(migration_schema)))
-
- do_migration = False
- do_migration = user_choice(
- """
-Warning: Ensure the following are completed before proceeding.
-- Appropriate backups have been taken,
-- Any existing DJ 0.11.X connections are suspended, and
-- External config has been updated to new dj.config['stores'] structure.
-Proceed?
- """, default='no') == 'yes'
- if do_migration:
- _migrate_dj011_blob(dj.Schema(migration_schema), store)
- print('Migration completed for schema: {}, store: {}.'.format(
- migration_schema, store))
- return
- print('No migration performed.')
-
-
-def _migrate_dj011_blob(schema, default_store):
- query = schema.connection.query
-
- LEGACY_HASH_SIZE = 43
-
- legacy_external = dj.FreeTable(
- schema.connection,
- '`{db}`.`~external`'.format(db=schema.database))
-
- # get referencing tables
- refs = [{k.lower(): v for k, v in elem.items()} for elem in query("""
- SELECT concat('`', table_schema, '`.`', table_name, '`')
- as referencing_table, column_name, constraint_name
- FROM information_schema.key_column_usage
- WHERE referenced_table_name="{tab}" and referenced_table_schema="{db}"
- """.format(
- tab=legacy_external.table_name,
- db=legacy_external.database), as_dict=True).fetchall()]
-
- for ref in refs:
- # get comment
- column = query(
- 'SHOW FULL COLUMNS FROM {referencing_table}'
- 'WHERE Field="{column_name}"'.format(
- **ref), as_dict=True).fetchone()
-
- store, comment = re.match(
- r':external(-(?P.+))?:(?P.*)',
- column['Comment']).group('store', 'comment')
-
- # get all the hashes from the reference
- hashes = {x[0] for x in query(
- 'SELECT `{column_name}` FROM {referencing_table}'.format(
- **ref))}
-
- # sanity check make sure that store suffixes match
- if store is None:
- assert all(len(_) == LEGACY_HASH_SIZE for _ in hashes)
- else:
- assert all(_[LEGACY_HASH_SIZE:] == store for _ in hashes)
-
- # create new-style external table
- ext = schema.external[store or default_store]
-
- # add the new-style reference field
- temp_suffix = 'tempsub'
-
- try:
- query("""ALTER TABLE {referencing_table}
- ADD COLUMN `{column_name}_{temp_suffix}` {type} DEFAULT NULL
- COMMENT ":blob@{store}:{comment}"
- """.format(
- type=dj.declare.UUID_DATA_TYPE,
- temp_suffix=temp_suffix,
- store=(store or default_store), comment=comment, **ref))
- except:
- print('Column already added')
- pass
-
- for _hash, size in zip(*legacy_external.fetch('hash', 'size')):
- if _hash in hashes:
- relative_path = str(Path(schema.database, _hash).as_posix())
- uuid = dj.hash.uuid_from_buffer(init_string=relative_path)
- external_path = ext._make_external_filepath(relative_path)
- if ext.spec['protocol'] == 's3':
- contents_hash = dj.hash.uuid_from_buffer(ext._download_buffer(external_path))
- else:
- contents_hash = dj.hash.uuid_from_file(external_path)
- ext.insert1(dict(
- filepath=relative_path,
- size=size,
- contents_hash=contents_hash,
- hash=uuid
- ), skip_duplicates=True)
-
- query(
- 'UPDATE {referencing_table} '
- 'SET `{column_name}_{temp_suffix}`=%s '
- 'WHERE `{column_name}` = "{_hash}"'
- .format(
- _hash=_hash,
- temp_suffix=temp_suffix, **ref), uuid.bytes)
-
- # check that all have been copied
- check = query(
- 'SELECT * FROM {referencing_table} '
- 'WHERE `{column_name}` IS NOT NULL'
- ' AND `{column_name}_{temp_suffix}` IS NULL'
- .format(temp_suffix=temp_suffix, **ref)).fetchall()
-
- assert len(check) == 0, 'Some hashes havent been migrated'
-
- # drop old foreign key, rename, and create new foreign key
- query("""
- ALTER TABLE {referencing_table}
- DROP FOREIGN KEY `{constraint_name}`,
- DROP COLUMN `{column_name}`,
- CHANGE COLUMN `{column_name}_{temp_suffix}` `{column_name}`
- {type} DEFAULT NULL
- COMMENT ":blob@{store}:{comment}",
- ADD FOREIGN KEY (`{column_name}`) REFERENCES {ext_table_name}
- (`hash`)
- """.format(
- temp_suffix=temp_suffix,
- ext_table_name=ext.full_table_name,
- type=dj.declare.UUID_DATA_TYPE,
- store=(store or default_store), comment=comment, **ref))
-
- # Drop the old external table but make sure it's no longer referenced
- # get referencing tables
- refs = [{k.lower(): v for k, v in elem.items()} for elem in query("""
- SELECT concat('`', table_schema, '`.`', table_name, '`') as
- referencing_table, column_name, constraint_name
- FROM information_schema.key_column_usage
- WHERE referenced_table_name="{tab}" and referenced_table_schema="{db}"
- """.format(
- tab=legacy_external.table_name,
- db=legacy_external.database), as_dict=True).fetchall()]
-
- assert not refs, 'Some references still exist'
-
- # drop old external table
- legacy_external.drop_quick()
diff --git a/datajoint/plugin.py b/datajoint/plugin.py
deleted file mode 100644
index d82e457d1..000000000
--- a/datajoint/plugin.py
+++ /dev/null
@@ -1,38 +0,0 @@
-from .settings import config
-import pkg_resources
-from pathlib import Path
-from cryptography.exceptions import InvalidSignature
-from otumat import hash_pkg, verify
-
-
-def _update_error_stack(plugin_name):
- try:
- base_name = 'datajoint'
- base_meta = pkg_resources.get_distribution(base_name)
- plugin_meta = pkg_resources.get_distribution(plugin_name)
-
- data = hash_pkg(pkgpath=str(Path(plugin_meta.module_path, plugin_name)))
- signature = plugin_meta.get_metadata('{}.sig'.format(plugin_name))
- pubkey_path = str(Path(base_meta.egg_info, '{}.pub'.format(base_name)))
- verify(pubkey_path=pubkey_path, data=data, signature=signature)
- print('DataJoint verified plugin `{}` detected.'.format(plugin_name))
- return True
- except (FileNotFoundError, InvalidSignature):
- print('Unverified plugin `{}` detected.'.format(plugin_name))
- return False
-
-
-def _import_plugins(category):
- return {
- entry_point.name: dict(object=entry_point,
- verified=_update_error_stack(
- entry_point.module_name.split('.')[0]))
- for entry_point
- in pkg_resources.iter_entry_points('datajoint_plugins.{}'.format(category))
- if 'plugin' not in config or category not in config['plugin'] or
- entry_point.module_name.split('.')[0] in config['plugin'][category]
- }
-
-
-connection_plugins = _import_plugins('connection')
-type_plugins = _import_plugins('datatype')
diff --git a/datajoint/preview.py b/datajoint/preview.py
deleted file mode 100644
index f3daeebf5..000000000
--- a/datajoint/preview.py
+++ /dev/null
@@ -1,113 +0,0 @@
-""" methods for generating previews of query expression results in python command line and Jupyter """
-
-from .settings import config
-
-
-def preview(query_expression, limit, width):
- heading = query_expression.heading
- rel = query_expression.proj(*heading.non_blobs)
- if limit is None:
- limit = config['display.limit']
- if width is None:
- width = config['display.width']
- tuples = rel.fetch(limit=limit + 1, format="array")
- has_more = len(tuples) > limit
- tuples = tuples[:limit]
- columns = heading.names
- widths = {f: min(max([len(f)] +
- [len(str(e)) for e in tuples[f]] if f in tuples.dtype.names else [len('=BLOB=')]) + 4, width) for f
- in columns}
- templates = {f: '%%-%d.%ds' % (widths[f], widths[f]) for f in columns}
- return (
- ' '.join([templates[f] % ('*' + f if f in rel.primary_key else f) for f in columns]) + '\n' +
- ' '.join(['+' + '-' * (widths[column] - 2) + '+' for column in columns]) + '\n' +
- '\n'.join(' '.join(templates[f] % (tup[f] if f in tup.dtype.names else '=BLOB=')
- for f in columns) for tup in tuples) +
- ('\n ...\n' if has_more else '\n') +
- (' (Total: %d)\n' % len(rel) if config['display.show_tuple_count'] else ''))
-
-
-def repr_html(query_expression):
- heading = query_expression.heading
- rel = query_expression.proj(*heading.non_blobs)
- info = heading.table_status
- tuples = rel.fetch(limit=config['display.limit'] + 1, format='array')
- has_more = len(tuples) > config['display.limit']
- tuples = tuples[0:config['display.limit']]
-
- css = """
-
- """
- head_template = """
-
{column}
- {comment}
-
"""
- return """
- {css}
- {title}
-
-
-
{head}
-
{body}
-
- {ellipsis}
- {count}
- """.format(
- css=css,
- title="" if info is None else "%s" % info['comment'],
- head='
'.join(
- head_template.format(column=c, comment=heading.attributes[c].comment,
- primary='primary' if c in query_expression.primary_key else 'nonprimary') for c in
- heading.names),
- ellipsis='
...
' if has_more else '',
- body='
'.join(
- ['\n'.join(['
%s
' % (tup[name] if name in tup.dtype.names else '=BLOB=')
- for name in heading.names])
- for tup in tuples]),
- count=('
Total: %d
' % len(rel)) if config['display.show_tuple_count'] else '')
diff --git a/datajoint/s3.py b/datajoint/s3.py
deleted file mode 100644
index e26cf1329..000000000
--- a/datajoint/s3.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""
-AWS S3 operations
-"""
-from io import BytesIO
-import minio # https://docs.minio.io/docs/python-client-api-reference
-import urllib3
-import warnings
-import uuid
-import logging
-from pathlib import Path
-from . import errors
-
-logger = logging.getLogger(__name__)
-
-
-class Folder:
- """
- A Folder instance manipulates a flat folder of objects within an S3-compatible object store
- """
- def __init__(self, endpoint, bucket, access_key, secret_key, *, secure=False,
- proxy_server=None, **_):
- # from https://docs.min.io/docs/python-client-api-reference
- self.client = minio.Minio(
- endpoint,
- access_key=access_key,
- secret_key=secret_key,
- secure=secure,
- http_client=(
- urllib3.ProxyManager(proxy_server,
- timeout=urllib3.Timeout.DEFAULT_TIMEOUT,
- cert_reqs="CERT_REQUIRED",
- retries=urllib3.Retry(total=5,
- backoff_factor=0.2,
- status_forcelist=[500, 502, 503,
- 504]))
- if proxy_server else None),
- )
- self.bucket = bucket
- if not self.client.bucket_exists(bucket):
- raise errors.BucketInaccessible('Inaccessible s3 bucket %s' % bucket)
-
- def put(self, name, buffer):
- logger.debug('put: {}:{}'.format(self.bucket, name))
- return self.client.put_object(
- self.bucket, str(name), BytesIO(buffer), length=len(buffer))
-
- def fput(self, local_file, name, metadata=None):
- logger.debug('fput: {} -> {}:{}'.format(self.bucket, local_file, name))
- return self.client.fput_object(
- self.bucket, str(name), str(local_file), metadata=metadata)
-
- def get(self, name):
- logger.debug('get: {}:{}'.format(self.bucket, name))
- try:
- return self.client.get_object(self.bucket, str(name)).data
- except minio.error.S3Error as e:
- if e.code == 'NoSuchKey':
- raise errors.MissingExternalFile('Missing s3 key %s' % name)
- else:
- raise e
-
- def fget(self, name, local_filepath):
- """get file from object name to local filepath"""
- logger.debug('fget: {}:{}'.format(self.bucket, name))
- name = str(name)
- stat = self.client.stat_object(self.bucket, name)
- meta = {k.lower().lstrip('x-amz-meta'): v for k, v in stat.metadata.items()}
- data = self.client.get_object(self.bucket, name)
- local_filepath = Path(local_filepath)
- local_filepath.parent.mkdir(parents=True, exist_ok=True)
- with local_filepath.open('wb') as f:
- for d in data.stream(1 << 16):
- f.write(d)
- if 'contents_hash' in meta:
- return uuid.UUID(meta['contents_hash'])
-
- def exists(self, name):
- logger.debug('exists: {}:{}'.format(self.bucket, name))
- try:
- self.client.stat_object(self.bucket, str(name))
- except minio.error.S3Error as e:
- if e.code == 'NoSuchKey':
- return False
- else:
- raise e
- return True
-
- def get_size(self, name):
- logger.debug('get_size: {}:{}'.format(self.bucket, name))
- try:
- return self.client.stat_object(self.bucket, str(name)).size
- except minio.error.S3Error as e:
- if e.code == 'NoSuchKey':
- raise errors.MissingExternalFile
- raise e
-
- def remove_object(self, name):
- logger.debug('remove_object: {}:{}'.format(self.bucket, name))
- try:
- self.client.remove_object(self.bucket, str(name))
- except minio.error.MinioException:
- raise errors.DataJointError('Failed to delete %s from s3 storage' % name)
diff --git a/datajoint/schemas.py b/datajoint/schemas.py
deleted file mode 100644
index ab2fc03af..000000000
--- a/datajoint/schemas.py
+++ /dev/null
@@ -1,415 +0,0 @@
-import warnings
-import logging
-import inspect
-import re
-import itertools
-import collections
-from .connection import conn
-from .diagram import Diagram, _get_tier
-from .settings import config
-from .errors import DataJointError, AccessError
-from .jobs import JobTable
-from .external import ExternalMapping
-from .heading import Heading
-from .utils import user_choice, to_camel_case
-from .user_tables import Part, Computed, Imported, Manual, Lookup
-from .table import lookup_class_name, Log, FreeTable
-import types
-
-logger = logging.getLogger(__name__)
-
-
-def ordered_dir(class_):
- """
- List (most) attributes of the class including inherited ones, similar to `dir` build-in function,
- but respects order of attribute declaration as much as possible.
- :param class_: class to list members for
- :return: a list of attributes declared in class_ and its superclasses
- """
- attr_list = list()
- for c in reversed(class_.mro()):
- attr_list.extend(e for e in c.__dict__ if e not in attr_list)
- return attr_list
-
-
-class Schema:
- """
- A schema object is a decorator for UserTable classes that binds them to their database.
- It also specifies the namespace `context` in which other UserTable classes are defined.
- """
-
- def __init__(self, schema_name=None, context=None, *, connection=None, create_schema=True,
- create_tables=True, add_objects=None):
- """
- Associate database schema `schema_name`. If the schema does not exist, attempt to
- create it on the server.
-
- If the schema_name is omitted, then schema.activate(..) must be called later
- to associate with the database.
-
- :param schema_name: the database schema to associate.
- :param context: dictionary for looking up foreign key references, leave None to use local context.
- :param connection: Connection object. Defaults to datajoint.conn().
- :param create_schema: When False, do not create the schema and raise an error if missing.
- :param create_tables: When False, do not create tables and raise errors when accessing missing tables.
- :param add_objects: a mapping with additional objects to make available to the context in which table classes
- are declared.
- """
- self._log = None
- self.connection = connection
- self.database = None
- self.context = context
- self.create_schema = create_schema
- self.create_tables = create_tables
- self._jobs = None
- self.external = ExternalMapping(self)
- self.add_objects = add_objects
- self.declare_list = []
- if schema_name:
- self.activate(schema_name)
-
- def is_activated(self):
- return self.database is not None
-
- def activate(self, schema_name=None, *, connection=None, create_schema=None,
- create_tables=None, add_objects=None):
- """
- Associate database schema `schema_name`. If the schema does not exist, attempt to
- create it on the server.
- :param schema_name: the database schema to associate.
- schema_name=None is used to assert that the schema has already been activated.
- :param connection: Connection object. Defaults to datajoint.conn().
- :param create_schema: If False, do not create the schema and raise an error if missing.
- :param create_tables: If False, do not create tables and raise errors when attempting
- to access missing tables.
- :param add_objects: a mapping with additional objects to make available to the context
- in which table classes are declared.
- """
- if schema_name is None:
- if self.exists:
- return
- raise DataJointError("Please provide a schema_name to activate the schema.")
- if self.database is not None and self.exists:
- if self.database == schema_name: # already activated
- return
- raise DataJointError(
- "The schema is already activated for schema {db}.".format(db=self.database))
- if connection is not None:
- self.connection = connection
- if self.connection is None:
- self.connection = conn()
- self.database = schema_name
- if create_schema is not None:
- self.create_schema = create_schema
- if create_tables is not None:
- self.create_tables = create_tables
- if add_objects:
- self.add_objects = add_objects
- if not self.exists:
- if not self.create_schema or not self.database:
- raise DataJointError(
- "Database `{name}` has not yet been declared. "
- "Set argument create_schema=True to create it.".format(name=schema_name))
- # create database
- logger.info("Creating schema `{name}`.".format(name=schema_name))
- try:
- self.connection.query("CREATE DATABASE `{name}`".format(name=schema_name))
- except AccessError:
- raise DataJointError(
- "Schema `{name}` does not exist and could not be created. "
- "Check permissions.".format(name=schema_name))
- else:
- self.log('created')
- self.connection.register(self)
-
- # decorate all tables already decorated
- for cls, context in self.declare_list:
- if self.add_objects:
- context = dict(context, **self.add_objects)
- self._decorate_master(cls, context)
-
- def _assert_exists(self, message=None):
- if not self.exists:
- raise DataJointError(
- message or "Schema `{db}` has not been created.".format(db=self.database))
-
- def __call__(self, cls, *, context=None):
- """
- Binds the supplied class to a schema. This is intended to be used as a decorator.
- :param cls: class to decorate.
- :param context: supplied when called from spawn_missing_classes
- """
- context = context or self.context or inspect.currentframe().f_back.f_locals
- if issubclass(cls, Part):
- raise DataJointError('The schema decorator should not be applied to Part relations')
- if self.is_activated():
- self._decorate_master(cls, context)
- else:
- self.declare_list.append((cls, context))
- return cls
-
- def _decorate_master(self, cls, context):
- """
- :param cls: the master class to process
- :param context: the class' declaration context
- """
- self._decorate_table(cls, context=dict(context, self=cls, **{cls.__name__: cls}))
- # Process part tables
- for part in ordered_dir(cls):
- if part[0].isupper():
- part = getattr(cls, part)
- if inspect.isclass(part) and issubclass(part, Part):
- part._master = cls
- # allow addressing master by name or keyword 'master'
- self._decorate_table(part, context=dict(
- context, master=cls, self=part, **{cls.__name__: cls}))
-
- def _decorate_table(self, table_class, context, assert_declared=False):
- """
- assign schema properties to the table class and declare the table
- """
- table_class.database = self.database
- table_class._connection = self.connection
- table_class._heading = Heading(table_info=dict(
- conn=self.connection,
- database=self.database,
- table_name=table_class.table_name,
- context=context))
- table_class._support = [table_class.full_table_name]
- table_class.declaration_context = context
-
- # instantiate the class, declare the table if not already
- instance = table_class()
- is_declared = instance.is_declared
- if not is_declared:
- if not self.create_tables or assert_declared:
- raise DataJointError('Table `%s` not declared' % instance.table_name)
- instance.declare(context)
- self.connection.dependencies.clear()
- is_declared = is_declared or instance.is_declared
-
- # add table definition to the doc string
- if isinstance(table_class.definition, str):
- table_class.__doc__ = (table_class.__doc__ or "") + "\nTable definition:\n\n" + table_class.definition
-
- # fill values in Lookup tables from their contents property
- if isinstance(instance, Lookup) and hasattr(instance, 'contents') and is_declared:
- contents = list(instance.contents)
- if len(contents) > len(instance):
- if instance.heading.has_autoincrement:
- warnings.warn(('Contents has changed but cannot be inserted because '
- '{table} has autoincrement.').format(
- table=instance.__class__.__name__))
- else:
- instance.insert(contents, skip_duplicates=True)
-
- @property
- def log(self):
- self._assert_exists()
- if self._log is None:
- self._log = Log(self.connection, self.database)
- return self._log
-
- def __repr__(self):
- return 'Schema `{name}`\n'.format(name=self.database)
-
- @property
- def size_on_disk(self):
- """
- :return: size of the entire schema in bytes
- """
- self._assert_exists()
- return int(self.connection.query(
- """
- SELECT SUM(data_length + index_length)
- FROM information_schema.tables WHERE table_schema='{db}'
- """.format(db=self.database)).fetchone()[0])
-
- def spawn_missing_classes(self, context=None):
- """
- Creates the appropriate python user relation classes from tables in the schema and places them
- in the context.
- :param context: alternative context to place the missing classes into, e.g. locals()
- """
- self._assert_exists()
- if context is None:
- if self.context is not None:
- context = self.context
- else:
- # if context is missing, use the calling namespace
- frame = inspect.currentframe().f_back
- context = frame.f_locals
- del frame
- tables = [
- row[0] for row in self.connection.query('SHOW TABLES in `%s`' % self.database)
- if lookup_class_name('`{db}`.`{tab}`'.format(db=self.database, tab=row[0]), context, 0) is None]
- master_classes = (Lookup, Manual, Imported, Computed)
- part_tables = []
- for table_name in tables:
- class_name = to_camel_case(table_name)
- if class_name not in context:
- try:
- cls = next(cls for cls in master_classes if re.fullmatch(cls.tier_regexp, table_name))
- except StopIteration:
- if re.fullmatch(Part.tier_regexp, table_name):
- part_tables.append(table_name)
- else:
- # declare and decorate master relation classes
- context[class_name] = self(type(class_name, (cls,), dict()), context=context)
-
- # attach parts to masters
- for table_name in part_tables:
- groups = re.fullmatch(Part.tier_regexp, table_name).groupdict()
- class_name = to_camel_case(groups['part'])
- try:
- master_class = context[to_camel_case(groups['master'])]
- except KeyError:
- raise DataJointError('The table %s does not follow DataJoint naming conventions' % table_name)
- part_class = type(class_name, (Part,), dict(definition=...))
- part_class._master = master_class
- self._decorate_table(part_class, context=context, assert_declared=True)
- setattr(master_class, class_name, part_class)
-
- def drop(self, force=False):
- """
- Drop the associated schema if it exists
- """
- if not self.exists:
- logger.info("Schema named `{database}` does not exist. Doing nothing.".format(
- database=self.database))
- elif (not config['safemode'] or
- force or
- user_choice("Proceed to delete entire schema `%s`?" % self.database, default='no') == 'yes'):
- logger.info("Dropping `{database}`.".format(database=self.database))
- try:
- self.connection.query("DROP DATABASE `{database}`".format(database=self.database))
- logger.info("Schema `{database}` was dropped successfully.".format(database=self.database))
- except AccessError:
- raise AccessError(
- "An attempt to drop schema `{database}` "
- "has failed. Check permissions.".format(database=self.database))
-
- @property
- def exists(self):
- """
- :return: true if the associated schema exists on the server
- """
- if self.database is None:
- raise DataJointError("Schema must be activated first.")
- return bool(self.connection.query(
- "SELECT schema_name "
- "FROM information_schema.schemata "
- "WHERE schema_name = '{database}'".format(database=self.database)).rowcount)
-
- @property
- def jobs(self):
- """
- schema.jobs provides a view of the job reservation table for the schema
- :return: jobs table
- """
- self._assert_exists()
- if self._jobs is None:
- self._jobs = JobTable(self.connection, self.database)
- return self._jobs
-
- @property
- def code(self):
- self._assert_exists()
- return self.save()
-
- def save(self, python_filename=None):
- """
- Generate the code for a module that recreates the schema.
- This method is in preparation for a future release and is not officially supported.
- :return: a string containing the body of a complete Python module defining this schema.
- """
- self._assert_exists()
- module_count = itertools.count()
- # add virtual modules for referenced modules with names vmod0, vmod1, ...
- module_lookup = collections.defaultdict(lambda: 'vmod' + str(next(module_count)))
- db = self.database
-
- def make_class_definition(table):
- tier = _get_tier(table).__name__
- class_name = table.split('.')[1].strip('`')
- indent = ''
- if tier == 'Part':
- class_name = class_name.split('__')[-1]
- indent += ' '
- class_name = to_camel_case(class_name)
-
- def replace(s):
- d, tabs = s.group(1), s.group(2)
- return ('' if d == db else (module_lookup[d]+'.')) + '.'.join(
- to_camel_case(tab) for tab in tabs.lstrip('__').split('__'))
-
- return ('' if tier == 'Part' else '\n@schema\n') + (
- '{indent}class {class_name}(dj.{tier}):\n'
- '{indent} definition = """\n'
- '{indent} {defi}"""').format(
- class_name=class_name,
- indent=indent,
- tier=tier,
- defi=re.sub(r'`([^`]+)`.`([^`]+)`', replace,
- FreeTable(self.connection, table).describe(printout=False)
- ).replace('\n', '\n ' + indent))
-
- diagram = Diagram(self)
- body = '\n\n'.join(make_class_definition(table) for table in diagram.topological_sort())
- python_code = '\n\n'.join((
- '"""This module was auto-generated by datajoint from an existing schema"""',
- "import datajoint as dj\n\nschema = dj.Schema('{db}')".format(db=db),
- '\n'.join("{module} = dj.VirtualModule('{module}', '{schema_name}')".format(module=v, schema_name=k)
- for k, v in module_lookup.items()), body))
- if python_filename is None:
- return python_code
- with open(python_filename, 'wt') as f:
- f.write(python_code)
-
- def list_tables(self):
- """
- Return a list of all tables in the schema except tables with ~ in first character such
- as ~logs and ~job
- :return: A list of table names from the database schema.
- """
- return [t for d, t in (full_t.replace('`', '').split('.')
- for full_t in Diagram(self).topological_sort())
- if d == self.database]
-
-
-class VirtualModule(types.ModuleType):
- """
- A virtual module imitates a Python module representing a DataJoint schema from table definitions in the database.
- It declares the schema objects and a class for each table.
- """
- def __init__(self, module_name, schema_name, *, create_schema=False,
- create_tables=False, connection=None, add_objects=None):
- """
- Creates a python module with the given name from the name of a schema on the server and
- automatically adds classes to it corresponding to the tables in the schema.
- :param module_name: displayed module name
- :param schema_name: name of the database in mysql
- :param create_schema: if True, create the schema on the database server
- :param create_tables: if True, module.schema can be used as the decorator for declaring new
- :param connection: a dj.Connection object to pass into the schema
- :param add_objects: additional objects to add to the module
- :return: the python module containing classes from the schema object and the table classes
- """
- super(VirtualModule, self).__init__(name=module_name)
- _schema = Schema(schema_name, create_schema=create_schema,
- create_tables=create_tables, connection=connection)
- if add_objects:
- self.__dict__.update(add_objects)
- self.__dict__['schema'] = _schema
- _schema.spawn_missing_classes(context=self.__dict__)
-
-
-def list_schemas(connection=None):
- """
- :param connection: a dj.Connection object
- :return: list of all accessible schemas on the server
- """
- return [r[0] for r in (connection or conn()).query(
- 'SELECT schema_name '
- 'FROM information_schema.schemata '
- 'WHERE schema_name <> "information_schema"')]
diff --git a/datajoint/settings.py b/datajoint/settings.py
deleted file mode 100644
index 75f265f87..000000000
--- a/datajoint/settings.py
+++ /dev/null
@@ -1,233 +0,0 @@
-"""
-Settings for DataJoint.
-"""
-from contextlib import contextmanager
-import json
-import os
-import pprint
-import logging
-import collections
-from enum import Enum
-from .errors import DataJointError
-
-LOCALCONFIG = 'dj_local_conf.json'
-GLOBALCONFIG = '.datajoint_config.json'
-# subfolding for external storage in filesystem.
-# 2, 2 means that file abcdef is stored as /ab/cd/abcdef
-DEFAULT_SUBFOLDING = (2, 2)
-
-validators = collections.defaultdict(lambda: lambda value: True)
-validators['database.port'] = lambda a: isinstance(a, int)
-
-Role = Enum('Role', 'manual lookup imported computed job')
-role_to_prefix = {
- Role.manual: '',
- Role.lookup: '#',
- Role.imported: '_',
- Role.computed: '__',
- Role.job: '~'
-}
-prefix_to_role = dict(zip(role_to_prefix.values(), role_to_prefix))
-
-default = dict({
- 'database.host': 'localhost',
- 'database.password': None,
- 'database.user': None,
- 'database.port': 3306,
- 'database.reconnect': True,
- 'connection.init_function': None,
- 'connection.charset': '', # pymysql uses '' as default
- 'loglevel': 'INFO',
- 'safemode': True,
- 'fetch_format': 'array',
- 'display.limit': 12,
- 'display.width': 14,
- 'display.show_tuple_count': True,
- 'database.use_tls': None,
- 'enable_python_native_blobs': True, # python-native/dj0 encoding support
-})
-
-logger = logging.getLogger(__name__)
-log_levels = {
- 'INFO': logging.INFO,
- 'WARNING': logging.WARNING,
- 'CRITICAL': logging.CRITICAL,
- 'DEBUG': logging.DEBUG,
- 'ERROR': logging.ERROR,
- None: logging.NOTSET
-}
-
-
-class Config(collections.abc.MutableMapping):
-
- instance = None
-
- def __init__(self, *args, **kwargs):
- if not Config.instance:
- Config.instance = Config.__Config(*args, **kwargs)
- else:
- Config.instance._conf.update(dict(*args, **kwargs))
-
- def __getattr__(self, name):
- return getattr(self.instance, name)
-
- def __getitem__(self, item):
- return self.instance.__getitem__(item)
-
- def __setitem__(self, item, value):
- self.instance.__setitem__(item, value)
-
- def __str__(self):
- return pprint.pformat(self.instance._conf, indent=4)
-
- def __repr__(self):
- return self.__str__()
-
- def __delitem__(self, key):
- del self.instance._conf[key]
-
- def __iter__(self):
- return iter(self.instance._conf)
-
- def __len__(self):
- return len(self.instance._conf)
-
- def save(self, filename, verbose=False):
- """
- Saves the settings in JSON format to the given file path.
- :param filename: filename of the local JSON settings file.
- :param verbose: report having saved the settings file
- """
- with open(filename, 'w') as fid:
- json.dump(self._conf, fid, indent=4)
- if verbose:
- print('Saved settings in ' + filename)
-
- def load(self, filename):
- """
- Updates the setting from config file in JSON format.
- :param filename: filename of the local JSON settings file. If None, the local config file is used.
- """
- if filename is None:
- filename = LOCALCONFIG
- with open(filename, 'r') as fid:
- self._conf.update(json.load(fid))
-
- def save_local(self, verbose=False):
- """
- saves the settings in the local config file
- """
- self.save(LOCALCONFIG, verbose)
-
- def save_global(self, verbose=False):
- """
- saves the settings in the global config file
- """
- self.save(os.path.expanduser(os.path.join('~', GLOBALCONFIG)), verbose)
-
- def get_store_spec(self, store):
- """
- find configuration of external stores for blobs and attachments
- """
- try:
- spec = self['stores'][store]
- except KeyError:
- raise DataJointError('Storage {store} is requested but not configured'.format(store=store))
-
- spec['subfolding'] = spec.get('subfolding', DEFAULT_SUBFOLDING)
- spec_keys = { # REQUIRED in uppercase and allowed in lowercase
- 'file': ('PROTOCOL', 'LOCATION', 'subfolding', 'stage'),
- 's3': ('PROTOCOL', 'ENDPOINT', 'BUCKET', 'ACCESS_KEY', 'SECRET_KEY', 'LOCATION',
- 'secure', 'subfolding', 'stage', 'proxy_server')}
-
- try:
- spec_keys = spec_keys[spec.get('protocol', '').lower()]
- except KeyError:
- raise DataJointError(
- 'Missing or invalid protocol in dj.config["stores"]["{store}"]'.format(store=store))
-
- # check that all required keys are present in spec
- try:
- raise DataJointError('dj.config["stores"]["{store}"] is missing "{k}"'.format(
- store=store, k=next(k.lower() for k in spec_keys if k.isupper() and k.lower() not in spec)))
- except StopIteration:
- pass
-
- # check that only allowed keys are present in spec
- try:
- raise DataJointError('Invalid key "{k}" in dj.config["stores"]["{store}"]'.format(
- store=store, k=next(k for k in spec if k.upper() not in spec_keys and k.lower() not in spec_keys)))
- except StopIteration:
- pass # no invalid keys
-
- return spec
-
- @contextmanager
- def __call__(self, **kwargs):
- """
- The config object can also be used in a with statement to change the state of the configuration
- temporarily. kwargs to the context manager are the keys into config, where '.' is replaced by a
- double underscore '__'. The context manager yields the changed config object.
-
- Example:
- >>> import datajoint as dj
- >>> with dj.config(safemode=False, database__host="localhost") as cfg:
- >>> # do dangerous stuff here
- """
-
- try:
- backup = self.instance
- self.instance = Config.__Config(self.instance._conf)
- new = {k.replace('__', '.'): v for k, v in kwargs.items()}
- self.instance._conf.update(new)
- yield self
- except:
- self.instance = backup
- raise
- else:
- self.instance = backup
-
- class __Config:
- """
- Stores datajoint settings. Behaves like a dictionary, but applies validator functions
- when certain keys are set.
-
- The default parameters are stored in datajoint.settings.default . If a local config file
- exists, the settings specified in this file override the default settings.
- """
-
- def __init__(self, *args, **kwargs):
- self._conf = dict(default)
- self._conf.update(dict(*args, **kwargs)) # use the free update to set keys
-
- def __getitem__(self, key):
- return self._conf[key]
-
- def __setitem__(self, key, value):
- logger.log(logging.INFO, u"Setting {0:s} to {1:s}".format(str(key), str(value)))
- if validators[key](value):
- self._conf[key] = value
- else:
- raise DataJointError(u'Validator for {0:s} did not pass'.format(key))
-
-
-# Load configuration from file
-config = Config()
-config_files = (os.path.expanduser(n) for n in (LOCALCONFIG, os.path.join('~', GLOBALCONFIG)))
-try:
- config_file = next(n for n in config_files if os.path.exists(n))
-except StopIteration:
- pass
-else:
- config.load(config_file)
-
-# override login credentials with environment variables
-mapping = {k: v for k, v in zip(
- ('database.host', 'database.user', 'database.password',
- 'external.aws_access_key_id', 'external.aws_secret_access_key',),
- map(os.getenv, ('DJ_HOST', 'DJ_USER', 'DJ_PASS',
- 'DJ_AWS_ACCESS_KEY_ID', 'DJ_AWS_SECRET_ACCESS_KEY',)))
- if v is not None}
-config.update(mapping)
-
-logger.setLevel(log_levels[config['loglevel']])
diff --git a/datajoint/table.py b/datajoint/table.py
deleted file mode 100644
index 587de6aef..000000000
--- a/datajoint/table.py
+++ /dev/null
@@ -1,876 +0,0 @@
-import collections
-import itertools
-import inspect
-import platform
-import numpy as np
-import pandas
-import logging
-import uuid
-import re
-import warnings
-from pathlib import Path
-from .settings import config
-from .declare import declare, alter
-from .condition import make_condition
-from .expression import QueryExpression
-from . import blob
-from .utils import user_choice, get_master
-from .heading import Heading
-from .errors import (DuplicateError, AccessError, DataJointError, UnknownAttributeError,
- IntegrityError)
-from .version import __version__ as version
-
-logger = logging.getLogger(__name__)
-
-foreign_key_error_regexp = re.compile(
- r"[\w\s:]*\((?P`[^`]+`.`[^`]+`), "
- r"CONSTRAINT (?P`[^`]+`) "
- r"(FOREIGN KEY \((?P[^)]+)\) "
- r"REFERENCES (?P`[^`]+`(\.`[^`]+`)?) \((?P[^)]+)\)[\s\w]+\))?")
-
-constraint_info_query = ' '.join("""
- SELECT
- COLUMN_NAME as fk_attrs,
- CONCAT('`', REFERENCED_TABLE_SCHEMA, '`.`', REFERENCED_TABLE_NAME, '`') as parent,
- REFERENCED_COLUMN_NAME as pk_attrs
- FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
- WHERE
- CONSTRAINT_NAME = %s AND TABLE_SCHEMA = %s AND TABLE_NAME = %s;
- """.split())
-
-
-class _RenameMap(tuple):
- """ for internal use """
- pass
-
-
-class Table(QueryExpression):
- """
- Table is an abstract class that represents a table in the schema.
- It implements insert and delete methods and inherits query functionality.
- To make it a concrete class, override the abstract properties specifying the connection,
- table name, database, and definition.
- """
-
- _table_name = None # must be defined in subclass
- _log_ = None # placeholder for the Log table object
-
- # These properties must be set by the schema decorator (schemas.py) at class level
- # or by FreeTable at instance level
- database = None
- declaration_context = None
-
- @property
- def table_name(self):
- return self._table_name
-
- @property
- def definition(self):
- raise NotImplementedError(
- 'Subclasses of Table must implement the `definition` property')
-
- def declare(self, context=None):
- """
- Declare the table in the schema based on self.definition.
- :param context: the context for foreign key resolution. If None, foreign keys are
- not allowed.
- """
- if self.connection.in_transaction:
- raise DataJointError('Cannot declare new tables inside a transaction, '
- 'e.g. from inside a populate/make call')
- sql, external_stores = declare(self.full_table_name, self.definition, context)
- sql = sql.format(database=self.database)
- try:
- # declare all external tables before declaring main table
- for store in external_stores:
- self.connection.schemas[self.database].external[store]
- self.connection.query(sql)
- except AccessError:
- # skip if no create privilege
- pass
- else:
- self._log('Declared ' + self.full_table_name)
-
- def alter(self, prompt=True, context=None):
- """
- Alter the table definition from self.definition
- """
- if self.connection.in_transaction:
- raise DataJointError(
- 'Cannot update table declaration inside a transaction, '
- 'e.g. from inside a populate/make call')
- if context is None:
- frame = inspect.currentframe().f_back
- context = dict(frame.f_globals, **frame.f_locals)
- del frame
- old_definition = self.describe(context=context, printout=False)
- sql, external_stores = alter(self.definition, old_definition, context)
- if not sql:
- if prompt:
- print('Nothing to alter.')
- else:
- sql = "ALTER TABLE {tab}\n\t".format(tab=self.full_table_name) + ",\n\t".join(sql)
- if not prompt or user_choice(sql + '\n\nExecute?') == 'yes':
- try:
- # declare all external tables before declaring main table
- for store in external_stores:
- self.connection.schemas[self.database].external[store]
- self.connection.query(sql)
- except AccessError:
- # skip if no create privilege
- pass
- else:
- # reset heading
- self.__class__._heading = Heading(table_info=self.heading.table_info)
- if prompt:
- print('Table altered')
- self._log('Altered ' + self.full_table_name)
-
- def from_clause(self):
- """
- :return: the FROM clause of SQL SELECT statements.
- """
- return self.full_table_name
-
- def get_select_fields(self, select_fields=None):
- """
- :return: the selected attributes from the SQL SELECT statement.
- """
- return '*' if select_fields is None else self.heading.project(select_fields).as_sql
-
- def parents(self, primary=None, as_objects=False, foreign_key_info=False):
- """
- :param primary: if None, then all parents are returned. If True, then only foreign keys composed of
- primary key attributes are considered. If False, return foreign keys including at least one
- secondary attribute.
- :param as_objects: if False, return table names. If True, return table objects.
- :param foreign_key_info: if True, each element in result also includes foreign key info.
- :return: list of parents as table names or table objects
- with (optional) foreign key information.
- """
- get_edge = self.connection.dependencies.parents
- nodes = [next(iter(get_edge(name).items())) if name.isdigit() else (name, props)
- for name, props in get_edge(self.full_table_name, primary).items()]
- if as_objects:
- nodes = [(FreeTable(self.connection, name), props) for name, props in nodes]
- if not foreign_key_info:
- nodes = [name for name, props in nodes]
- return nodes
-
- def children(self, primary=None, as_objects=False, foreign_key_info=False):
- """
- :param primary: if None, then all children are returned. If True, then only foreign keys composed of
- primary key attributes are considered. If False, return foreign keys including at least one
- secondary attribute.
- :param as_objects: if False, return table names. If True, return table objects.
- :param foreign_key_info: if True, each element in result also includes foreign key info.
- :return: list of children as table names or table objects
- with (optional) foreign key information.
- """
- get_edge = self.connection.dependencies.children
- nodes = [next(iter(get_edge(name).items())) if name.isdigit() else (name, props)
- for name, props in get_edge(self.full_table_name, primary).items()]
- if as_objects:
- nodes = [(FreeTable(self.connection, name), props) for name, props in nodes]
- if not foreign_key_info:
- nodes = [name for name, props in nodes]
- return nodes
-
- def descendants(self, as_objects=False):
- """
- :param as_objects: False - a list of table names; True - a list of table objects.
- :return: list of tables descendants in topological order.
- """
- return [FreeTable(self.connection, node) if as_objects else node
- for node in self.connection.dependencies.descendants(self.full_table_name) if not node.isdigit()]
-
- def ancestors(self, as_objects=False):
- """
- :param as_objects: False - a list of table names; True - a list of table objects.
- :return: list of tables ancestors in topological order.
- """
- return [FreeTable(self.connection, node) if as_objects else node
- for node in self.connection.dependencies.ancestors(self.full_table_name) if not node.isdigit()]
-
- def parts(self, as_objects=False):
- """
- return part tables either as entries in a dict with foreign key informaiton or a list of objects
- :param as_objects: if False (default), the output is a dict describing the foreign keys. If True, return table objects.
- """
- nodes = [node for node in self.connection.dependencies.nodes
- if not node.isdigit() and node.startswith(self.full_table_name[:-1] + '__')]
- return [FreeTable(self.connection, c) for c in nodes] if as_objects else nodes
-
- @property
- def is_declared(self):
- """
- :return: True is the table is declared in the schema.
- """
- return self.connection.query(
- 'SHOW TABLES in `{database}` LIKE "{table_name}"'.format(
- database=self.database, table_name=self.table_name)).rowcount > 0
-
- @property
- def full_table_name(self):
- """
- :return: full table name in the schema
- """
- return r"`{0:s}`.`{1:s}`".format(self.database, self.table_name)
-
- @property
- def _log(self):
- if self._log_ is None:
- self._log_ = Log(self.connection, database=self.database, skip_logging=self.table_name.startswith('~'))
- return self._log_
-
- @property
- def external(self):
- return self.connection.schemas[self.database].external
-
- def update1(self, row):
- """
- update1 updates one existing entry in the table.
- Caution: In DataJoint the primary modes for data manipulation is to ``insert`` and ``delete``
- entire records since referential integrity works on the level of records, not fields.
- Therefore, updates are reserved for corrective operations outside of main workflow.
- Use UPDATE methods sparingly with full awareness of potential violations of assumptions.
-
- :param row: a ``dict`` containing the primary key values and the attributes to update.
- Setting an attribute value to None will reset it to the default value (if any)
- The primary key attributes must always be provided.
- Examples:
- >>> table.update1({'id': 1, 'value': 3}) # update value in record with id=1
- >>> table.update1({'id': 1, 'value': None}) # reset value to default
- """
- # argument validations
- if not isinstance(row, collections.abc.Mapping):
- raise DataJointError('The argument of update1 must be dict-like.')
- if not set(row).issuperset(self.primary_key):
- raise DataJointError('The argument of update1 must supply all primary key values.')
- try:
- raise DataJointError('Attribute `%s` not found.' %
- next(k for k in row if k not in self.heading.names))
- except StopIteration:
- pass # ok
- if len(self.restriction):
- raise DataJointError('Update cannot be applied to a restricted table.')
- key = {k: row[k] for k in self.primary_key}
- if len(self & key) != 1:
- raise DataJointError('Update entry must exist.')
- # UPDATE query
- row = [self.__make_placeholder(k, v) for k, v in row.items()
- if k not in self.primary_key]
- query = "UPDATE {table} SET {assignments} WHERE {where}".format(
- table=self.full_table_name,
- assignments=",".join('`%s`=%s' % r[:2] for r in row),
- where=make_condition(self, key, set()))
- self.connection.query(query, args=list(r[2] for r in row if r[2] is not None))
-
- def insert1(self, row, **kwargs):
- """
- Insert one data record into the table. For ``kwargs``, see ``insert()``.
-
- :param row: a numpy record, a dict-like object, or an ordered sequence to be inserted
- as one row.
- """
- self.insert((row,), **kwargs)
-
- def insert(self, rows, replace=False, skip_duplicates=False, ignore_extra_fields=False,
- allow_direct_insert=None):
- """
- Insert a collection of rows.
- :param rows: An iterable where an element is a numpy record, a dict-like object, a
- pandas.DataFrame, a sequence, or a query expression with the same heading as self.
- :param replace: If True, replaces the existing tuple.
- :param skip_duplicates: If True, silently skip duplicate inserts.
- :param ignore_extra_fields: If False, fields that are not in the heading raise error.
- :param allow_direct_insert: applies only in auto-populated tables.
- If False (default), insert are allowed only from inside the make callback.
- Example::
- >>> relation.insert([
- >>> dict(subject_id=7, species="mouse", date_of_birth="2014-09-01"),
- >>> dict(subject_id=8, species="mouse", date_of_birth="2014-09-02")])
- """
- if isinstance(rows, pandas.DataFrame):
- # drop 'extra' synthetic index for 1-field index case -
- # frames with more advanced indices should be prepared by user.
- rows = rows.reset_index(
- drop=len(rows.index.names) == 1 and not rows.index.names[0]
- ).to_records(index=False)
-
- # prohibit direct inserts into auto-populated tables
- if not allow_direct_insert and not getattr(self, '_allow_insert', True):
- raise DataJointError(
- 'Inserts into an auto-populated table can only be done inside '
- 'its make method during a populate call.'
- ' To override, set keyword argument allow_direct_insert=True.')
-
- if inspect.isclass(rows) and issubclass(rows, QueryExpression):
- rows = rows() # instantiate if a class
- if isinstance(rows, QueryExpression):
- # insert from select
- if not ignore_extra_fields:
- try:
- raise DataJointError(
- "Attribute %s not found. To ignore extra attributes in insert, "
- "set ignore_extra_fields=True." % next(
- name for name in rows.heading if name not in self.heading))
- except StopIteration:
- pass
- fields = list(name for name in rows.heading if name in self.heading)
- query = '{command} INTO {table} ({fields}) {select}{duplicate}'.format(
- command='REPLACE' if replace else 'INSERT',
- fields='`' + '`,`'.join(fields) + '`',
- table=self.full_table_name,
- select=rows.make_sql(fields),
- duplicate=(' ON DUPLICATE KEY UPDATE `{pk}`={table}.`{pk}`'.format(
- table=self.full_table_name, pk=self.primary_key[0])
- if skip_duplicates else ''))
- self.connection.query(query)
- return
-
- field_list = [] # collects the field list from first row (passed by reference)
- rows = list(self.__make_row_to_insert(row, field_list, ignore_extra_fields) for row in rows)
- if rows:
- try:
- query = "{command} INTO {destination}(`{fields}`) VALUES {placeholders}{duplicate}".format(
- command='REPLACE' if replace else 'INSERT',
- destination=self.from_clause(),
- fields='`,`'.join(field_list),
- placeholders=','.join('(' + ','.join(row['placeholders']) + ')' for row in rows),
- duplicate=(' ON DUPLICATE KEY UPDATE `{pk}`=`{pk}`'.format(pk=self.primary_key[0])
- if skip_duplicates else ''))
- self.connection.query(query, args=list(
- itertools.chain.from_iterable(
- (v for v in r['values'] if v is not None) for r in rows)))
- except UnknownAttributeError as err:
- raise err.suggest(
- 'To ignore extra fields in insert, set ignore_extra_fields=True')
- except DuplicateError as err:
- raise err.suggest(
- 'To ignore duplicate entries in insert, set skip_duplicates=True')
-
- def delete_quick(self, get_count=False):
- """
- Deletes the table without cascading and without user prompt.
- If this table has populated dependent tables, this will fail.
- """
- query = 'DELETE FROM ' + self.full_table_name + self.where_clause()
- self.connection.query(query)
- count = self.connection.query("SELECT ROW_COUNT()").fetchone()[0] if get_count else None
- self._log(query[:255])
- return count
-
- def delete(self, transaction=True, safemode=None, force_parts=False):
- """
- Deletes the contents of the table and its dependent tables, recursively.
-
- :param transaction: if True, use the entire delete becomes an atomic transaction.
- This is the default and recommended behavior. Set to False if this delete is nested
- within another transaction.
- :param safemode: If True, prohibit nested transactions and prompt to confirm. Default
- is dj.config['safemode'].
- :param force_parts: Delete from parts even when not deleting from their masters.
- :return: number of deleted rows (excluding those from dependent tables)
- """
- deleted = set()
-
- def cascade(table):
- """service function to perform cascading deletes recursively."""
- max_attempts = 50
- for _ in range(max_attempts):
- try:
- delete_count = table.delete_quick(get_count=True)
- except IntegrityError as error:
- match = foreign_key_error_regexp.match(error.args[0]).groupdict()
- if "`.`" not in match['child']: # if schema name missing, use table
- match['child'] = '{}.{}'.format(table.full_table_name.split(".")[0],
- match['child'])
- if match['pk_attrs'] is not None: # fully matched, adjusting the keys
- match['fk_attrs'] = [k.strip('`') for k in match['fk_attrs'].split(',')]
- match['pk_attrs'] = [k.strip('`') for k in match['pk_attrs'].split(',')]
- else: # only partially matched, querying with constraint to determine keys
- match['fk_attrs'], match['parent'], match['pk_attrs'] = list(map(
- list, zip(*table.connection.query(constraint_info_query, args=(
- match['name'].strip('`'),
- *[_.strip('`') for _ in match['child'].split('`.`')]
- )).fetchall())))
- match['parent'] = match['parent'][0]
-
- # Restrict child by table if
- # 1. if table's restriction attributes are not in child's primary key
- # 2. if child renames any attributes
- # Otherwise restrict child by table's restriction.
- child = FreeTable(table.connection, match['child'])
- if set(table.restriction_attributes) <= set(child.primary_key) and \
- match['fk_attrs'] == match['pk_attrs']:
- child._restriction = table._restriction
- elif match['fk_attrs'] != match['pk_attrs']:
- child &= table.proj(**dict(zip(match['fk_attrs'],
- match['pk_attrs'])))
- else:
- child &= table.proj()
- cascade(child)
- else:
- deleted.add(table.full_table_name)
- print("Deleting {count} rows from {table}".format(
- count=delete_count, table=table.full_table_name))
- break
- else:
- raise DataJointError('Exceeded maximum number of delete attempts.')
- return delete_count
-
- safemode = config['safemode'] if safemode is None else safemode
-
- # Start transaction
- if transaction:
- if not self.connection.in_transaction:
- self.connection.start_transaction()
- else:
- if not safemode:
- transaction = False
- else:
- raise DataJointError(
- "Delete cannot use a transaction within an ongoing transaction. "
- "Set transaction=False or safemode=False).")
-
- # Cascading delete
- try:
- delete_count = cascade(self)
- except:
- if transaction:
- self.connection.cancel_transaction()
- raise
-
- if not force_parts:
- # Avoid deleting from child before master (See issue #151)
- for part in deleted:
- master = get_master(part)
- if master and master not in deleted:
- if transaction:
- self.connection.cancel_transaction()
- raise DataJointError(
- 'Attempt to delete part table {part} before deleting from '
- 'its master {master} first.'.format(part=part, master=master))
-
- # Confirm and commit
- if delete_count == 0:
- if safemode:
- print('Nothing to delete.')
- if transaction:
- self.connection.cancel_transaction()
- else:
- if not safemode or user_choice("Commit deletes?", default='no') == 'yes':
- if transaction:
- self.connection.commit_transaction()
- if safemode:
- print('Deletes committed.')
- else:
- if transaction:
- self.connection.cancel_transaction()
- if safemode:
- print('Deletes cancelled')
- return delete_count
-
- def drop_quick(self):
- """
- Drops the table associated with this relation without cascading and without user prompt.
- If the table has any dependent table(s), this call will fail with an error.
- """
- if self.is_declared:
- query = 'DROP TABLE %s' % self.full_table_name
- self.connection.query(query)
- logger.info("Dropped table %s" % self.full_table_name)
- self._log(query[:255])
- else:
- logger.info("Nothing to drop: table %s is not declared" % self.full_table_name)
-
- def drop(self):
- """
- Drop the table and all tables that reference it, recursively.
- User is prompted for confirmation if config['safemode'] is set to True.
- """
- if self.restriction:
- raise DataJointError('A table with an applied restriction cannot be dropped.'
- ' Call drop() on the unrestricted Table.')
- self.connection.dependencies.load()
- do_drop = True
- tables = [table for table in self.connection.dependencies.descendants(
- self.full_table_name) if not table.isdigit()]
-
- # avoid dropping part tables without their masters: See issue #374
- for part in tables:
- master = get_master(part)
- if master and master not in tables:
- raise DataJointError(
- 'Attempt to drop part table {part} before dropping '
- 'its master. Drop {master} first.'.format(part=part, master=master))
-
- if config['safemode']:
- for table in tables:
- print(table, '(%d tuples)' % len(FreeTable(self.connection, table)))
- do_drop = user_choice("Proceed?", default='no') == 'yes'
- if do_drop:
- for table in reversed(tables):
- FreeTable(self.connection, table).drop_quick()
- print('Tables dropped. Restart kernel.')
-
- @property
- def size_on_disk(self):
- """
- :return: size of data and indices in bytes on the storage device
- """
- ret = self.connection.query(
- 'SHOW TABLE STATUS FROM `{database}` WHERE NAME="{table}"'.format(
- database=self.database, table=self.table_name), as_dict=True).fetchone()
- return ret['Data_length'] + ret['Index_length']
-
- def show_definition(self):
- raise AttributeError('show_definition is deprecated. Use the describe method instead.')
-
- def describe(self, context=None, printout=True):
- """
- :return: the definition string for the relation using DataJoint DDL.
- """
- if context is None:
- frame = inspect.currentframe().f_back
- context = dict(frame.f_globals, **frame.f_locals)
- del frame
- if self.full_table_name not in self.connection.dependencies:
- self.connection.dependencies.load()
- parents = self.parents(foreign_key_info=True)
- in_key = True
- definition = ('# ' + self.heading.table_status['comment'] + '\n'
- if self.heading.table_status['comment'] else '')
- attributes_thus_far = set()
- attributes_declared = set()
- indexes = self.heading.indexes.copy()
- for attr in self.heading.attributes.values():
- if in_key and not attr.in_key:
- definition += '---\n'
- in_key = False
- attributes_thus_far.add(attr.name)
- do_include = True
- for parent_name, fk_props in parents:
- if attr.name in fk_props['attr_map']:
- do_include = False
- if attributes_thus_far.issuperset(fk_props['attr_map']):
- # foreign key properties
- try:
- index_props = indexes.pop(tuple(fk_props['attr_map']))
- except KeyError:
- index_props = ''
- else:
- index_props = [k for k, v in index_props.items() if v]
- index_props = ' [{}]'.format(', '.join(index_props)) if index_props else ''
-
- if not fk_props['aliased']:
- # simple foreign key
- definition += '->{props} {class_name}\n'.format(
- props=index_props,
- class_name=lookup_class_name(parent_name, context) or parent_name)
- else:
- # projected foreign key
- definition += '->{props} {class_name}.proj({proj_list})\n'.format(
- props=index_props,
- class_name=lookup_class_name(parent_name, context) or parent_name,
- proj_list=','.join(
- '{}="{}"'.format(attr, ref)
- for attr, ref in fk_props['attr_map'].items() if ref != attr))
- attributes_declared.update(fk_props['attr_map'])
- if do_include:
- attributes_declared.add(attr.name)
- definition += '%-20s : %-28s %s\n' % (
- attr.name if attr.default is None else '%s=%s' % (attr.name, attr.default),
- '%s%s' % (attr.type, ' auto_increment' if attr.autoincrement else ''),
- '# ' + attr.comment if attr.comment else '')
- # add remaining indexes
- for k, v in indexes.items():
- definition += '{unique}INDEX ({attrs})\n'.format(
- unique='UNIQUE ' if v['unique'] else '',
- attrs=', '.join(k))
- if printout:
- print(definition)
- return definition
-
- def _update(self, attrname, value=None):
- """
- This is a deprecated function to be removed in datajoint 0.14.
- Use ``.update1`` instead.
-
- Updates a field in one existing tuple. self must be restricted to exactly one entry.
- In DataJoint the principal way of updating data is to delete and re-insert the
- entire record and updates are reserved for corrective actions.
- This is because referential integrity is observed on the level of entire
- records rather than individual attributes.
-
- Safety constraints:
- 1. self must be restricted to exactly one tuple
- 2. the update attribute must not be in primary key
-
- Example:
- >>> (v2p.Mice() & key)._update('mouse_dob', '2011-01-01')
- >>> (v2p.Mice() & key)._update( 'lens') # set the value to NULL
- """
- warnings.warn(
- '`_update` is a deprecated function to be removed in datajoint 0.14. '
- 'Use `.update1` instead.')
- if len(self) != 1:
- raise DataJointError('Update is only allowed on one tuple at a time')
- if attrname not in self.heading:
- raise DataJointError('Invalid attribute name')
- if attrname in self.heading.primary_key:
- raise DataJointError('Cannot update a key value.')
-
- attr = self.heading[attrname]
-
- if attr.is_blob:
- value = blob.pack(value)
- placeholder = '%s'
- elif attr.numeric:
- if value is None or np.isnan(float(value)): # nans are turned into NULLs
- placeholder = 'NULL'
- value = None
- else:
- placeholder = '%s'
- value = str(int(value) if isinstance(value, bool) else value)
- else:
- placeholder = '%s' if value is not None else 'NULL'
- command = "UPDATE {full_table_name} SET `{attrname}`={placeholder} {where_clause}".format(
- full_table_name=self.from_clause(),
- attrname=attrname,
- placeholder=placeholder,
- where_clause=self.where_clause())
- self.connection.query(command, args=(value, ) if value is not None else ())
-
- # --- private helper functions ----
- def __make_placeholder(self, name, value, ignore_extra_fields=False):
- """
- For a given attribute `name` with `value`, return its processed value or value placeholder
- as a string to be included in the query and the value, if any, to be submitted for
- processing by mysql API.
- :param name: name of attribute to be inserted
- :param value: value of attribute to be inserted
- """
- if ignore_extra_fields and name not in self.heading:
- return None
- attr = self.heading[name]
- if attr.adapter:
- value = attr.adapter.put(value)
- if value is None or (attr.numeric and (value == '' or np.isnan(float(value)))):
- # set default value
- placeholder, value = 'DEFAULT', None
- else: # not NULL
- placeholder = '%s'
- if attr.uuid:
- if not isinstance(value, uuid.UUID):
- try:
- value = uuid.UUID(value)
- except (AttributeError, ValueError):
- raise DataJointError(
- 'badly formed UUID value {v} for attribute `{n}`'.format(v=value,
- n=name))
- value = value.bytes
- elif attr.is_blob:
- value = blob.pack(value)
- value = self.external[attr.store].put(value).bytes if attr.is_external else value
- elif attr.is_attachment:
- attachment_path = Path(value)
- if attr.is_external:
- # value is hash of contents
- value = self.external[attr.store].upload_attachment(attachment_path).bytes
- else:
- # value is filename + contents
- value = str.encode(attachment_path.name) + b'\0' + attachment_path.read_bytes()
- elif attr.is_filepath:
- value = self.external[attr.store].upload_filepath(value).bytes
- elif attr.numeric:
- value = str(int(value) if isinstance(value, bool) else value)
- return name, placeholder, value
-
- def __make_row_to_insert(self, row, field_list, ignore_extra_fields):
- """
- Helper function for insert and update
- :param row: A tuple to insert
- :return: a dict with fields 'names', 'placeholders', 'values'
- """
-
- def check_fields(fields):
- """
- Validates that all items in `fields` are valid attributes in the heading
- :param fields: field names of a tuple
- """
- if not field_list:
- if not ignore_extra_fields:
- for field in fields:
- if field not in self.heading:
- raise KeyError(u'`{0:s}` is not in the table heading'.format(field))
- elif set(field_list) != set(fields).intersection(self.heading.names):
- raise DataJointError('Attempt to insert rows with different fields.')
-
- if isinstance(row, np.void): # np.array
- check_fields(row.dtype.fields)
- attributes = [self.__make_placeholder(name, row[name], ignore_extra_fields)
- for name in self.heading if name in row.dtype.fields]
- elif isinstance(row, collections.abc.Mapping): # dict-based
- check_fields(row)
- attributes = [self.__make_placeholder(name, row[name], ignore_extra_fields)
- for name in self.heading if name in row]
- else: # positional
- try:
- if len(row) != len(self.heading):
- raise DataJointError(
- 'Invalid insert argument. Incorrect number of attributes: '
- '{given} given; {expected} expected'.format(
- given=len(row), expected=len(self.heading)))
- except TypeError:
- raise DataJointError('Datatype %s cannot be inserted' % type(row))
- else:
- attributes = [self.__make_placeholder(name, value, ignore_extra_fields)
- for name, value in zip(self.heading, row)]
- if ignore_extra_fields:
- attributes = [a for a in attributes if a is not None]
-
- assert len(attributes), 'Empty tuple'
- row_to_insert = dict(zip(('names', 'placeholders', 'values'), zip(*attributes)))
- if not field_list:
- # first row sets the composition of the field list
- field_list.extend(row_to_insert['names'])
- else:
- # reorder attributes in row_to_insert to match field_list
- order = list(row_to_insert['names'].index(field) for field in field_list)
- row_to_insert['names'] = list(row_to_insert['names'][i] for i in order)
- row_to_insert['placeholders'] = list(row_to_insert['placeholders'][i] for i in order)
- row_to_insert['values'] = list(row_to_insert['values'][i] for i in order)
- return row_to_insert
-
-
-def lookup_class_name(name, context, depth=3):
- """
- given a table name in the form `schema_name`.`table_name`, find its class in the context.
- :param name: `schema_name`.`table_name`
- :param context: dictionary representing the namespace
- :param depth: search depth into imported modules, helps avoid infinite recursion.
- :return: class name found in the context or None if not found
- """
- # breadth-first search
- nodes = [dict(context=context, context_name='', depth=depth)]
- while nodes:
- node = nodes.pop(0)
- for member_name, member in node['context'].items():
- if not member_name.startswith('_'): # skip IPython's implicit variables
- if inspect.isclass(member) and issubclass(member, Table):
- if member.full_table_name == name: # found it!
- return '.'.join([node['context_name'], member_name]).lstrip('.')
- try: # look for part tables
- parts = member.__dict__
- except AttributeError:
- pass # not a UserTable -- cannot have part tables.
- else:
- for part in (getattr(member, p) for p in parts
- if p[0].isupper() and hasattr(member, p)):
- if inspect.isclass(part) and issubclass(part, Table) and \
- part.full_table_name == name:
- return '.'.join([node['context_name'],
- member_name, part.__name__]).lstrip('.')
- elif node['depth'] > 0 and inspect.ismodule(member) and \
- member.__name__ != 'datajoint':
- try:
- nodes.append(
- dict(context=dict(inspect.getmembers(member)),
- context_name=node['context_name'] + '.' + member_name,
- depth=node['depth']-1))
- except ImportError:
- pass # could not import, so do not attempt
- return None
-
-
-class FreeTable(Table):
- """
- A base relation without a dedicated class. Each instance is associated with a table
- specified by full_table_name.
- :param conn: a dj.Connection object
- :param full_table_name: in format `database`.`table_name`
- """
- def __init__(self, conn, full_table_name):
- self.database, self._table_name = (s.strip('`') for s in full_table_name.split('.'))
- self._connection = conn
- self._support = [full_table_name]
- self._heading = Heading(table_info=dict(
- conn=conn,
- database=self.database,
- table_name=self.table_name,
- context=None))
-
- def __repr__(self):
- return "FreeTable(`%s`.`%s`)\n" % (self.database, self._table_name) + super().__repr__()
-
-
-class Log(Table):
- """
- The log table for each schema.
- Instances are callable. Calls log the time and identifying information along with the event.
- :param skip_logging: if True, then log entry is skipped by default. See __call__
- """
-
- _table_name = '~log'
-
- def __init__(self, conn, database, skip_logging=False):
- self.database = database
- self.skip_logging = skip_logging
- self._connection = conn
- self._heading = Heading(table_info=dict(
- conn=conn,
- database=database,
- table_name=self.table_name,
- context=None
- ))
- self._support = [self.full_table_name]
-
- self._definition = """ # event logging table for `{database}`
- id :int unsigned auto_increment # event order id
- ---
- timestamp = CURRENT_TIMESTAMP : timestamp # event timestamp
- version :varchar(12) # datajoint version
- user :varchar(255) # user@host
- host="" :varchar(255) # system hostname
- event="" :varchar(255) # event message
- """.format(database=database)
-
- super().__init__()
-
- if not self.is_declared:
- self.declare()
- self.connection.dependencies.clear()
- self._user = self.connection.get_user()
-
- @property
- def definition(self):
- return self._definition
-
- def __call__(self, event, skip_logging=None):
- """
- :param event: string to write into the log table
- :param skip_logging: If True then do not log. If None, then use self.skip_logging
- """
- skip_logging = self.skip_logging if skip_logging is None else skip_logging
- if not skip_logging:
- try:
- self.insert1(dict(
- user=self._user,
- version=version + 'py',
- host=platform.uname().node,
- event=event), skip_duplicates=True, ignore_extra_fields=True)
- except DataJointError:
- logger.info('could not log event in table ~log')
-
- def delete(self):
- """
- bypass interactive prompts and cascading dependencies
- :return: number of deleted items
- """
- return self.delete_quick(get_count=True)
-
- def drop(self):
- """bypass interactive prompts and cascading dependencies"""
- self.drop_quick()
diff --git a/datajoint/user_tables.py b/datajoint/user_tables.py
deleted file mode 100644
index 76cea5fbf..000000000
--- a/datajoint/user_tables.py
+++ /dev/null
@@ -1,188 +0,0 @@
-"""
-Hosts the table tiers, user relations should be derived from.
-"""
-
-from .table import Table
-from .autopopulate import AutoPopulate
-from .utils import from_camel_case, ClassProperty
-from .errors import DataJointError
-
-_base_regexp = r'[a-z][a-z0-9]*(_[a-z][a-z0-9]*)*'
-
-# attributes that trigger instantiation of user classes
-
-
-supported_class_attrs = {
- 'key_source', 'describe', 'alter', 'heading', 'populate', 'progress', 'primary_key',
- 'proj', 'aggr', 'join', 'fetch', 'fetch1', 'head', 'tail',
- 'descendants', 'ancestors', 'parts', 'parents', 'children',
- 'insert', 'insert1', 'update1', 'drop', 'drop_quick', 'delete', 'delete_quick'}
-
-
-class TableMeta(type):
- """
- TableMeta subclasses allow applying some instance methods and properties directly
- at class level. For example, this allows Table.fetch() instead of Table().fetch().
- """
- def __getattribute__(cls, name):
- # trigger instantiation for supported class attrs
- return (cls().__getattribute__(name) if name in supported_class_attrs
- else super().__getattribute__(name))
-
- def __and__(cls, arg):
- return cls() & arg
-
- def __xor__(cls, arg):
- return cls() ^ arg
-
- def __sub__(cls, arg):
- return cls() - arg
-
- def __neg__(cls):
- return -cls()
-
- def __mul__(cls, arg):
- return cls() * arg
-
- def __matmul__(cls, arg):
- return cls() @ arg
-
- def __add__(cls, arg):
- return cls() + arg
-
- def __iter__(cls):
- return iter(cls())
-
-
-class UserTable(Table, metaclass=TableMeta):
- """
- A subclass of UserTable is a dedicated class interfacing a base relation.
- UserTable is initialized by the decorator generated by schema().
- """
- # set by @schema
- _connection = None
- _heading = None
- _support = None
-
- # set by subclass
- tier_regexp = None
- _prefix = None
-
- @property
- def definition(self):
- """
- :return: a string containing the table definition using the DataJoint DDL.
- """
- raise NotImplementedError(
- 'Subclasses of Table must implement the property "definition"')
-
- @ClassProperty
- def connection(cls):
- return cls._connection
-
- @ClassProperty
- def table_name(cls):
- """
- :return: the table name of the table formatted for mysql.
- """
- if cls._prefix is None:
- raise AttributeError('Class prefix is not defined!')
- return cls._prefix + from_camel_case(cls.__name__)
-
- @ClassProperty
- def full_table_name(cls):
- if cls not in {Manual, Imported, Lookup, Computed, Part, UserTable}:
- # for derived classes only
- if cls.database is None:
- raise DataJointError(
- 'Class %s is not properly declared (schema decorator not applied?)' %
- cls.__name__)
- return r"`{0:s}`.`{1:s}`".format(cls.database, cls.table_name)
-
-
-class Manual(UserTable):
- """
- Inherit from this class if the table's values are entered manually.
- """
- _prefix = r''
- tier_regexp = r'(?P' + _prefix + _base_regexp + ')'
-
-
-class Lookup(UserTable):
- """
- Inherit from this class if the table's values are for lookup. This is
- currently equivalent to defining the table as Manual and serves semantic
- purposes only.
- """
- _prefix = '#'
- tier_regexp = r'(?P' + _prefix + _base_regexp.replace('TIER', 'lookup') + ')'
-
-
-class Imported(UserTable, AutoPopulate):
- """
- Inherit from this class if the table's values are imported from external data sources.
- The inherited class must at least provide the function `_make_tuples`.
- """
- _prefix = '_'
- tier_regexp = r'(?P' + _prefix + _base_regexp + ')'
-
-
-class Computed(UserTable, AutoPopulate):
- """
- Inherit from this class if the table's values are computed from other relations in the schema.
- The inherited class must at least provide the function `_make_tuples`.
- """
- _prefix = '__'
- tier_regexp = r'(?P' + _prefix + _base_regexp + ')'
-
-
-class Part(UserTable):
- """
- Inherit from this class if the table's values are details of an entry in another relation
- and if this table is populated by this relation. For example, the entries inheriting from
- dj.Part could be single entries of a matrix, while the parent table refers to the entire matrix.
- Part relations are implemented as classes inside classes.
- """
-
- _connection = None
- _master = None
-
- tier_regexp = r'(?P' + '|'.join(
- [c.tier_regexp for c in (Manual, Lookup, Imported, Computed)]
- ) + r'){1,1}' + '__' + r'(?P' + _base_regexp + ')'
-
- @ClassProperty
- def connection(cls):
- return cls._connection
-
- @ClassProperty
- def full_table_name(cls):
- return None if cls.database is None or cls.table_name is None else r"`{0:s}`.`{1:s}`".format(
- cls.database, cls.table_name)
-
- @ClassProperty
- def master(cls):
- return cls._master
-
- @ClassProperty
- def table_name(cls):
- return None if cls.master is None else cls.master.table_name + '__' + from_camel_case(cls.__name__)
-
- def delete(self, force=False):
- """
- unless force is True, prohibits direct deletes from parts.
- """
- if force:
- super().delete(force_parts=True)
- else:
- raise DataJointError(
- 'Cannot delete from a Part directly. Delete from master instead')
-
- def drop(self, force=False):
- """
- unless force is True, prohibits direct deletes from parts.
- """
- if force:
- super().drop()
- else:
- raise DataJointError('Cannot drop a Part directly. Delete from master instead')
diff --git a/datajoint/utils.py b/datajoint/utils.py
deleted file mode 100644
index 057e7e820..000000000
--- a/datajoint/utils.py
+++ /dev/null
@@ -1,128 +0,0 @@
-"""General-purpose utilities"""
-
-import re
-from pathlib import Path
-import shutil
-from .errors import DataJointError
-
-
-class ClassProperty:
- def __init__(self, f):
- self.f = f
-
- def __get__(self, obj, owner):
- return self.f(owner)
-
-
-def user_choice(prompt, choices=("yes", "no"), default=None):
- """
- Prompts the user for confirmation. The default value, if any, is capitalized.
- :param prompt: Information to display to the user.
- :param choices: an iterable of possible choices.
- :param default: default choice
- :return: the user's choice
- """
- assert default is None or default in choices
- choice_list = ', '.join((choice.title() if choice == default else choice for choice in choices))
- response = None
- while response not in choices:
- response = input(prompt + ' [' + choice_list + ']: ')
- response = response.lower() if response else default
- return response
-
-
-def get_master(full_table_name: str) -> str:
- """
- If the table name is that of a part table, then return what the master table name would be.
- This follows DataJoint's table naming convention where a master and a part must be in the
- same schema and the part table is prefixed with the master table name + ``__``.
-
- Example:
- `ephys`.`session` -- master
- `ephys`.`session__recording` -- part
-
- :param full_table_name: Full table name including part.
- :type full_table_name: str
- :return: Supposed master full table name or empty string if not a part table name.
- :rtype: str
- """
- match = re.match(r'(?P`\w+`.`\w+)__(?P\w+)`', full_table_name)
- return match['master'] + '`' if match else ''
-
-
-def to_camel_case(s):
- """
- Convert names with under score (_) separation into camel case names.
- :param s: string in under_score notation
- :returns: string in CamelCase notation
- Example:
- >>> to_camel_case("table_name") # returns "TableName"
- """
-
- def to_upper(match):
- return match.group(0)[-1].upper()
-
- return re.sub(r'(^|[_\W])+[a-zA-Z]', to_upper, s)
-
-
-def from_camel_case(s):
- """
- Convert names in camel case into underscore (_) separated names
- :param s: string in CamelCase notation
- :returns: string in under_score notation
- Example:
- >>> from_camel_case("TableName") # yields "table_name"
- """
-
- def convert(match):
- return ('_' if match.groups()[0] else '') + match.group(0).lower()
-
- if not re.match(r'[A-Z][a-zA-Z0-9]*', s):
- raise DataJointError(
- 'ClassName must be alphanumeric in CamelCase, begin with a capital letter')
- return re.sub(r'(\B[A-Z])|(\b[A-Z])', convert, s)
-
-
-def safe_write(filepath, blob):
- """
- A two-step write.
- :param filename: full path
- :param blob: binary data
- """
- filepath = Path(filepath)
- if not filepath.is_file():
- filepath.parent.mkdir(parents=True, exist_ok=True)
- temp_file = filepath.with_suffix(filepath.suffix + '.saving')
- temp_file.write_bytes(blob)
- temp_file.rename(filepath)
-
-
-def safe_copy(src, dest, overwrite=False):
- """
- Copy the contents of src file into dest file as a two-step process. Skip if dest exists already
- """
- src, dest = Path(src), Path(dest)
- if not (dest.exists() and src.samefile(dest)) and (overwrite or not dest.is_file()):
- dest.parent.mkdir(parents=True, exist_ok=True)
- temp_file = dest.with_suffix(dest.suffix + '.copying')
- shutil.copyfile(str(src), str(temp_file))
- temp_file.rename(dest)
-
-
-def parse_sql(filepath):
- """
- yield SQL statements from an SQL file
- """
- delimiter = ';'
- statement = []
- with Path(filepath).open('rt') as f:
- for line in f:
- line = line.strip()
- if not line.startswith('--') and len(line) > 1:
- if line.startswith('delimiter'):
- delimiter = line.split()[1]
- else:
- statement.append(line)
- if line.endswith(delimiter):
- yield ' '.join(statement)
- statement = []
diff --git a/datajoint/version.py b/datajoint/version.py
deleted file mode 100644
index 6da275f35..000000000
--- a/datajoint/version.py
+++ /dev/null
@@ -1,3 +0,0 @@
-__version__ = "0.13.4"
-
-assert len(__version__) <= 10 # The log table limits version to the 10 characters
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 000000000..23fd773c1
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,104 @@
+# Development environment with MySQL and MinIO services
+#
+# NOTE: docker-compose is OPTIONAL for running tests.
+# Tests use testcontainers to automatically manage containers.
+# Just run: pytest tests/
+#
+# Use docker-compose for development/debugging when you want
+# persistent containers that survive test runs:
+# docker compose up -d db minio # Start services manually
+# pytest tests/ # Tests will use these containers
+#
+# Full Docker testing (CI):
+# docker compose --profile test up djtest --build
+services:
+ db:
+ image: datajoint/mysql:${MYSQL_VER:-8.0}
+ environment:
+ - MYSQL_ROOT_PASSWORD=${DJ_PASS:-password}
+ command: mysqld --default-authentication-plugin=mysql_native_password
+ ports:
+ - "3306:3306"
+ healthcheck:
+ test: [ "CMD", "mysqladmin", "ping", "-h", "localhost" ]
+ timeout: 30s
+ retries: 5
+ interval: 15s
+ postgres:
+ image: postgres:${POSTGRES_VER:-15}
+ environment:
+ - POSTGRES_PASSWORD=${PG_PASS:-password}
+ - POSTGRES_USER=${PG_USER:-postgres}
+ - POSTGRES_DB=${PG_DB:-test}
+ ports:
+ - "5432:5432"
+ healthcheck:
+ test: [ "CMD-SHELL", "pg_isready -U postgres" ]
+ timeout: 30s
+ retries: 5
+ interval: 15s
+ minio:
+ image: minio/minio:${MINIO_VER:-RELEASE.2025-02-28T09-55-16Z}
+ environment:
+ - MINIO_ACCESS_KEY=datajoint
+ - MINIO_SECRET_KEY=datajoint
+ ports:
+ - "9000:9000"
+ command: server --address ":9000" /data
+ healthcheck:
+ test:
+ - "CMD"
+ - "curl"
+ - "--fail"
+ - "http://localhost:9000/minio/health/live"
+ timeout: 30s
+ retries: 5
+ interval: 15s
+ app:
+ image: datajoint/datajoint:${DJ_VERSION:-latest}
+ build:
+ context: .
+ dockerfile: Dockerfile
+ args:
+ PY_VER: ${PY_VER:-3.10}
+ HOST_UID: ${HOST_UID:-1000}
+ depends_on:
+ db:
+ condition: service_healthy
+ postgres:
+ condition: service_healthy
+ minio:
+ condition: service_healthy
+ environment:
+ - DJ_HOST=db
+ - DJ_USER=root
+ - DJ_PASS=password
+ - DJ_TEST_HOST=db
+ - DJ_TEST_USER=datajoint
+ - DJ_TEST_PASSWORD=datajoint
+ - DJ_PG_HOST=postgres
+ - DJ_PG_USER=postgres
+ - DJ_PG_PASS=password
+ - DJ_PG_PORT=5432
+ - S3_ENDPOINT=minio:9000
+ - S3_ACCESS_KEY=datajoint
+ - S3_SECRET_KEY=datajoint
+ - S3_BUCKET=datajoint.test
+ - PYTHON_USER=dja
+ - JUPYTER_PASSWORD=datajoint
+ working_dir: /src
+ user: ${HOST_UID:-1000}:mambauser
+ volumes:
+ - .:/src
+ djtest:
+ extends:
+ service: app
+ profiles: ["test"]
+ command:
+ - sh
+ - -c
+ - |
+ set -e
+ pip install -q -e ".[test]"
+ pip freeze | grep datajoint
+ pytest --cov-report term-missing --cov=datajoint tests
diff --git a/docs-parts/admin/5-blob-config_lang1.rst b/docs-parts/admin/5-blob-config_lang1.rst
deleted file mode 100644
index 0eb0203f5..000000000
--- a/docs-parts/admin/5-blob-config_lang1.rst
+++ /dev/null
@@ -1,17 +0,0 @@
-.. code-block:: python
-
- dj.config['stores'] = {
- 'external': dict( # 'regular' external storage for this pipeline
- protocol='s3',
- endpoint='s3.amazonaws.com:9000',
- bucket = 'testbucket',
- location = 'datajoint-projects/lab1',
- access_key='1234567',
- secret_key='foaf1234'),
- 'external-raw': dict( # 'raw' storage for this pipeline
- protocol='file',
- location='/net/djblobs/myschema')
- }
- # external object cache - see fetch operation below for details.
- dj.config['cache'] = '/net/djcache'
-
diff --git a/docs-parts/admin/5-blob-config_lang2.rst b/docs-parts/admin/5-blob-config_lang2.rst
deleted file mode 100644
index efae4392b..000000000
--- a/docs-parts/admin/5-blob-config_lang2.rst
+++ /dev/null
@@ -1 +0,0 @@
-Use ``dj.config`` for configuration.
diff --git a/docs-parts/admin/5-blob-config_lang3.rst b/docs-parts/admin/5-blob-config_lang3.rst
deleted file mode 100644
index 5628e385d..000000000
--- a/docs-parts/admin/5-blob-config_lang3.rst
+++ /dev/null
@@ -1,5 +0,0 @@
- This is done by saving the path in the ``cache`` key of the DataJoint configuration dictionary:
-
- .. code-block:: python
-
- dj.config['cache'] = '/temp/dj-cache'
diff --git a/docs-parts/admin/5-blob-config_lang4.rst b/docs-parts/admin/5-blob-config_lang4.rst
deleted file mode 100644
index 7d8422260..000000000
--- a/docs-parts/admin/5-blob-config_lang4.rst
+++ /dev/null
@@ -1,29 +0,0 @@
-
-To remove only the tracking entries in the external table, call ``delete``
-on the ``~external_`` table for the external configuration with the argument
-``delete_external_files=False``.
-
-.. note::
-
- Currently, cleanup operations on a schema's external table are not 100%
- transaction safe and so must be run when there is no write activity occurring
- in tables which use a given schema / external store pairing.
-
-.. code-block:: python
-
- >>> schema.external['external_raw'].delete(delete_external_files=False)
-
-To remove the tracking entries as well as the underlying files, call `delete`
-on the external table for the external configuration with the argument
-`delete_external_files=True`.
-
-.. code-block:: python
-
- >>> schema.external['external_raw'].delete(delete_external_files=True)
-
-.. note::
-
- Setting ``delete_external_files=True`` will always attempt to delete
- the underlying data file, and so should not typically be used with
- the ``filepath`` datatype.
-
diff --git a/docs-parts/computation/01-autopopulate_lang1.rst b/docs-parts/computation/01-autopopulate_lang1.rst
deleted file mode 100644
index 376feb471..000000000
--- a/docs-parts/computation/01-autopopulate_lang1.rst
+++ /dev/null
@@ -1,18 +0,0 @@
-
-.. code-block:: python
-
- @schema
- class FilteredImage(dj.Computed):
- definition = """
- # Filtered image
- -> Image
- ---
- filtered_image : longblob
- """
-
- def make(self, key):
- img = (test.Image & key).fetch1('image')
- key['filtered_image'] = myfilter(img)
- self.insert1(key)
-
-The ``make`` method receives one argument: the dict ``key`` containing the primary key value of an element of :ref:`key source ` to be worked on.
diff --git a/docs-parts/computation/01-autopopulate_lang2.rst b/docs-parts/computation/01-autopopulate_lang2.rst
deleted file mode 100644
index 8af2c1954..000000000
--- a/docs-parts/computation/01-autopopulate_lang2.rst
+++ /dev/null
@@ -1,6 +0,0 @@
-
-.. code-block:: python
-
- FilteredImage.populate()
-
-The progress of long-running calls to ``populate()`` in datajoint-python can be visualized by adding the ``display_progress=True`` argument to the populate call.
diff --git a/docs-parts/computation/01-autopopulate_lang3.rst b/docs-parts/computation/01-autopopulate_lang3.rst
deleted file mode 100644
index ae3024d71..000000000
--- a/docs-parts/computation/01-autopopulate_lang3.rst
+++ /dev/null
@@ -1,23 +0,0 @@
-The ``populate`` method accepts a number of optional arguments that provide more features and allow greater control over the method's behavior.
-
-- ``restrictions`` - A list of restrictions, restricting as ``(tab.key_source & AndList(restrictions)) - tab.proj()``.
- Here ``target`` is the table to be populated, usually ``tab`` itself.
-- ``suppress_errors`` - If ``True``, encountering an error will cancel the current ``make`` call, log the error, and continue to the next ``make`` call.
- Error messages will be logged in the job reservation table (if ``reserve_jobs`` is ``True``) and returned as a list.
- See also ``return_exception_objects`` and ``reserve_jobs``.
- Defaults to ``False``.
-- ``return_exception_objects`` - If ``True``, error objects are returned instead of error messages.
- This applies only when ``suppress_errors`` is ``True``.
- Defaults to ``False``.
-- ``reserve_jobs`` - If ``True``, reserves job to indicate to other distributed processes.
- The job reservation table may be access as ``schema.jobs``.
- Errors are logged in the jobs table.
- Defaults to ``False``.
-- ``order`` - The order of execution, either ``"original"``, ``"reverse"``, or ``"random"``.
- Defaults to ``"original"``.
-- ``display_progress`` - If ``True``, displays a progress bar.
- Defaults to ``False``.
-- ``limit`` - If not ``None``, checks at most this number of keys.
- Defaults to ``None``.
-- ``max_calls`` - If not ``None``, populates at most this many keys.
- Defaults to ``None``, which means no limit.
diff --git a/docs-parts/computation/01-autopopulate_lang4.rst b/docs-parts/computation/01-autopopulate_lang4.rst
deleted file mode 100644
index fff832398..000000000
--- a/docs-parts/computation/01-autopopulate_lang4.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-The method ``table.progress`` reports how many ``key_source`` entries have been populated and how many remain.
-Two optional parameters allow more advanced use of the method.
-A parameter of restriction conditions can be provided, specifying which entities to consider.
-A Boolean parameter ``display`` (default is ``True``) allows disabling the output, such that the numbers of remaining and total entities are returned but not printed.
diff --git a/docs-parts/computation/02-keysource_lang1.rst b/docs-parts/computation/02-keysource_lang1.rst
deleted file mode 100644
index 583ceee7d..000000000
--- a/docs-parts/computation/02-keysource_lang1.rst
+++ /dev/null
@@ -1 +0,0 @@
-A custom key source can be configured by setting the ``key_source`` property within a table class, after the ``definition`` string.
diff --git a/docs-parts/computation/02-keysource_lang2.rst b/docs-parts/computation/02-keysource_lang2.rst
deleted file mode 100644
index d370ff238..000000000
--- a/docs-parts/computation/02-keysource_lang2.rst
+++ /dev/null
@@ -1,11 +0,0 @@
-.. code-block:: python
-
- @schema
- class EEG(dj.Imported):
- definition = """
- -> Recording
- ---
- sample_rate : float
- eeg_data : longblob
- """
- key_source = Recording & 'recording_type = "EEG"'
diff --git a/docs-parts/computation/04-master-part_lang1.rst b/docs-parts/computation/04-master-part_lang1.rst
deleted file mode 100644
index 3bda5abb9..000000000
--- a/docs-parts/computation/04-master-part_lang1.rst
+++ /dev/null
@@ -1,29 +0,0 @@
-
-In Python, the master-part relationship is expressed by making the part a nested class of the master.
-The part is subclassed from ``dj.Part`` and does not need the ``@schema`` decorator.
-
-
-.. code-block:: python
-
- @schema
- class Segmentation(dj.Computed):
- definition = """ # image segmentation
- -> Image
- """
-
- class ROI(dj.Part):
- definition = """ # Region of interest resulting from segmentation
- -> Segmentation
- roi : smallint # roi number
- ---
- roi_pixels : longblob # indices of pixels
- roi_weights : longblob # weights of pixels
- """
-
- def make(self, key):
- image = (Image & key).fetch1('image')
- self.insert1(key)
- count = itertools.count()
- Segmentation.ROI.insert(
- dict(key, roi=next(count), roi_pixel=roi_pixels, roi_weights=roi_weights)
- for roi_pixels, roi_weights in mylib.segment(image))
diff --git a/docs-parts/computation/04-master-part_lang2.rst b/docs-parts/computation/04-master-part_lang2.rst
deleted file mode 100644
index 8bf4ef731..000000000
--- a/docs-parts/computation/04-master-part_lang2.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-
-.. code-block:: python
-
- Segmentation.populate()
diff --git a/docs-parts/computation/04-master-part_lang3.rst b/docs-parts/computation/04-master-part_lang3.rst
deleted file mode 100644
index 1e9bfe70f..000000000
--- a/docs-parts/computation/04-master-part_lang3.rst
+++ /dev/null
@@ -1,22 +0,0 @@
-
-.. code-block:: python
-
- @schema
- class ArrayResponse(dj.Computed):
- definition = """
- array: int
- """
-
- class ElectrodeResponse(dj.Part):
- definition = """
- -> master
- electrode: int # electrode number on the probe
- """
-
- class ChannelResponse(dj.Part):
- definition = """
- -> ElectrodeResponse
- channel: int
- ---
- response: longblob # response of a channel
- """
diff --git a/docs-parts/computation/06-distributed-computing_jobs_by_key.rst b/docs-parts/computation/06-distributed-computing_jobs_by_key.rst
deleted file mode 100644
index 9862d06c7..000000000
--- a/docs-parts/computation/06-distributed-computing_jobs_by_key.rst
+++ /dev/null
@@ -1,22 +0,0 @@
-
-This can be done by using `dj.key_hash` to convert the key as follows:
-
-.. code-block:: python
-
- In [4]: jk = {'table_name': JobResults.table_name, 'key_hash' : dj.key_hash({'id': 2})}
- In [5]: schema.jobs & jk
- Out[5]:
- *table_name *key_hash status key error_message error_stac user host pid connection_id timestamp
- +------------+ +------------+ +--------+ +--------+ +------------+ +--------+ +------------+ +-------+ +--------+ +------------+ +------------+
- __job_results c81e728d9d4c2f error =BLOB= KeyboardInterr =BLOB= datajoint@localhost localhost 15571 59 2017-09-04 14:
- (Total: 1)
-
- In [6]: (schema.jobs & jk).delete()
-
- In [7]: schema.jobs & jk
- Out[7]:
- *table_name *key_hash status key error_message error_stac user host pid connection_id timestamp
- +------------+ +----------+ +--------+ +--------+ +------------+ +--------+ +------+ +------+ +-----+ +------------+ +-----------+
-
- (Total: 0)
-
diff --git a/docs-parts/computation/06-distributed-computing_kill_order_by.rst b/docs-parts/computation/06-distributed-computing_kill_order_by.rst
deleted file mode 100644
index 585f309fc..000000000
--- a/docs-parts/computation/06-distributed-computing_kill_order_by.rst
+++ /dev/null
@@ -1,14 +0,0 @@
-
-For example, to sort the output by hostname in descending order:
-
-.. code-block:: python
-
- In [3]: dj.kill(None, None, 'host desc')
- Out[3]:
- ID USER HOST STATE TIME INFO
- +--+ +----------+ +-----------+ +-----------+ +-----+
- 33 chris localhost:54840 1261 None
- 17 chris localhost:54587 3246 None
- 4 event_scheduler localhost Waiting on empty queue 187180 None
- process to kill or "q" to quit > q
-
diff --git a/docs-parts/computation/06-distributed-computing_lang1.rst b/docs-parts/computation/06-distributed-computing_lang1.rst
deleted file mode 100644
index 93906e82d..000000000
--- a/docs-parts/computation/06-distributed-computing_lang1.rst
+++ /dev/null
@@ -1,2 +0,0 @@
-
-Job reservations are activated by setting the keyword argument ``reserve_jobs=True`` in ``populate`` calls.
diff --git a/docs-parts/computation/06-distributed-computing_lang2.rst b/docs-parts/computation/06-distributed-computing_lang2.rst
deleted file mode 100644
index 2df361bf8..000000000
--- a/docs-parts/computation/06-distributed-computing_lang2.rst
+++ /dev/null
@@ -1,10 +0,0 @@
-
-.. code-block:: python
-
- In [1]: schema.jobs
- Out[1]:
- *table_name *key_hash status error_message user host pid connection_id timestamp key error_stack
- +------------+ +------------+ +----------+ +------------+ +------------+ +------------+ +-------+ +------------+ +------------+ +--------+ +------------+
- __job_results e4da3b7fbbce23 reserved datajoint@localhos localhost 15571 59 2017-09-04 14:
- (2 tuples)
-
diff --git a/docs-parts/computation/06-distributed-computing_lang3.rst b/docs-parts/computation/06-distributed-computing_lang3.rst
deleted file mode 100644
index 2a26bc51c..000000000
--- a/docs-parts/computation/06-distributed-computing_lang3.rst
+++ /dev/null
@@ -1,11 +0,0 @@
-
-For example, if a Python process is interrupted via the keyboard, a KeyboardError will be logged to the database as follows:
-
-.. code-block:: python
-
- In [2]: schema.jobs
- Out[2]:
- *table_name *key_hash status error_message user host pid connection_id timestamp key error_stack
- +------------+ +------------+ +--------+ +------------+ +------------+ +------------+ +-------+ +------------+ +------------+ +--------+ +------------+
- __job_results 3416a75f4cea91 error KeyboardInterr datajoint@localhos localhost 15571 59 2017-09-04 14:
- (1 tuples)
diff --git a/docs-parts/computation/06-distributed-computing_lang4.rst b/docs-parts/computation/06-distributed-computing_lang4.rst
deleted file mode 100644
index 106fa51f1..000000000
--- a/docs-parts/computation/06-distributed-computing_lang4.rst
+++ /dev/null
@@ -1,23 +0,0 @@
-
-For example, given the above table, errors can be inspected as follows:
-
-.. code-block:: python
-
- In [3]: (schema.jobs & 'status="error"' ).fetch(as_dict=True)
- Out[3]:
- [OrderedDict([('table_name', '__job_results'),
- ('key_hash', 'c81e728d9d4c2f636f067f89cc14862c'),
- ('status', 'error'),
- ('key', rec.array([(2,)],
- dtype=[('id', 'O')])),
- ('error_message', 'KeyboardInterrupt'),
- ('error_stack', None),
- ('user', 'datajoint@localhost'),
- ('host', 'localhost'),
- ('pid', 15571),
- ('connection_id', 59),
- ('timestamp', datetime.datetime(2017, 9, 4, 15, 3, 53))])]
-
-
-This particular error occurred when processing the record with ID ``2``, resulted from a `KeyboardInterrupt`, and has no additional
-error trace.
diff --git a/docs-parts/computation/06-distributed-computing_lang5.rst b/docs-parts/computation/06-distributed-computing_lang5.rst
deleted file mode 100644
index 31ffaf493..000000000
--- a/docs-parts/computation/06-distributed-computing_lang5.rst
+++ /dev/null
@@ -1,6 +0,0 @@
-
-For example:
-
-.. code-block:: python
-
- In [4]: (schema.jobs & 'status="error"' ).delete()
diff --git a/docs-parts/concepts/04-Integrity_lang1.rst b/docs-parts/concepts/04-Integrity_lang1.rst
deleted file mode 100644
index dd5a6b710..000000000
--- a/docs-parts/concepts/04-Integrity_lang1.rst
+++ /dev/null
@@ -1,17 +0,0 @@
-.. code-block:: python
-
- @schema
- class Mouse(dj.Manual):
- definition = """
- mouse_name : varchar(64)
- ---
- mouse_dob : datetime
- """
-
- @schema
- class MouseDeath(dj.Manual):
- definition = """
- -> Mouse
- ---
- death_date : datetime
- """
diff --git a/docs-parts/concepts/04-Integrity_lang2.rst b/docs-parts/concepts/04-Integrity_lang2.rst
deleted file mode 100644
index c0da41dbe..000000000
--- a/docs-parts/concepts/04-Integrity_lang2.rst
+++ /dev/null
@@ -1,20 +0,0 @@
-.. code-block:: python
-
- @schema
- class EEGRecording(dj.Manual):
- definition = """
- -> Session
- eeg_recording_id : int
- ---
- eeg_system : varchar(64)
- num_channels : int
- """
-
- @schema
- class ChannelData(dj.Imported):
- definition = """
- -> EEGRecording
- channel_idx : int
- ---
- channel_data : longblob
- """
diff --git a/docs-parts/concepts/04-Integrity_lang3.rst b/docs-parts/concepts/04-Integrity_lang3.rst
deleted file mode 100644
index da8e07fd1..000000000
--- a/docs-parts/concepts/04-Integrity_lang3.rst
+++ /dev/null
@@ -1,23 +0,0 @@
-.. code-block:: python
-
- @schema
- class Mouse(dj.Manual):
- definition = """
- mouse_name : varchar(64)
- ---
- mouse_dob : datetime
- """
-
- @schema
- class SubjectGroup(dj.Manual):
- definition = """
- group_number : int
- ---
- group_name : varchar(64)
- """
-
- class GroupMember(dj.Part):
- definition = """
- -> master
- -> Mouse
- """
diff --git a/docs-parts/concepts/04-Integrity_lang4.rst b/docs-parts/concepts/04-Integrity_lang4.rst
deleted file mode 100644
index 6c7f38315..000000000
--- a/docs-parts/concepts/04-Integrity_lang4.rst
+++ /dev/null
@@ -1,20 +0,0 @@
-.. code-block:: python
-
- @schema
- class RecordingModality(dj.Lookup):
- definition = """
- modality : varchar(64)
- """
-
- @schema
- class MultimodalSession(dj.Manual):
- definition = """
- -> Session
- modes : int
- """
-
- class SessionMode(dj.Part):
- definition = """
- -> master
- -> RecordingModality
- """
diff --git a/docs-parts/definition/01-Creating-Schemas_lang1.rst b/docs-parts/definition/01-Creating-Schemas_lang1.rst
deleted file mode 100644
index 0c3666d57..000000000
--- a/docs-parts/definition/01-Creating-Schemas_lang1.rst
+++ /dev/null
@@ -1,28 +0,0 @@
-
-.. note:: By convention, the ``datajoint`` package is imported as ``dj``.
- The documentation refers to the package as ``dj`` throughout.
-
-Create a new schema using the ``dj.Schema`` class object:
-
-.. code-block:: python
-
- import datajoint as dj
- schema = dj.Schema('alice_experiment')
-
-This statement creates the database schema ``alice_experiment`` on the server.
-
-The returned object ``schema`` will then serve as a decorator for DataJoint classes, as described in :ref:`table`.
-
-It is a common practice to have a separate Python module for each schema.
-Therefore, each such module has only one ``dj.Schema`` object defined and is usually named ``schema``.
-
-The ``dj.Schema`` constructor can take a number of optional parameters after the schema name.
-
-- ``context`` - Dictionary for looking up foreign key references.
- Defaults to ``None`` to use local context.
-- ``connection`` - Specifies the DataJoint connection object.
- Defaults to ``dj.conn()``.
-- ``create_schema`` - When ``False``, the schema object will not create a schema on the database and will raise an error if one does not already exist.
- Defaults to ``True``.
-- ``create_tables`` - When ``False``, the schema object will not create tables on the database and will raise errors when accessing missing tables.
- Defaults to ``True``.
diff --git a/docs-parts/definition/02-Creating-Tables_lang1.rst b/docs-parts/definition/02-Creating-Tables_lang1.rst
deleted file mode 100644
index 1411d3aa0..000000000
--- a/docs-parts/definition/02-Creating-Tables_lang1.rst
+++ /dev/null
@@ -1,43 +0,0 @@
-
-To define a DataJoint table in Python:
-
-1. Define a class inheriting from the appropriate DataJoint class: ``dj.Lookup``, ``dj.Manual``, ``dj.Imported`` or ``dj.Computed``.
-
-2. Decorate the class with the schema object (see :ref:`schema`)
-
-3. Define the class property ``definition`` to define the table heading.
-
-For example, the following code defines the table ``Person``:
-
-.. code-block:: python
-
- import datajoint as dj
- schema = dj.Schema('alice_experiment')
-
- @schema
- class Person(dj.Manual):
- definition = '''
- username : varchar(20) # unique user name
- ---
- first_name : varchar(30)
- last_name : varchar(30)
- '''
-
-
-The ``@schema`` decorator uses the class name and the data tier to check whether an appropriate table exists on the database.
-If a table does not already exist, the decorator creates one on the database using the definition property.
-The decorator attaches the information about the table to the class, and then returns the class.
-
-The class will become usable after you define the ``definition`` property as described in :ref:`definitions`.
-
-DataJoint classes in Python
-^^^^^^^^^^^^^^^^^^^^^^^^^^^
-
-DataJoint for Python is implemented through the use of classes providing access to the actual tables stored on the database.
-Since only a single table exists on the database for any class, interactions with all instances of the class are equivalent.
-As such, most methods can be called on the classes themselves rather than on an object, for convenience.
-Whether calling a DataJoint method on a class or on an instance, the result will only depend on or apply to the corresponding table.
-All of the basic functionality of DataJoint is built to operate on the classes themselves, even when called on an instance.
-For example, calling ``Person.insert(...)`` (on the class) and ``Person.insert(...)`` (on an instance) both have the identical effect of inserting data into the table on the database server.
-DataJoint does not prevent a user from working with instances, but the workflow is complete without the need for instantiation.
-It is up to the user whether to implement additional functionality as class methods or methods called on instances.
diff --git a/docs-parts/definition/03-Table-Definition_lang1.rst b/docs-parts/definition/03-Table-Definition_lang1.rst
deleted file mode 100644
index e88fd3484..000000000
--- a/docs-parts/definition/03-Table-Definition_lang1.rst
+++ /dev/null
@@ -1,15 +0,0 @@
-
-The table definition is contained in the ``definition`` property of the class.
-
-.. code-block:: python
-
- @schema
- class User(dj.Manual):
- definition = """
- # database users
- username : varchar(20) # unique user name
- ---
- first_name : varchar(30)
- last_name : varchar(30)
- role : enum('admin', 'contributor', 'viewer')
- """
diff --git a/docs-parts/definition/03-Table-Definition_lang2.rst b/docs-parts/definition/03-Table-Definition_lang2.rst
deleted file mode 100644
index e2b8e8377..000000000
--- a/docs-parts/definition/03-Table-Definition_lang2.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-
-Users do not need to do anything special to have a table created in the database.
-Tables are created at the time of class definition.
-In fact, table creation on the database is one of the jobs performed by the decorator ``@schema`` of the class.
diff --git a/docs-parts/definition/03-Table-Definition_lang3.rst b/docs-parts/definition/03-Table-Definition_lang3.rst
deleted file mode 100644
index 480445931..000000000
--- a/docs-parts/definition/03-Table-Definition_lang3.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-
-.. code-block:: python
-
- s = lab.User.describe()
diff --git a/docs-parts/definition/07-Primary-Key_lang1.rst b/docs-parts/definition/07-Primary-Key_lang1.rst
deleted file mode 100644
index 58bc636c6..000000000
--- a/docs-parts/definition/07-Primary-Key_lang1.rst
+++ /dev/null
@@ -1,9 +0,0 @@
-.. code-block:: python
-
- U().aggr(Scan & key, next='max(scan_idx)+1')
-
- # or
-
- Session.aggr(Scan, next='max(scan_idx)+1') & key
-
-Note that the first option uses a :ref:`universal set `.
diff --git a/docs-parts/definition/10-Dependencies_lang1.rst b/docs-parts/definition/10-Dependencies_lang1.rst
deleted file mode 100644
index 108ea6a2f..000000000
--- a/docs-parts/definition/10-Dependencies_lang1.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-
-.. code-block:: python
-
- mp.BrainSlice.heading
diff --git a/docs-parts/definition/11-ERD_lang1.rst b/docs-parts/definition/11-ERD_lang1.rst
deleted file mode 100644
index 647e77f04..000000000
--- a/docs-parts/definition/11-ERD_lang1.rst
+++ /dev/null
@@ -1,21 +0,0 @@
-
-To plot the Diagram for an entire schema, an Diagram object can be initialized with the schema object (which is normally used to decorate table objects)
-
-.. code-block:: python
-
- import datajoint as dj
- schema = dj.Schema('my_database')
- dj.Diagram(schema).draw()
-
-or alternatively an object that has the schema object as an attribute, such as the module defining a schema:
-
-.. code-block:: python
-
- import datajoint as dj
- import seq # import the sequence module defining the seq database
- dj.Diagram(seq).draw() # draw the Diagram
-
-Note that calling the ``.draw()`` method is not necessary when working in a Jupyter notebook.
-You can simply let the object display itself, for example by entering ``dj.Diagram(seq)`` in a notebook cell.
-The Diagram will automatically render in the notebook by calling its ``_repr_html_`` method.
-A Diagram displayed without ``.draw()`` will be rendered as an SVG, and hovering the mouse over a table will reveal a compact version of the output of the ``.describe()`` method.
diff --git a/docs-parts/definition/11-ERD_lang2.rst b/docs-parts/definition/11-ERD_lang2.rst
deleted file mode 100644
index 523b072fa..000000000
--- a/docs-parts/definition/11-ERD_lang2.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-
-.. code-block:: python
-
- dj.Diagram(seq.Genome).draw()
diff --git a/docs-parts/definition/11-ERD_lang3.rst b/docs-parts/definition/11-ERD_lang3.rst
deleted file mode 100644
index 91632fafb..000000000
--- a/docs-parts/definition/11-ERD_lang3.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-
-.. code-block:: python
-
- # plot the Diagram with tables Genome and Species from module seq.
- (dj.Diagram(seq.Genome) + dj.Diagram(seq.Species)).draw()
diff --git a/docs-parts/definition/11-ERD_lang4.rst b/docs-parts/definition/11-ERD_lang4.rst
deleted file mode 100644
index 03936a728..000000000
--- a/docs-parts/definition/11-ERD_lang4.rst
+++ /dev/null
@@ -1,15 +0,0 @@
-
-.. code-block:: python
-
- # Plot all the tables directly downstream from ``seq.Genome``:
- (dj.Diagram(seq.Genome)+1).draw()
-
-.. code-block:: python
-
- # Plot all the tables directly upstream from ``seq.Genome``:
- (dj.Diagram(seq.Genome)-1).draw()
-
-.. code-block:: python
-
- # Plot the local neighborhood of ``seq.Genome``
- (dj.Diagram(seq.Genome)+1-1+1-1).draw()
diff --git a/docs-parts/definition/12-Example_lang1.rst b/docs-parts/definition/12-Example_lang1.rst
deleted file mode 100644
index b595c6a2a..000000000
--- a/docs-parts/definition/12-Example_lang1.rst
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-.. code-block:: python
-
- @schema
- class Animal(dj.Manual):
- definition = """
- # information about animal
- animal_id : int # animal id assigned by the lab
- ---
- -> Species
- date_of_birth=null : date # YYYY-MM-DD optional
- sex='' : enum('M', 'F', '') # leave empty if unspecified
- """
-
- @schema
- class Session(dj.Manual):
- definition = """
- # Experiment Session
- -> Animal
- session : smallint # session number for the animal
- ---
- session_date : date # YYYY-MM-DD
- -> User
- -> Anesthesia
- -> Rig
- """
-
- @schema
- class Scan(dj.Manual):
- definition = """
- # Two-photon imaging scan
- -> Session
- scan : smallint # scan number within the session
- ---
- -> Lens
- laser_wavelength : decimal(5,1) # um
- laser_power : decimal(4,1) # mW
- """
diff --git a/docs-parts/definition/13-Lookup-Tables_lang1.rst b/docs-parts/definition/13-Lookup-Tables_lang1.rst
deleted file mode 100644
index 104ee6724..000000000
--- a/docs-parts/definition/13-Lookup-Tables_lang1.rst
+++ /dev/null
@@ -1,17 +0,0 @@
-
-.. code-block:: python
-
- @schema
- class User(dj.Lookup):
- definition = """
- # users in the lab
- username : varchar(20) # user in the lab
- ---
- first_name : varchar(20) # user first name
- last_name : varchar(20) # user last name
- """
- contents = [
- ['cajal', 'Santiago', 'Cajal'],
- ['hubel', 'David', 'Hubel'],
- ['wiesel', 'Torsten', 'Wiesel']
- ]
diff --git a/docs-parts/definition/14-Drop_lang1.rst b/docs-parts/definition/14-Drop_lang1.rst
deleted file mode 100644
index 2851015d3..000000000
--- a/docs-parts/definition/14-Drop_lang1.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-
-.. code-block:: python
-
- # drop the Person table from its schema
- Person.drop()
diff --git a/docs-parts/definition/14-Drop_lang2.rst b/docs-parts/definition/14-Drop_lang2.rst
deleted file mode 100644
index 97c2a8b61..000000000
--- a/docs-parts/definition/14-Drop_lang2.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-A :ref:`part table ` is usually removed as a consequence of calling ``drop`` on its master table.
-To enforce this workflow, calling ``drop`` directly on a part table produces an error.
-In some cases, it may be necessary to override this behavior.
-To remove a part table without removing its master, use the argument ``force=True``.
diff --git a/docs-parts/existing/0-Virtual-Modules_lang1.rst b/docs-parts/existing/0-Virtual-Modules_lang1.rst
deleted file mode 100644
index 8893a4d80..000000000
--- a/docs-parts/existing/0-Virtual-Modules_lang1.rst
+++ /dev/null
@@ -1,14 +0,0 @@
-The class object ``VirtualModule`` of the ``dj.Schema`` class provides access to virtual modules.
-It creates a python module with the given name from the name of a schema on the server, automatically adds classes to it corresponding to the tables in the schema.
-
-The function can take several parameters:
-
- ``module_name``: displayed module name.
-
- ``schema_name``: name of the database in MySQL.
-
- ``create_schema``: if ``True``, create the schema on the database server if it does not already exist; if ``False`` (default), raise an error when the schema is not found.
-
- ``create_tables``: if ``True``, ``module.schema`` can be used as the decorator for declaring new classes; if ``False``, such use will raise an error stating that the module is intend only to work with existing tables.
-
-The function returns the Python module containing classes from the schema object with all the table classes already declared inside it.
diff --git a/docs-parts/existing/1-Loading-Classes_lang1.rst b/docs-parts/existing/1-Loading-Classes_lang1.rst
deleted file mode 100644
index 7d209b40c..000000000
--- a/docs-parts/existing/1-Loading-Classes_lang1.rst
+++ /dev/null
@@ -1,239 +0,0 @@
-
-This section describes how to work with database schemas without access to the
-original code that generated the schema. These situations often arise when the
-database is created by another user who has not shared the generating code yet
-or when the database schema is created from a programming language other than
-Python.
-
-.. code-block:: python
-
- import datajoint as dj
-
-
-Working with schemas and their modules
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-Typically a DataJoint schema is created as a dedicated Python module. This
-module defines a schema object that is used to link classes declared in the
-module to tables in the database schema. As an example, examine the university
-module: `university.py `_.
-
-You may then import the module to interact with its tables:
-
-.. code-block:: python
-
- import university as uni
-
-*Connecting dimitri\@localhost:3306*
-
-.. code-block:: python
-
- dj.Diagram(uni)
-
-.. figure:: virtual-module-ERD.svg
- :align: center
- :alt: query object preview
-
-.. .. raw:: html
-.. :file: virtual-module-ERD.svg
-
-Note that dj.Diagram can extract the diagram from a schema object or from a
-Python module containing its schema object, lending further support to the
-convention of one-to-one correspondence between database schemas and Python
-modules in a DataJoint project:
-
-``dj.Diagram(uni)``
-
-is equvalent to
-
-``dj.Diagram(uni.schema)``
-
-.. code-block:: python
-
- # students without majors
- uni.Student - uni.StudentMajor
-
-.. figure:: StudentTable.png
- :align: center
- :alt: query object preview
-
-.. .. csv-table::
-.. :file: Student_Table.csv
-.. :widths: 5, 5, 5, 5, 5, 5, 5, 5, 5, 5
-.. :header-rows: 1
-
-Spawning missing classes
-~~~~~~~~~~~~~~~~~~~~~~~~
-Now imagine that you do not have access to ``university.py`` or you do not have
-its latest version. You can still connect to the database schema but you will
-not have classes declared to interact with it.
-
-So let's start over in this scenario.
-
-You may use the ``dj.list_schemas`` function (new in DataJoint 0.12.0) to
-list the names of database schemas available to you.
-
-.. code-block:: python
-
- import datajoint as dj
- dj.list_schemas()
-
-*Connecting dimitri@localhost:3306*
-
-*['dimitri_alter','dimitri_attach','dimitri_blob','dimitri_blobs',
-'dimitri_nphoton','dimitri_schema','dimitri_university','dimitri_uuid',
-'university']*
-
-Just as with a new schema, we start by creating a schema object to connect to
-the chosen database schema:
-
-.. code-block:: python
-
- schema = dj.Schema('dimitri_university')
-
-If the schema already exists, dj.Schema is initialized as usual and you may plot
-the schema diagram. But instead of seeing class names, you will see the raw
-table names as they appear in the database.
-
-.. code-block:: python
-
- # let's plot its diagram
- dj.Diagram(schema)
-
-.. figure:: dimitri-ERD.svg
- :align: center
- :alt: query object preview
-
-.. .. raw:: html
-.. :file: dimitri-ERD.svg
-
-You may view the diagram but, at this point, there is no way to interact with
-these tables. A similar situation arises when another developer has added new
-tables to the schema but has not yet shared the updated module code with you.
-Then the diagram will show a mixture of class names and database table names.
-
-Now you may use the ``spawn_missing_classes`` method to spawn classes into
-the local namespace for any tables missing their classes:
-
-.. code-block:: python
-
- schema.spawn_missing_classes()
- dj.Diagram(schema)
-
-.. figure:: spawned-classes-ERD.svg
- :align: center
- :alt: query object preview
-
-.. .. raw:: html
-.. :file: spawned-classes-ERD.svg
-
-Now you may interact with these tables as if they were declared right here in
-this namespace:
-
-.. code-block:: python
-
- # students without majors
- Student - StudentMajor
-
-.. figure:: StudentTable.png
- :align: center
- :alt: query object preview
-
-Creating a virtual module
-~~~~~~~~~~~~~~~~~~~~~~~~~
-Now ``spawn_missing_classes`` creates the new classes in the local namespace.
-However, it is often more convenient to import a schema with its Python module,
-equivalent to the Python command
-
-.. code-block:: python
-
- import university as uni
-
-We can mimick this import without having access to ``university.py`` using the
-``VirtualModule`` class object:
-
-.. code-block:: python
-
- import datajoint as dj
-
- uni = dj.VirtualModule('university.py', 'dimitri_university')
-
-*Connecting dimitri@localhost:3306*
-
-Now ``uni`` behaves as an imported module complete with the schema object and all
-the table classes.
-
-.. code-block:: python
-
- dj.Diagram(uni)
-
-.. figure:: added-example-ERD.svg
- :align: center
- :alt: query object preview
-
-.. .. raw:: html
-.. :file: added-example-ERD.svg
-
-.. code-block:: python
-
- uni.Student - uni.StudentMajor
-
-.. figure:: StudentTable.png
- :align: center
- :alt: query object preview
-
-``dj.VirtualModule`` takes optional arguments.
-
-First, ``create_schema=False`` assures that an error is raised when the schema
-does not already exist. Set it to ``True`` if you want to create an empty schema.
-
-.. code-block:: python
-
- dj.VirtualModule('what', 'nonexistent')
-
-.. code-block:: python
-
- ---------------------------------------------------------------------------
- DataJointError Traceback (most recent call last)
- .
- .
- .
- DataJointError: Database named `nonexistent` was not defined. Set argument create_schema=True to create it.
-
-
-The other optional argument, ``create_tables=False`` is passed to the schema
-object. It prevents the use of the schema obect of the virtual module for
-creating new tables in the existing schema. This is a precautionary measure
-since virtual modules are often used for completed schemas. You may set this
-argument to ``True`` if you wish to add new tables to the existing schema. A
-more common approach in this scenario would be to create a new schema object and
-to use the ``spawn_missing_classes`` function to make the classes available.
-
-However, you if do decide to create new tables in an existing tables using the
-virtual module, you may do so by using the schema object from the module as the
-decorator for declaring new tables:
-
-.. code-block:: python
-
- uni = dj.VirtualModule('university.py', 'dimitri_university', create_tables=True)
-
-.. code-block:: python
-
- @uni.schema
- class Example(dj.Manual):
- definition = """
- -> uni.Student
- ---
- example : varchar(255)
- """
-
-.. code-block:: python
-
- dj.Diagram(uni)
-
-.. figure:: added-example-ERD.svg
- :align: center
- :alt: query object preview
-
-.. .. raw:: html
-.. :file: added-example-ERD.svg
diff --git a/docs-parts/existing/StudentTable.png b/docs-parts/existing/StudentTable.png
deleted file mode 100644
index c8623f2ab..000000000
Binary files a/docs-parts/existing/StudentTable.png and /dev/null differ
diff --git a/docs-parts/existing/added-example-ERD.svg b/docs-parts/existing/added-example-ERD.svg
deleted file mode 100644
index 7603f2c2c..000000000
--- a/docs-parts/existing/added-example-ERD.svg
+++ /dev/null
@@ -1,207 +0,0 @@
-
-
\ No newline at end of file
diff --git a/docs-parts/existing/dimitri-ERD.svg b/docs-parts/existing/dimitri-ERD.svg
deleted file mode 100644
index 5c805f8ed..000000000
--- a/docs-parts/existing/dimitri-ERD.svg
+++ /dev/null
@@ -1,117 +0,0 @@
-
-
\ No newline at end of file
diff --git a/docs-parts/existing/spawned-classes-ERD.svg b/docs-parts/existing/spawned-classes-ERD.svg
deleted file mode 100644
index 65fbd7ccd..000000000
--- a/docs-parts/existing/spawned-classes-ERD.svg
+++ /dev/null
@@ -1,147 +0,0 @@
-
-
\ No newline at end of file
diff --git a/docs-parts/existing/virtual-module-ERD.svg b/docs-parts/existing/virtual-module-ERD.svg
deleted file mode 100644
index 69d98ae2a..000000000
--- a/docs-parts/existing/virtual-module-ERD.svg
+++ /dev/null
@@ -1,147 +0,0 @@
-
-
\ No newline at end of file
diff --git a/docs-parts/index_lang1.rst b/docs-parts/index_lang1.rst
deleted file mode 100644
index dc7dd445c..000000000
--- a/docs-parts/index_lang1.rst
+++ /dev/null
@@ -1 +0,0 @@
-This is a detailed manual for active users of DataJoint in Python.
diff --git a/docs-parts/intro/Releases_lang1.rst b/docs-parts/intro/Releases_lang1.rst
deleted file mode 100644
index 0510b9640..000000000
--- a/docs-parts/intro/Releases_lang1.rst
+++ /dev/null
@@ -1,290 +0,0 @@
-0.13.4 -- TBA
-----------------------
-* Add - Allow reading blobs produced by legacy 32-bit compiled mYm library for matlab. PR #995
-
-0.13.3 -- Feb 9, 2022
-----------------------
-* Bugfix - Fix error in listing ancestors, descendants with part tables.
-* Bugfix - Fix Python 3.10 compatibility (#983) PR #972
-* Bugfix - Allow renaming non-conforming attributes in proj (#982) PR #972
-* Add - Expose proxy feature for S3 external stores (#961) PR #962
-* Add - implement multiprocessing in populate (#695) PR #704, #969
-* Bugfix - Dependencies not properly loaded on populate. (#902) PR #919
-* Bugfix - Replace use of numpy aliases of built-in types with built-in type. (#938) PR #939
-* Bugfix - Deletes and drops must include the master of each part. (#151, #374) PR #957
-* Bugfix - `ExternalTable.delete` should not remove row on error (#953) PR #956
-* Bugfix - Fix error handling of remove_object function in `s3.py` (#952) PR #955
-* Bugfix - Fix sql code generation to comply with sql mode ``ONLY_FULL_GROUP_BY`` (#916) PR #965
-* Bugfix - Fix count for left-joined ``QueryExpressions`` (#951) PR #966
-* Bugfix - Fix assertion error when performing a union into a join (#930) PR #967
-* Bugfix - Fix regression issue with `DISTINCT` clause and `GROUP_BY` (#914) PR #963
-* Update `~jobs.error_stack` from blob to mediumblob to allow error stacks >64kB in jobs (#984) PR #986
-* Bugfix - Fix error when performing a union on multiple tables (#926) PR #964
-* Add - Allow optional keyword arguments for `make()` in `populate()` PR #971
-
-0.13.2 -- May 7, 2021
-----------------------
-* Update `setuptools_certificate` dependency to new name `otumat`
-* Bugfix - Explicit calls to `dj.Connection` throw error due to missing `host_input` (#895) PR #907
-* Bugfix - Correct count of deleted items. (#897) PR #912
-
-0.13.1 -- Apr 16, 2021
-----------------------
-* Add `None` as an alias for `IS NULL` comparison in `dict` restrictions (#824) PR #893
-* Drop support for MySQL 5.6 since it has reached EOL PR #893
-* Bugfix - `schema.list_tables()` is not topologically sorted (#838) PR #893
-* Bugfix - Diagram part tables do not show proper class name (#882) PR #893
-* Bugfix - Error in complex restrictions (#892) PR #893
-* Bugfix - WHERE and GROUP BY clases are dropped on joins with aggregation (#898, #899) PR #893
-
-0.13.0 -- Mar 24, 2021
-----------------------
-* Re-implement query transpilation into SQL, fixing issues (#386, #449, #450, #484, #558). PR #754
-* Re-implement cascading deletes for better performance. PR #839
-* Add support for deferred schema activation to allow for greater modularity. (#834) PR #839
-* Add query caching mechanism for offline development (#550) PR #839
-* Add table method `.update1` to update a row in the table with new values (#867) PR #763, #889
-* Python datatypes are now enabled by default in blobs (#761). PR #859
-* Added permissive join and restriction operators `@` and `^` (#785) PR #754
-* Support DataJoint datatype and connection plugins (#715, #729) PR 730, #735
-* Add `dj.key_hash` alias to `dj.hash.key_hash` (#804) PR #862
-* Default enable_python_native_blobs to True
-* Bugfix - Regression error on joins with same attribute name (#857) PR #878
-* Bugfix - Error when `fetch1('KEY')` when `dj.config['fetch_format']='frame'` set (#876) PR #880, #878
-* Bugfix - Error when cascading deletes in tables with many, complex keys (#883, #886) PR #839
-* Add deprecation warning for `_update`. PR #889
-* Add `purge_query_cache` utility. PR #889
-* Add tests for query caching and permissive join and restriction. PR #889
-* Drop support for Python 3.5 (#829) PR #861
-
-0.12.9 -- Mar 12, 2021
-----------------------
-* Fix bug with fetch1 with `dj.config['fetch_format']="frame"`. Issue #876 (PR #880)
-
-0.12.8 -- Jan 12, 2021
-----------------------
-* table.children, .parents, .descendents, and ancestors can return queryable objects. PR #833
-* Load dependencies before querying dependencies. (#179) PR #833
-* Fix display of part tables in `schema.save`. (#821) PR #833
-* Add `schema.list_tables`. (#838) PR #844
-* Fix minio new version regression. PR #847
-* Add more S3 logging for debugging. (#831) PR #832
-* Convert testing framework from TravisCI to GitHub Actions (#841) PR #840
-
-0.12.7 -- Oct 27, 2020
-----------------------
-* Fix case sensitivity issues to adapt to MySQL 8+. PR #819
-* Fix pymysql regression bug (#814) PR #816
-* Adapted attribute types now have `dtype=object` in all recarray results. PR #811
-
-0.12.6 -- May 15, 2020
-----------------------
-* Add `order_by` to `dj.kill` (#668, #779) PR #775, #783
-* Add explicit S3 bucket and file storage location existence checks (#748) PR #781
-* Modify `_update` to allow nullable updates for strings/date (#664) PR #760
-* Avoid logging events on auxiliary tables (#737) PR #753
-* Add `kill_quick` and expand display to include host (#740) PR #741
-* Bugfix - pandas insert fails due to additional `index` field (#666) PR #776
-* Bugfix - `delete_external_files=True` does not remove from S3 (#686) PR #781
-* Bugfix - pandas fetch throws error when `fetch_format='frame'` PR #774
-
-0.12.5 -- Feb 24, 2020
-----------------------
-* Rename module `dj.schema` into `dj.schemas`. `dj.schema` remains an alias for class `dj.Schema`. (#731) PR #732
-* `dj.create_virtual_module` is now called `dj.VirtualModule` (#731) PR #732
-* Bugfix - SSL `KeyError` on failed connection (#716) PR #725
-* Bugfix - Unable to run unit tests using nosetests (#723) PR #724
-* Bugfix - `suppress_errors` does not suppress loss of connection error (#720) PR #721
-
-0.12.4 -- Jan 14, 2020
-----------------------
-* Support for simple scalar datatypes in blobs (#690) PR #709
-* Add support for the `serial` data type in declarations: alias for `bigint unsigned auto_increment` PR #713
-* Improve the log table to avoid primary key collisions PR #713
-* Improve documentation in README PR #713
-
-0.12.3 -- Nov 22, 2019
-----------------------
-* Bugfix - networkx 2.4 causes error in diagrams (#675) PR #705
-* Bugfix - include table definition in doc string and help (#698, #699) PR #706
-* Bugfix - job reservation fails when native python datatype support is disabled (#701) PR #702
-
-0.12.2 -- Nov 11, 2019
--------------------------
-* Bugfix - Convoluted error thrown if there is a reference to a non-existent table attribute (#691) PR #696
-* Bugfix - Insert into external does not trim leading slash if defined in `dj.config['stores']['']['location']` (#692) PR #693
-
-0.12.1 -- Nov 2, 2019
--------------------------
-* Bugfix - AttributeAdapter converts into a string (#684) PR #688
-
-0.12.0 -- Oct 31, 2019
--------------------------
-* Dropped support for Python 3.4
-* Support secure connections with TLS (aka SSL) PR #620
-* Convert numpy array from python object to appropriate data type if all elements are of the same type (#587) PR #608
-* Remove expression requirement to have additional attributes (#604) PR #604
-* Support for filepath datatype (#481) PR #603, #659
-* Support file attachment datatype (#480, #592, #637) PR #659
-* Fetch return a dict array when specifying `as_dict=True` for specified attributes. (#595) PR #593
-* Support of ellipsis in `proj`: `query_expression.proj(.., '-movie')` (#499) PR #578
-* Expand support of blob serialization (#572, #520, #427, #392, #244, #594) PR #577
-* Support for alter (#110) PR #573
-* Support for `conda install datajoint` via `conda-forge` channel (#293)
-* `dj.conn()` accepts a `port` keyword argument (#563) PR #571
-* Support for UUID datatype (#562) PR #567
-* `query_expr.fetch("KEY", as_dict=False)` returns results as `np.recarray`(#414) PR #574
-* `dj.ERD` is now called `dj.Diagram` (#255, #546) PR #565
-* `dj.Diagram` underlines "distinguished" classes (#378) PR #557
-* Accept alias for supported MySQL datatypes (#544) PR #545
-* Support for pandas in `fetch` (#459, #537) PR #534
-* Support for ordering by "KEY" in `fetch` (#541) PR #534
-* Add config to enable python native blobs PR #672, #676
-* Add secure option for external storage (#663) PR #674, #676
-* Add blob migration utility from DJ011 to DJ012 PR #673
-* Improved external storage - a migration script needed from version 0.11 (#467, #475, #480, #497) PR #532
-* Increase default display rows (#523) PR #526
-* Bugfixes (#521, #205, #279, #477, #570, #581, #597, #596, #618, #633, #643, #644, #647, #648, #650, #656)
-* Minor improvements (#538)
-
-0.11.1 -- Nov 15, 2018
-----------------------
-* Fix ordering of attributes in proj (#483, #516)
-* Prohibit direct insert into auto-populated tables (#511)
-
-0.11.0 -- Oct 25, 2018
-----------------------
-* Full support of dependencies with renamed attributes using projection syntax (#300, #345, #436, #506, #507)
-* Rename internal class and module names to comply with terminology in documentation (#494, #500)
-* Full support of secondary indexes (#498, 500)
-* ERD no longer shows numbers in nodes corresponding to derived dependencies (#478, #500)
-* Full support of unique and nullable dependencies (#254, #301, #493, #495, #500)
-* Improve memory management in ``populate`` (#461, #486)
-* Fix query errors and redundancies (#456, #463, #482)
-
-0.10.1 -- Aug 28, 2018
------------------------
-* Fix ERD Tooltip message (#431)
-* Networkx 2.0 support (#443)
-* Fix insert from query with skip_duplicates=True (#451)
-* Sped up queries (#458)
-* Bugfix in restriction of the form (A & B) * B (#463)
-* Improved error messages (#466)
-
-0.10.0 -- Jan 10, 2018
-----------------------
-* Deletes are more efficient (#424)
-* ERD shows table definition on tooltip hover in Jupyter (#422)
-* S3 external storage
-* Garbage collection for external sorage
-* Most operators and methods of tables can be invoked as class methods rather than instance methods (#407)
-* The schema decorator object no longer requires locals() to specify the context
-* Compatibility with pymysql 0.8.0+
-* More efficient loading of dependencies (#403)
-
-0.9.0 -- Nov 17, 2017
----------------------
-* Made graphviz installation optional
-* Implement file-based external storage
-* Implement union operator +
-* Implement file-based external storage
-
-0.8.0 -- Jul 26, 2017
----------------------
-Documentation and tutorials available at https://docs.datajoint.io and https://tutorials.datajoint.io
-* improved the ERD graphics and features using the graphviz libraries (#207, #333)
-* improved password handling logic (#322, #321)
-* the use of the ``contents`` property to populate tables now only works in ``dj.Lookup`` classes (#310).
-* allow suppressing the display of size of query results through the ``show_tuple_count`` configuration option (#309)
-* implemented renamed foreign keys to spec (#333)
-* added the ``limit`` keyword argument to populate (#329)
-* reduced the number of displayed messages (#308)
-* added ``size_on_disk`` property for dj.Schema() objects (#323)
-* job keys are entered in the jobs table (#316, #243)
-* simplified the ``fetch`` and ``fetch1`` syntax, deprecating the ``fetch[...]`` syntax (#319)
-* the jobs tables now store the connection ids to allow identifying abandoned jobs (#288, #317)
-
-0.5.0 (#298) -- Mar 8, 2017
----------------------------
-* All fetched integers are now 64-bit long and all fetched floats are double precision.
-* Added ``dj.create_virtual_module``
-
-0.4.10 (#286) -- Feb 6, 2017
-----------------------------
-* Removed Vagrant and Readthedocs support
-* Explicit saving of configuration (issue #284)
-
-0.4.9 (#285) -- Feb 2, 2017
----------------------------
-* Fixed setup.py for pip install
-
-0.4.7 (#281) -- Jan 24, 2017
-----------------------------
-* Fixed issues related to order of attributes in projection.
-
-0.4.6 (#277) -- Dec 22, 2016
-----------------------------
-* Proper handling of interruptions during populate
-
-0.4.5 (#274) -- Dec 20, 2016
-----------------------------
-* Populate reports how many keys remain to be populated at the start.
-
-0.4.3 (#271) -- Dec 6, 2016
-----------------------------
-* Fixed aggregation issues (#270)
-* datajoint no longer attempts to connect to server at import time
-* dropped support of view (reversed #257)
-* more elegant handling of insufficient privileges (#268)
-
-0.4.2 (#267) -- Dec 6, 2016
-----------------------------
-* improved table appearance in Jupyter
-
-0.4.1 (#266) -- Oct 28, 2016
-----------------------------
-* bugfix for very long error messages
-
-0.3.9 -- Sep 27, 2016
----------------------
-* Added support for datatype ``YEAR``
-* Fixed issues with ``dj.U`` and the ``aggr`` operator (#246, #247)
-
-0.3.8 -- Aug 2, 2016
----------------------
-* added the ``_update`` method in ``base_relation``. It allows updating values in existing tuples.
-* bugfix in reading values of type double. Previously it was cast as float32.
-
-0.3.7 -- Jul 31, 2016
-----------------------
-* added parameter ``ignore_extra_fields`` in ``insert``
-* ``insert(..., skip_duplicates=True)`` now relies on ``SELECT IGNORE``. Previously it explicitly checked if tuple already exists.
-* table previews now include blob attributes displaying the string
-
-0.3.6 -- Jul 30, 2016
-----------------------
-* bugfix in ``schema.spawn_missing_classes``. Previously, spawned part classes would not show in ERDs.
-* dj.key now causes fetch to return as a list of dicts. Previously it was a recarray.
-
-0.3.5
------
-* ``dj.set_password()`` now asks for user confirmation before changing the password.
-* fixed issue #228
-
-0.3.4
------
-* Added method the ``ERD.add_parts`` method, which adds the part tables of all tables currently in the ERD.
-* ``ERD() + arg`` and ``ERD() - arg`` can now accept relation classes as arg.
-
-0.3.3
------
-* Suppressed warnings (redirected them to logging). Previoiusly, scipy would throw warnings in ERD, for example.
-* Added ERD.from_sequence as a shortcut to combining the ERDs of multiple sources
-* ERD() no longer text the context argument.
-* ERD.draw() now takes an optional context argument. By default uses the caller's locals.
-
-0.3.2
------
-* Fixed issue #223: ``insert`` can insert relations without fetching.
-* ERD() now takes the ``context`` argument, which specifies in which context to look for classes. The default is taken from the argument (schema or relation).
-* ERD.draw() no longer has the ``prefix`` argument: class names are shown as found in the context.
diff --git a/docs-parts/manipulation/1-Insert_lang1.rst b/docs-parts/manipulation/1-Insert_lang1.rst
deleted file mode 100644
index bee8417da..000000000
--- a/docs-parts/manipulation/1-Insert_lang1.rst
+++ /dev/null
@@ -1,42 +0,0 @@
-
-In Python there is a separate method ``insert1`` to insert one entity at a time.
-The entity may have the form of a Python dictionary with key names matching the attribute names in the table.
-
-.. code-block:: python
-
- lab.Person.insert1(
- dict(username='alice',
- first_name='Alice',
- last_name='Cooper'))
-
-The entity also may take the form of a sequence of values in the same order as the attributes in the table.
-
-.. code-block:: python
-
- lab.Person.insert1(['alice', 'Alice', 'Cooper'])
-
-Additionally, the entity may be inserted as a `NumPy record array `_ or `Pandas DataFrame `_.
-
-The ``insert`` method accepts a sequence or a generator of multiple entities and is used to insert multiple entities at once.
-
-.. code-block:: python
-
- lab.Person.insert([
- ['alice', 'Alice', 'Cooper'],
- ['bob', 'Bob', 'Dylan'],
- ['carol', 'Carol', 'Douglas']])
-
-Several optional parameters can be used with ``insert``:
-
- ``replace`` If ``True``, replaces the existing entity.
- (Default ``False``.)
-
- ``skip_duplicates`` If ``True``, silently skip duplicate inserts.
- (Default ``False``.)
-
- ``ignore_extra_fields`` If ``False``, fields that are not in the heading raise an error.
- (Default ``False``.)
-
- ``allow_direct_insert`` If ``True``, allows inserts outside of populate calls.
- Applies only in auto-populated tables.
- (Default ``None``.)
diff --git a/docs-parts/manipulation/1-Insert_lang2.rst b/docs-parts/manipulation/1-Insert_lang2.rst
deleted file mode 100644
index 9e5d8616f..000000000
--- a/docs-parts/manipulation/1-Insert_lang2.rst
+++ /dev/null
@@ -1,9 +0,0 @@
-
-.. code-block:: python
-
- # Server-side inserts are faster...
- phase_two.Protocol.insert(phase_one.Protocol)
-
- # ...than fetching before inserting
- protocols = phase_one.Protocol.fetch()
- phase_two.Protocol.insert(protocols)
diff --git a/docs-parts/manipulation/1-Update_lang1.rst b/docs-parts/manipulation/1-Update_lang1.rst
deleted file mode 100644
index 0960e2e22..000000000
--- a/docs-parts/manipulation/1-Update_lang1.rst
+++ /dev/null
@@ -1,14 +0,0 @@
-
-.. code-block:: python
-
- # with record as a dict specifying the primary and
- # secondary attribute values
- table.update1(record)
-
- # update value in record with id as primary key
- table.update1({'id': 1, 'value': 3})
-
- # reset value to default with id as primary key
- table.update1({'id': 1, 'value': None})
- ## OR
- table.update1({'id': 1})
diff --git a/docs-parts/manipulation/2-Delete_lang1.rst b/docs-parts/manipulation/2-Delete_lang1.rst
deleted file mode 100644
index 1b154cabd..000000000
--- a/docs-parts/manipulation/2-Delete_lang1.rst
+++ /dev/null
@@ -1 +0,0 @@
-The ``delete`` method deletes entities from a table and all dependent entries in dependent tables.
diff --git a/docs-parts/manipulation/2-Delete_lang2.rst b/docs-parts/manipulation/2-Delete_lang2.rst
deleted file mode 100644
index a1c9a68da..000000000
--- a/docs-parts/manipulation/2-Delete_lang2.rst
+++ /dev/null
@@ -1,11 +0,0 @@
-
-.. code-block:: python
-
- # delete all entries from tuning.VonMises
- tuning.VonMises.delete()
-
- # delete entries from tuning.VonMises for mouse 1010
- (tuning.VonMises & 'mouse=1010').delete()
-
- # delete entries from tuning.VonMises except mouse 1010
- (tuning.VonMises - 'mouse=1010').delete()
diff --git a/docs-parts/manipulation/2-Delete_lang3.rst b/docs-parts/manipulation/2-Delete_lang3.rst
deleted file mode 100644
index 7e2aa7a66..000000000
--- a/docs-parts/manipulation/2-Delete_lang3.rst
+++ /dev/null
@@ -1,3 +0,0 @@
-To enforce this workflow, calling ``delete`` directly on a part table produces an error.
-In some cases, it may be necessary to override this behavior.
-To remove entities from a part table without calling ``delete`` master, use the argument ``force=True``.
diff --git a/docs-parts/manipulation/3-Transactions_lang1.rst b/docs-parts/manipulation/3-Transactions_lang1.rst
deleted file mode 100644
index 53732b0df..000000000
--- a/docs-parts/manipulation/3-Transactions_lang1.rst
+++ /dev/null
@@ -1,20 +0,0 @@
-Transactions are formed using the ``transaction`` property of the connection object.
-The connection object may be obtained from any table object.
-The ``transaction`` property can then be used as a context manager in Python's ``with`` statement.
-
-For example, the following code inserts matching entries for the master table ``Session`` and its part table ``Session.Experimenter``.
-
-.. code-block:: python
-
- # get the connection object
- connection = Session.connection
-
- # insert Session and Session.Experimenter entries in a transaction
- with connection.transaction:
- key = {'subject_id': animal_id, 'session_time': session_time}
- Session.insert1({**key, 'brain_region':region, 'cortical_layer':layer})
- Session.Experimenter.insert1({**key, 'experimenter': username})
-
-Here, to external observers, both inserts will take effect together upon exiting from the ``with`` block or will not have any effect at all.
-For example, if the second insert fails due to an error, the first insert will be rolled back.
-
diff --git a/docs-parts/queries/01-Queries_lang1.rst b/docs-parts/queries/01-Queries_lang1.rst
deleted file mode 100644
index 7e0543b1c..000000000
--- a/docs-parts/queries/01-Queries_lang1.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-
-.. code-block:: python
-
- query = experiment.Session()
-
diff --git a/docs-parts/queries/01-Queries_lang2.rst b/docs-parts/queries/01-Queries_lang2.rst
deleted file mode 100644
index c334dfa72..000000000
--- a/docs-parts/queries/01-Queries_lang2.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-
-.. code-block:: python
-
- query = experiment.Session * experiment.Scan & 'animal_id = 102'
-
-Note that for brevity, query operators can be applied directly to class objects rather than instance objects so that ``experiment.Session`` may be used in place of ``experiment.Session()``.
-
diff --git a/docs-parts/queries/01-Queries_lang3.rst b/docs-parts/queries/01-Queries_lang3.rst
deleted file mode 100644
index aafd492c8..000000000
--- a/docs-parts/queries/01-Queries_lang3.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-
- s = query.fetch()
-
-Here fetching from the ``query`` object produces the NumPy record array ``s`` of the queried data.
-
diff --git a/docs-parts/queries/01-Queries_lang4.rst b/docs-parts/queries/01-Queries_lang4.rst
deleted file mode 100644
index 082409f9a..000000000
--- a/docs-parts/queries/01-Queries_lang4.rst
+++ /dev/null
@@ -1,10 +0,0 @@
-
-The ``bool`` function applied to a query object evaluates to ``True`` if the query returns any entities and to ``False`` if the query result is empty.
-
-The ``len`` function applied to a query object determines the number of entities returned by the query.
-
-.. code-block:: python
-
- # number of sessions since the start of 2018.
- n = len(Session & 'session_date >= "2018-01-01"')
-
diff --git a/docs-parts/queries/02-Example-Schema_lang1.rst b/docs-parts/queries/02-Example-Schema_lang1.rst
deleted file mode 100644
index 00a17cee0..000000000
--- a/docs-parts/queries/02-Example-Schema_lang1.rst
+++ /dev/null
@@ -1,100 +0,0 @@
-
-.. warning::
- Empty primary keys, such as in the ``CurrentTerm`` table, are not yet supported by DataJoint.
- This feature will become available in a future release.
- See `Issue #113 `_ for more information.
-
-.. code-block:: python
-
- @schema
- class Student (dj.Manual):
- definition = """
- student_id : int unsigned # university ID
- ---
- first_name : varchar(40)
- last_name : varchar(40)
- sex : enum('F', 'M', 'U')
- date_of_birth : date
- home_address : varchar(200) # street address
- home_city : varchar(30)
- home_state : char(2) # two-letter abbreviation
- home_zipcode : char(10)
- home_phone : varchar(14)
- """
-
- @schema
- class Department (dj.Manual):
- definition = """
- dept : char(6) # abbreviated department name, e.g. BIOL
- ---
- dept_name : varchar(200) # full department name
- dept_address : varchar(200) # mailing address
- dept_phone : varchar(14)
- """
-
- @schema
- class StudentMajor (dj.Manual):
- definition = """
- -> Student
- ---
- -> Department
- declare_date : date # when student declared her major
- """
-
- @schema
- class Course (dj.Manual):
- definition = """
- -> Department
- course : int unsigned # course number, e.g. 1010
- ---
- course_name : varchar(200) # e.g. "Cell Biology"
- credits : decimal(3,1) # number of credits earned by completing the course
- """
-
- @schema
- class Term (dj.Manual):
- definition = """
- term_year : year
- term : enum('Spring', 'Summer', 'Fall')
- """
-
- @schema
- class Section (dj.Manual):
- definition = """
- -> Course
- -> Term
- section : char(1)
- ---
- room : varchar(12) # building and room code
- """
-
- @schema
- class CurrentTerm (dj.Manual):
- definition = """
- ---
- -> Term
- """
-
- @schema
- class Enroll (dj.Manual):
- definition = """
- -> Section
- -> Student
- """
-
- @schema
- class LetterGrade (dj.Manual):
- definition = """
- grade : char(2)
- ---
- points : decimal(3,2)
- """
-
- @schema
- class Grade (dj.Manual):
- definition = """
- -> Enroll
- ---
- -> LetterGrade
- """
-
diff --git a/docs-parts/queries/03-Fetch_lang1.rst b/docs-parts/queries/03-Fetch_lang1.rst
deleted file mode 100644
index b291948c9..000000000
--- a/docs-parts/queries/03-Fetch_lang1.rst
+++ /dev/null
@@ -1,96 +0,0 @@
-
-Entire table
-~~~~~~~~~~~~
-
-The following statement retrieves the entire table as a NumPy `recarray `_.
-
-.. code-block:: python
-
- data = query.fetch()
-
-To retrieve the data as a list of ``dict``:
-
-.. code-block:: python
-
- data = query.fetch(as_dict=True)
-
-In some cases, the amount of data returned by fetch can be quite large; in these cases it can be useful to use the ``size_on_disk`` attribute to determine if running a bare fetch would be wise.
-Please note that it is only currently possible to query the size of entire tables stored directly in the database at this time.
-
-As separate variables
-~~~~~~~~~~~~~~~~~~~~~
-
-.. code-block:: python
-
- name, img = query.fetch1('name', 'image') # when query has exactly one entity
- name, img = query.fetch('name', 'image') # [name, ...] [image, ...]
-
-Primary key values
-~~~~~~~~~~~~~~~~~~
-
-.. code-block:: python
-
- keydict = tab.fetch1("KEY") # single key dict when tab has exactly one entity
- keylist = tab.fetch("KEY") # list of key dictionaries [{}, ...]
-
-``KEY`` can also used when returning attribute values as separate variables, such that one of the returned variables contains the entire primary keys.
-
-Sorting and limiting the results
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-To sort the result, use the ``order_by`` keyword argument.
-
-.. code-block:: python
-
- # ascending order:
- data = query.fetch(order_by='name')
- # descending order:
- data = query.fetch(order_by='name desc')
- # by name first, year second:
- data = query.fetch(order_by=('name desc', 'year'))
- # sort by the primary key:
- data = query.fetch(order_by='KEY')
- # sort by name but for same names order by primary key:
- data = query.fetch(order_by=('name', 'KEY desc'))
-
-The ``order_by`` argument can be a string specifying the attribute to sort by. By default the sort is in ascending order. Use ``'attr desc'`` to sort in descending order by attribute ``attr``. The value can also be a sequence of strings, in which case, the sort performed on all the attributes jointly in the order specified.
-
-The special attribute name ``'KEY'`` represents the primary key attributes in order that they appear in the index. Otherwise, this name can be used as any other argument.
-
-If an attribute happens to be a SQL reserved word, it needs to be enclosed in backquotes. For example:
-
-.. code-block:: python
-
- data = query.fetch(order_by='`select` desc')
-
-The ``order_by`` value is eventually passed to the ``ORDER BY`` `clause `_.
-
-Similarly, the ``limit`` and ``offset`` arguments can be used to limit the result to a subset of entities.
-
-For example, one could do the following:
-
-.. code-block:: python
-
- data = query.fetch(order_by='name', limit=10, offset=5)
-
-Note that an ``offset`` cannot be used without specifying a ``limit`` as well.
-
-Usage with Pandas
-~~~~~~~~~~~~~~~~~
-
-The ``pandas`` `library `_ is a popular library for data analysis in Python which can easily be used with DataJoint query results.
-Since the records returned by ``fetch()`` are contained within a ``numpy.recarray``, they can be easily converted to ``pandas.DataFrame`` objects by passing them into the ``pandas.DataFrame`` constructor.
-For example:
-
-.. code-block:: python
-
- import pandas as pd
- frame = pd.DataFrame(tab.fetch())
-
-Calling ``fetch()`` with the argument ``format="frame"`` returns results as ``pandas.DataFrame`` objects indexed by the table's primary key attributes.
-
-.. code-block:: python
-
- frame = tab.fetch(format="frame")
-
-Returning results as a ``DataFrame`` is not possible when fetching a particular subset of attributes or when ``as_dict`` is set to ``True``.
diff --git a/docs-parts/queries/04-Iteration_lang1.rst b/docs-parts/queries/04-Iteration_lang1.rst
deleted file mode 100644
index 1afb8b7e4..000000000
--- a/docs-parts/queries/04-Iteration_lang1.rst
+++ /dev/null
@@ -1,24 +0,0 @@
-
-In the simple example below, iteration is used to display the names and values of the attributes of each entity in the simple table or table expression ``tab``.
-
-.. code-block:: python
-
- for entity in tab:
- print(entity)
-
-This example illustrates the function of the iterator: DataJoint iterates through the whole table expression, returning the entire entity during each step.
-In this case, each entity will be returned as a ``dict`` containing all attributes.
-
-At the start of the above loop, DataJoint internally fetches only the primary keys of the entities in ``tab``.
-Since only the primary keys are needed to distinguish between entities, DataJoint can then iterate over the list of primary keys to execute the loop.
-At each step of the loop, DataJoint uses a single primary key to fetch an entire entity for use in the iteration, such that ``print(entity)`` will print all attributes of each entity.
-By first fetching only the primary keys and then fetching each entity individually, DataJoint saves memory at the cost of network overhead.
-This can be particularly useful for tables containing large amounts of data in secondary attributes.
-
-The memory savings of the above syntax may not be worth the additional network overhead in all cases, such as for tables with little data stored as secondary attributes.
-In the example below, DataJoint fetches all of the attributes of each entity in a single call and then iterates over the list of entities stored in memory.
-
-.. code-block:: python
-
- for entity in tab.fetch(as_dict=True):
- print(entity)
diff --git a/docs-parts/queries/06-Restriction_lang1.rst b/docs-parts/queries/06-Restriction_lang1.rst
deleted file mode 100644
index bff0f5e88..000000000
--- a/docs-parts/queries/06-Restriction_lang1.rst
+++ /dev/null
@@ -1,9 +0,0 @@
-
-* another table
-* a mapping, e.g. ``dict``
-* an expression in a character string
-* a collection of conditions as a ``list``, ``tuple``, or Pandas ``DataFrame``
-* a Boolean expression (``True`` or ``False``)
-* an ``AndList``
-* a ``Not`` object
-* a query expression
diff --git a/docs-parts/queries/06-Restriction_lang2.rst b/docs-parts/queries/06-Restriction_lang2.rst
deleted file mode 100644
index 29482c6fe..000000000
--- a/docs-parts/queries/06-Restriction_lang2.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-
-.. code-block:: python
-
- Session & {'session_dat': "2018-01-01"}
-
diff --git a/docs-parts/queries/06-Restriction_lang3.rst b/docs-parts/queries/06-Restriction_lang3.rst
deleted file mode 100644
index e04d86151..000000000
--- a/docs-parts/queries/06-Restriction_lang3.rst
+++ /dev/null
@@ -1,8 +0,0 @@
-
-.. code-block:: python
-
- # All the sessions performed by Alice
- Session & 'user = "Alice"'
-
- # All the experiments at least one minute long
- Experiment & 'duration >= 60'
diff --git a/docs-parts/queries/06-Restriction_lang4.rst b/docs-parts/queries/06-Restriction_lang4.rst
deleted file mode 100644
index 6302e764c..000000000
--- a/docs-parts/queries/06-Restriction_lang4.rst
+++ /dev/null
@@ -1,15 +0,0 @@
-
-A collection can be a list, a tuple, or a Pandas ``DataFrame``.
-
-.. code-block:: python
-
- # a list:
- cond_list = ['first_name = "Aaron"', 'last_name = "Aaronson"']
-
- # a tuple:
- cond_tuple = ('first_name = "Aaron"', 'last_name = "Aaronson"')
-
- # a dataframe:
- import pandas as pd
- cond_frame = pd.DataFrame(
- data={'first_name': ['Aaron'], 'last_name': ['Aaronson']})
diff --git a/docs-parts/queries/06-Restriction_lang5.rst b/docs-parts/queries/06-Restriction_lang5.rst
deleted file mode 100644
index a0f9dc2e1..000000000
--- a/docs-parts/queries/06-Restriction_lang5.rst
+++ /dev/null
@@ -1,11 +0,0 @@
-
-.. code-block:: python
-
- Student() & ['first_name = "Aaron"', 'last_name = "Aaronson"']
-
-.. figure:: ../_static/img/python_collection.png
- :align: center
- :alt: restriction by collection
-
- Restriction by a collection, returning all entities matching any condition in the collection.
-
diff --git a/docs-parts/queries/06-Restriction_lang6.rst b/docs-parts/queries/06-Restriction_lang6.rst
deleted file mode 100644
index c32575ec1..000000000
--- a/docs-parts/queries/06-Restriction_lang6.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-
-``A & True`` and ``A - False`` are equivalent to ``A``.
-
-``A & False`` and ``A - True`` are empty.
diff --git a/docs-parts/queries/06-Restriction_lang7.rst b/docs-parts/queries/06-Restriction_lang7.rst
deleted file mode 100644
index d57ae0630..000000000
--- a/docs-parts/queries/06-Restriction_lang7.rst
+++ /dev/null
@@ -1,18 +0,0 @@
-
-Restriction by an ``AndList``
------------------------------
-
-The special function ``dj.AndList`` represents logical conjunction (logical AND).
-Restriction of table ``A`` by an ``AndList`` will return all entities in ``A`` that meet *all* of the conditions in the list.
-``A & dj.AndList([c1, c2, c3])`` is equivalent to ``A & c1 & c2 & c3``.
-Usually, it is more convenient to simply write out all of the conditions, as ``A & c1 & c2 & c3``.
-However, when a list of conditions has already been generated, the list can simply be passed as the argument to ``dj.AndList``.
-
-Restriction of table ``A`` by an empty ``AndList``, as in ``A & dj.AndList([])``, will return all of the entities in ``A``.
-Exclusion by an empty ``AndList`` will return no entities.
-
-Restriction by a ``Not`` object
--------------------------------
-
-The special function ``dj.Not`` represents logical negation, such that ``A & dj.Not(cond)`` is equivalent to ``A - cond``.
-
diff --git a/docs-parts/queries/06-Restriction_lang8.rst b/docs-parts/queries/06-Restriction_lang8.rst
deleted file mode 100644
index a00bff9cf..000000000
--- a/docs-parts/queries/06-Restriction_lang8.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-.. code-block:: python
-
- query = Session & 'user = "Alice"'
- Experiment & query
diff --git a/docs-parts/queries/08-Proj_lang1.rst b/docs-parts/queries/08-Proj_lang1.rst
deleted file mode 100644
index 1843e087d..000000000
--- a/docs-parts/queries/08-Proj_lang1.rst
+++ /dev/null
@@ -1,3 +0,0 @@
-
-This is done using keyword arguments:
-``tab.proj(new_attr='old_attr')``
diff --git a/docs-parts/queries/08-Proj_lang2.rst b/docs-parts/queries/08-Proj_lang2.rst
deleted file mode 100644
index edadb0210..000000000
--- a/docs-parts/queries/08-Proj_lang2.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-
-.. code-block:: python
-
- tab.proj(animal='mouse', 'stimulus')
diff --git a/docs-parts/queries/08-Proj_lang3.rst b/docs-parts/queries/08-Proj_lang3.rst
deleted file mode 100644
index 08ec43285..000000000
--- a/docs-parts/queries/08-Proj_lang3.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-
-.. code-block:: python
-
- tab * tab.proj(other='cell')
diff --git a/docs-parts/queries/08-Proj_lang4.rst b/docs-parts/queries/08-Proj_lang4.rst
deleted file mode 100644
index c32bfd67b..000000000
--- a/docs-parts/queries/08-Proj_lang4.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-
-.. code-block:: python
-
- tab.proj(depth='scan_z-surface_z') & 'depth > 500'
diff --git a/docs-parts/queries/09-Aggr_lang1.rst b/docs-parts/queries/09-Aggr_lang1.rst
deleted file mode 100644
index 6bb99964d..000000000
--- a/docs-parts/queries/09-Aggr_lang1.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-
-.. code-block:: python
-
- # Number of students in each course section
- Section.aggr(Enroll, n="count(*)")
- # Average grade in each course
- Course.aggr(Grade * LetterGrade, avg_grade="avg(points)")
diff --git a/docs-parts/queries/11-Universal-Sets_lang1.rst b/docs-parts/queries/11-Universal-Sets_lang1.rst
deleted file mode 100644
index f80d231cd..000000000
--- a/docs-parts/queries/11-Universal-Sets_lang1.rst
+++ /dev/null
@@ -1,14 +0,0 @@
-
-.. code-block:: python
-
- # All home cities of students
- dj.U('home_city', 'home_state') & Student
-
- # Total number of students from each city
- dj.U('home_city', 'home_state').aggr(Student, n="count(*)")
-
- # Total number of students from each state
- U('home_state').aggr(Student, n="count(*)")
-
- # Total number of students in the database
- U().aggr(Student, n="count(*)")
diff --git a/docs-parts/queries/12-Query-Caching_lang1.rst b/docs-parts/queries/12-Query-Caching_lang1.rst
deleted file mode 100644
index 673eef85b..000000000
--- a/docs-parts/queries/12-Query-Caching_lang1.rst
+++ /dev/null
@@ -1,13 +0,0 @@
-
-.. code-block:: python
-
- # set the query cache path
- dj.config['query_cache'] = os.path.expanduser('~/dj_query_cache')
-
- # access the active connection object for the tables
- conn = dj.conn() # if queries co-located with tables
- conn = module.schema.connection # if schema co-located with tables
- conn = module.table.connection # most flexible
-
- # activate query caching for a namespace called 'main'
- conn.set_query_cache(query_cache='main')
diff --git a/docs-parts/queries/12-Query-Caching_lang2.rst b/docs-parts/queries/12-Query-Caching_lang2.rst
deleted file mode 100644
index 54661c6b7..000000000
--- a/docs-parts/queries/12-Query-Caching_lang2.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-
-.. code-block:: python
-
- # deactivate query caching
- conn.set_query_cache(query_cache=None)
- ## OR
- conn.set_query_cache()
diff --git a/docs-parts/queries/12-Query-Caching_lang3.rst b/docs-parts/queries/12-Query-Caching_lang3.rst
deleted file mode 100644
index 34e3784cd..000000000
--- a/docs-parts/queries/12-Query-Caching_lang3.rst
+++ /dev/null
@@ -1,6 +0,0 @@
-
-.. code-block:: python
-
- # purged the cached queries
- conn.purge_query_cache()
-
diff --git a/docs-parts/queries/13-Transpiler-Design_lang1.md b/docs-parts/queries/13-Transpiler-Design_lang1.md
deleted file mode 100644
index e015646e1..000000000
--- a/docs-parts/queries/13-Transpiler-Design_lang1.md
+++ /dev/null
@@ -1,113 +0,0 @@
-MySQL appears to differ from standard SQL by the sequence of evaluating the clauses of the SELECT statement.
-
-```
-Standard SQL: FROM > WHERE > GROUP BY > HAVING > SELECT
-MySQL: FROM > WHERE > SELECT > GROUP BY > HAVING
-```
-
-> TODO: verify with latest SQL standards and postgres / CockroachDB implementations and whether this order can be configured
-
-Moving `SELECT` to an earlier phase allows the `GROUP BY` and `HAVING` clauses to use alias column names created by the `SELECT` clause.
-The current implementation targets the MySQL implementation where table column aliases can be used in `HAVING`.
-If postgres or CockroachDB cannot be coerced to work this way, restrictions of aggregations will have to be updated accordingly.
-
-## QueryExpression
-`QueryExpression` is the main object representing a distinct `SELECT` statement.
-It implements operators `&`, `*`, and `proj` — restriction, join, and projection.
-
-Property `heading` describes all attributes.
-
-Operator `proj` creates a new heading.
-
-Property `restriction` contains the `AndList` of conditions. Operator `&` creates a new restriction appending the new condition to the input's restriction.
-
-Property `support` represents the `FROM` clause and contains a list of either `QueryExpression` objects or table names in the case of base queries.
-The joint operator `*` adds new elements to the `support` attribute.
-
-At least one element must be present in `support`. Multiple elements in `support` indicate a join.
-
-From the user's perspective `QueryExpression` objects are immutable: once created they cannot be modified. All operators derive new objects.
-
-### Alias attributes
-`proj` can create an alias attribute by renaming an existing attribute or calculating a new attribute.
-Alias attributes are the primary reason why subqueries are sometimes required.
-
-### Subqueries
-Projections, restrictions, and joins do not necessarily trigger new subqueries: the resulting `QueryExpression` object simply merges the properties of its inputs into self: `heading`, `restriction`, and `support`.
-
-The input object is treated as a subquery in the following cases:
-1. A restriction is applied that uses alias attributes in the heading
-1. A projection uses an alias attribute to create a new alias attribute.
-1. A join is performed on an alias attribute.
-1. An Aggregation is used a restriction.
-
-An error arises if
-1. If a restriction or a projection attempts to use attributes not in the current heading.
-2. If attempting to join on attributes that are not join-compatible
-3. If attempting to restrict by a non-join-compatible expression
-
-A subquery is created by creating a new `QueryExpression` object (or a subclass object) with its `support` pointing to the input object.
-
-### Join compatibility
-The join is always natural (i.e. *equijoin* on the namesake attributes).
-
-**Before version 0.13:** As of version `0.12.*` and earlier, two query expressions were considered join-compatible if their namesake attributes were the primary key of at least one of the input expressions. This rule was easiest to implement but does not provide best semantics.
-
-**Version 0.13:** In version `0.13.*`, two query expressions are considered join-compatible if their namesake attributes are either in the primary key or in a foreign key in both input expressions.
-
- **Future (potentially version 0.14+):**
- This compatibility requirement will be further restricted to require that the namesake attributes ultimately derive from the same primary key attribute by being passed down through foreign keys.
-
-The same join compatibility rules apply when restricting one query expression with another.
-
-### Join mechanics
-Any restriction applied to the inputs of a join can be applied to its output.
-Therefore, those inputs that are not turned into queries donate their supports, restrictions, and projections to the join itself.
-
-## Table
-`Table` is a subclass of `QueryExpression` implementing table manipulation methods such as `insert`, `insert1`, `delete`, `update1`, and `drop`.
-
-The restriction operator `&` applied to a `Table` preserves its class identity so that the result remains of type `Table`.
-However, `proj` converts the result into a `QueryExpression` object. This may produce a base query that is not an instance of Table.
-
-## Aggregation
-`Aggregation` is a subclass of `QueryExpression`.
-Its main input is the *aggregating* query expression and it takes an additional second input — the *aggregated* query expression.
-
-The SQL equivalent of aggregation is
-1. the NATURAL LEFT JOIN of the two inputs.
-1. followed by a GROUP BY on the primary key arguments of the first input
-1. followed by a projection.
-
-The projection works the same as `.proj` with respect to the first input.
-With respect to the second input, the projection part of aggregation allows only calculated attributes that use aggregating functions (*eg* `SUM`, `AVG`, `COUNT`) applied to the attributes of the aggregated (second) input and non-aggregating functions on the attribute of the aggregating (first) input.
-
-`Aggregation` supports all the same operators as `QueryExpression` except:
-1. `restriction` turns into a `HAVING` clause instead of a `WHERE` clause. This allows applying any valid restriction without making a subquery (at least for MySQL). Therefore, restricting an `Aggregation` object never results in a subquery.
-2. In joins, aggregation always turns into a subquery.
-
-All other rules for subqueries remain the same as for `QueryExpression`
-
-## Union
-`Union` is a subclass of `QueryExpression`.
-A `Union` object results from the `+` operator on two `QueryExpression` objects.
-Its `support` property contains the list of expressions (at least two) to unify.
-Thus the `+` operator on unions simply merges their supports, making a bigger union.
-
-The `Union` operator performs an OUTER JOIN of its inputs provided that the inputs have the same primary key and no secondary attributes in common.
-
-Union treats all its inputs as subqueries except for unrestricted Union objects.
-
-## Universal Sets `dj.U`
-`dj.U` is a special operand in query expressions that allows performing special operations. By itself, it can never form a query and is not a subclass of `QueryExpression`. Other query expressions are modified through participation in operations with `dj.U`.
-
-### Aggegating by `dj.U`
-
-### Resttricting a `dj.U` object with a `QueryExpression` object
-
-### Joining a `dj.U` object
-
-## Query "Backprojection"
-Once a QueryExpression is used in a `fetch` operation or becomes a subquery in another query, it can project out all unnecessary attributes from its own inputs, recursively.
-This is implemented by the `finalize` method.
-This simplification produces much leaner queries resulting in improved query performance in version 0.13, especially on complex queries with blob data, compensating for MySQL's deficiencies in query optimization.
diff --git a/docs-parts/setup/01-Install-and-Connect_lang1.rst b/docs-parts/setup/01-Install-and-Connect_lang1.rst
deleted file mode 100644
index 6bb1a9e52..000000000
--- a/docs-parts/setup/01-Install-and-Connect_lang1.rst
+++ /dev/null
@@ -1,53 +0,0 @@
-
-DataJoint is implemented for Python 3.4+.
-You may install it from `PyPI `_:
-
-::
-
- pip3 install datajoint
-
-or upgrade
-
-::
-
- pip3 install --upgrade datajoint
-
-Next configure the connection through DataJoint's ``config`` object:
-
-.. code-block:: python
-
- In [1]: import datajoint as dj
- DataJoint 0.4.9 (February 1, 2017)
- No configuration found. Use `dj.config` to configure and save the configuration.
-
-You may now set the database credentials:
-
-.. code-block:: python
-
- In [2]: dj.config['database.host'] = "alicelab.datajoint.io"
- In [3]: dj.config['database.user'] = "alice"
- In [4]: dj.config['database.password'] = "haha not my real password"
-
-Skip setting the password to make DataJoint prompt to enter the password every time.
-
-You may save the configuration in the local work directory with ``dj.config.save_local()`` or for all your projects in ``dj.config.save_global()``.
-Configuration changes should be made through the ``dj.config`` interface; the config file should not be modified directly by the user.
-
-You may leave the user or the password as ``None``, in which case you will be prompted to enter them manually for every session.
-Setting the password as an empty string allows access without a password.
-
-Note that the system environment variables ``DJ_HOST``, ``DJ_USER``, and ``DJ_PASS`` will overwrite the settings in the config file.
-You can use them to set the connection credentials instead of config files.
-
-To change the password, the ``dj.set_password`` function will walk you through the process:
-
-::
-
- >>> dj.set_password()
-
-After that, update the password in the configuration and save it as described above:
-
-.. code-block:: python
-
- dj.config['database.password'] = 'my#cool!new*psswrd'
- dj.config.save_local() # or dj.config.save_global()
diff --git a/docs-parts/version_common.json b/docs-parts/version_common.json
deleted file mode 100644
index 2296f52e6..000000000
--- a/docs-parts/version_common.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "comm_version": "v0.2"
-}
\ No newline at end of file
diff --git a/images/pipeline.png b/images/pipeline.png
new file mode 100644
index 000000000..48f5f3ecd
Binary files /dev/null and b/images/pipeline.png differ
diff --git a/images/pipeline.svg b/images/pipeline.svg
new file mode 100644
index 000000000..94154a0c7
--- /dev/null
+++ b/images/pipeline.svg
@@ -0,0 +1,118 @@
+
diff --git a/local-docker-compose.yml b/local-docker-compose.yml
deleted file mode 100644
index 19a8c7ae2..000000000
--- a/local-docker-compose.yml
+++ /dev/null
@@ -1,113 +0,0 @@
-# docker-compose -f local-docker-compose.yml --env-file LNX.env up --build
-version: '2.2'
-x-net: &net
- networks:
- - main
-services:
- db:
- <<: *net
- image: datajoint/mysql:$MYSQL_VER
- environment:
- - MYSQL_ROOT_PASSWORD=simple
- # ports:
- # - "3306:3306"
- # To persist MySQL data
- # volumes:
- # - ./mysql/data:/var/lib/mysql
- minio:
- <<: *net
- image: minio/minio:$MINIO_VER
- environment:
- - MINIO_ACCESS_KEY=datajoint
- - MINIO_SECRET_KEY=datajoint
- # ports:
- # - "9000:9000"
- # To persist MinIO data and config
- # volumes:
- # - ./minio/data:/data
- # - ./minio/config:/root/.minio
- command: server --address ":9000" /data
- healthcheck:
- test: ["CMD", "curl", "--fail", "http://minio:9000/minio/health/live"]
- timeout: 5s
- retries: 60
- interval: 1s
- fakeservices.datajoint.io:
- <<: *net
- image: datajoint/nginx:v0.0.19
- environment:
- - ADD_db_TYPE=DATABASE
- - ADD_db_ENDPOINT=db:3306
- - ADD_minio_TYPE=MINIO
- - ADD_minio_ENDPOINT=minio:9000
- - ADD_minio_PORT=80 # allow unencrypted connections
- - ADD_minio_PREFIX=/datajoint
- - ADD_browser_TYPE=MINIOADMIN
- - ADD_browser_ENDPOINT=minio:9000
- - ADD_browser_PORT=80 # allow unencrypted connections
- ports:
- - "80:80"
- - "443:443"
- - "3306:3306"
- - "9000:9000"
- depends_on:
- db:
- condition: service_healthy
- minio:
- condition: service_healthy
- app:
- <<: *net
- image: datajoint/pydev:${PY_VER}-alpine${ALPINE_VER}
- depends_on:
- fakeservices.datajoint.io:
- condition: service_healthy
- environment:
- - DJ_HOST=fakeservices.datajoint.io
- - DJ_USER=root
- - DJ_PASS=simple
- - DJ_TEST_HOST=fakeservices.datajoint.io
- - DJ_TEST_USER=datajoint
- - DJ_TEST_PASSWORD=datajoint
- # If running tests locally, make sure to add entry in /etc/hosts for 127.0.0.1 fakeservices.datajoint.io
- - S3_ENDPOINT=fakeservices.datajoint.io
- - S3_ACCESS_KEY=datajoint
- - S3_SECRET_KEY=datajoint
- - S3_BUCKET=datajoint.test
- - PYTHON_USER=dja
- - JUPYTER_PASSWORD=datajoint
- - DISPLAY
- working_dir: /src
- command:
- - sh
- - -c
- - |
- set -e
- pip install --user nose nose-cov coveralls flake8 ptvsd
- pip install -e .
- pip freeze | grep datajoint
- ## You may run the below tests once sh'ed into container i.e. docker exec -it datajoint-python_app_1 sh
- # nosetests -vsw tests; #run all tests
- # nosetests -vs --tests=tests.test_external_class:test_insert_and_fetch; #run specific basic test
- # nosetests -vs --tests=tests.test_fetch:TestFetch.test_getattribute_for_fetch1; #run specific Class test
- # flake8 datajoint --count --select=E9,F63,F7,F82 --show-source --statistics
- # flake8 --ignore=E121,E123,E126,E226,E24,E704,W503,W504,E722,F401,W605 datajoint --count --max-complexity=62 --max-line-length=127 --statistics
- ## Interactive Jupyter Notebook environment
- jupyter notebook &
- ## Remote debugger
- while true
- do
- python -m ptvsd --host 0.0.0.0 --port 5678 --wait .
- sleep 2
- done
- ports:
- - "8888:8888"
- - "5678:5678"
- user: ${UID}:${GID}
- volumes:
- - .:/src
- - /tmp/.X11-unix:/tmp/.X11-unix:rw
- # Additional mounted notebooks may go here
- # - ./notebook:/home/dja/notebooks
- # - ../dj-python-101/ch1:/home/dja/tutorials
-networks:
- main:
diff --git a/pixi.lock b/pixi.lock
new file mode 100644
index 000000000..0421929da
--- /dev/null
+++ b/pixi.lock
@@ -0,0 +1,6805 @@
+version: 6
+environments:
+ default:
+ channels:
+ - url: https://conda.anaconda.org/conda-forge/
+ indexes:
+ - https://pypi.org/simple
+ options:
+ pypi-prerelease-mode: if-necessary-or-explicit
+ packages:
+ linux-64:
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-48.1-unix_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/at-spi2-atk-2.38.0-h0630a04_3.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/at-spi2-core-2.40.3-h0630a04_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/atk-1.0-2.38.0-h04ea711_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.8.3-hbd8a1cb_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-h3394656_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h3c4dab8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/epoxy-1.5.10-h166bdaf_1.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.15.0-h7e30c49_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.1-ha770c72_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.1-h2b0a6b4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.86.0-hf516916_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/graphviz-13.1.2-h87b6fe6_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/gtk3-3.24.43-h0c6a113_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/gts-0.7.6-h977cf35_4.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-11.5.0-h15599e2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/hicolor-icon-theme-0.17-ha770c72_2.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-h1423503_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.0.0-h0aef613_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-hb8b1518_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.24-h86f0d12_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.6-h2dba641_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.1.0-h767d61c_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.1.0-h69a702a_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgd-2.3.3-h6f5c62b_11.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.86.0-h1fed272_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.1.0-h767d61c_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.0-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.50-h421ea60_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.58.4-he92a37e_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.50.4-h0c1763c_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.1.0-h8f9b012_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.1.0-h4852527_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.0-h8261f1e_6.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.1-he9a06e4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.11.0-he8b52b9_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.13.8-h04c0eec_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.5.2-h26f9b46_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.56.4-hadf4263_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.46-h1321c63_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.7-h2b335a9_100_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.24.0-h3e06ad9_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.45-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.12-h4f16b4b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.6-hb9d3cd8_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.6-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.1-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.2-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxinerama-1.1.5-h5888daf_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.4-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda
+ - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9a/a5/bf3db6e66c4b160d6ea10b534c381a1955dfab34cb1017ea93aa33c70ed3/numpy-2.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8f/52/0634adaace9be2d8cac9ef78f05c47f3a675882e068438b9d7ec7ef0c13f/pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/53/b8/fbab973592e23ae313042d450fc26fa24282ebffba21ba373786e1ce63b4/pyparsing-3.2.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl
+ - pypi: ./
+ linux-aarch64:
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-2_gnu.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/at-spi2-atk-2.38.0-h1f2db35_3.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/at-spi2-core-2.40.3-h1f2db35_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/atk-1.0-2.38.0-hedc4a1f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_8.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cairo-1.18.4-h83712da_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/dbus-1.16.2-heda779d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/epoxy-1.5.10-he30d5cf_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/fontconfig-2.15.0-h8dda3cd_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/freetype-2.14.1-h8af1aa0_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/fribidi-1.0.16-he30d5cf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gdk-pixbuf-2.44.4-h90308e0_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/glib-tools-2.86.1-hc87f4d4_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/graphite2-1.3.14-hfae3067_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/graphviz-13.1.2-hdb06ba2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gtk3-3.24.43-h4cd1324_6.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gts-0.7.6-he293c15_4.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/harfbuzz-12.2.0-he4899c9_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/hicolor-icon-theme-0.17-h8af1aa0_2.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/icu-75.1-hf9b3779_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/keyutils-1.6.3-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/krb5-1.21.3-h50a48e9_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.44-hd32f0e1_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/lerc-4.0.0-hfdc4d58_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libcups-2.3.3-h5cdc715_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libdeflate-1.25-h1af38f5_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libdrm-2.4.125-he30d5cf_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libedit-3.1.20250104-pl5321h976ea20_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libegl-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libegl-devel-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.7.1-hfae3067_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-hd65408f_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype-2.14.1-h8af1aa0_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype6-2.14.1-hdae7a39_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-he277a41_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-15.2.0-he9431aa_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgd-2.3.3-hc8d7b1d_11.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgl-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgl-devel-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglib-2.86.1-he84ff74_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglvnd-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglx-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglx-devel-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-he277a41_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libiconv-1.18-h90929bb_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libjpeg-turbo-3.1.2-he30d5cf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.1-h86ecc28_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libpciaccess-0.18-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libpng-1.6.50-h1abf092_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/librsvg-2.60.0-h8171147_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.51.0-h022381a_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-h3f4de04_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-ng-15.2.0-hf1166c9_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtiff-4.7.1-hdb009f0_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.41.2-h3e4203c_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libwebp-base-1.6.0-ha2e29f5_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxcb-1.17.0-h262b8f6_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxkbcommon-1.13.0-h3c6a4c8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-16-2.15.1-h8591a01_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-2.15.1-h788dabe_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h86ecc28_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-ha32ae93_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.5.4-h8e36d6e_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pango-1.56.4-he55ef5b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pcre2-10.46-h15761aa_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pixman-0.46.4-h7ac5ae9_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pthread-stubs-0.4-h86ecc28_1002.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.13.9-h4c0d347_101_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.2-h8382b9d_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h5688188_102.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/wayland-1.24.0-h4f8a99f_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xkeyboard-config-2.46-he30d5cf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libice-1.1.2-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libsm-1.2.6-h0808dbd_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libx11-1.8.12-hca56bd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxau-1.0.12-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxcomposite-0.4.6-h86ecc28_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxcursor-1.2.3-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxdamage-1.1.6-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxdmcp-1.1.5-h57736b2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxext-1.3.6-h57736b2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxfixes-6.0.2-he30d5cf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxi-1.8.2-h57736b2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxinerama-1.1.5-h5ad3122_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrandr-1.5.4-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrender-0.9.12-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxtst-1.2.5-h57736b2_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxxf86vm-1.1.6-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-xorgproto-2024.1-h86ecc28_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-hbcf94c1_2.conda
+ - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl
+ - pypi: ./
+ osx-arm64:
+ - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/atk-1.0-2.38.0-hd03087b_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cairo-1.18.4-h6a3b0d2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/epoxy-1.5.10-hc919400_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fontconfig-2.15.0-h1383a14_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.1-hce30654_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fribidi-1.0.16-hc919400_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gdk-pixbuf-2.44.4-h7542897_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-tools-2.86.1-hb9d6e3a_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphite2-1.3.14-hec049ff_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphviz-13.1.2-hcd33d8b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gtk3-3.24.43-h5febe37_6.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gts-0.7.6-he42f4ea_4.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-12.1.0-haf38c7b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hicolor-icon-theme-0.17-hce30654_2.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-75.1-hfee45f7_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lerc-4.0.0-hd64df32_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.4-hf598326_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.24-h5773f1b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.1-hec049ff_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-he5f378a_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.1-hce30654_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.1-h6da58f4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgd-2.3.3-hb2c3a21_11.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.86.1-he69a767_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.1.0-h5505292_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.50-h280e0eb_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/librsvg-2.60.0-h5c55ec3_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.50.4-h4237e3c_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.1-h7dc4979_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h07db88b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.1-h0ff4647_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.5.4-h5503f6c_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pango-1.56.4-h875632e_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.46-h7125dd6_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pixman-0.46.4-h81086ad_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.9-hfc2f54d_101_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-h6491c7d_2.conda
+ - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl
+ - pypi: ./
+ dev:
+ channels:
+ - url: https://conda.anaconda.org/conda-forge/
+ indexes:
+ - https://pypi.org/simple
+ options:
+ pypi-prerelease-mode: if-necessary-or-explicit
+ packages:
+ linux-64:
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-48.1-unix_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/at-spi2-atk-2.38.0-h0630a04_3.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/at-spi2-core-2.40.3-h0630a04_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/atk-1.0-2.38.0-h04ea711_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.8.3-hbd8a1cb_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-h3394656_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h3c4dab8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/epoxy-1.5.10-h166bdaf_1.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.15.0-h7e30c49_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.1-ha770c72_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.1-h2b0a6b4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.86.0-hf516916_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/graphviz-13.1.2-h87b6fe6_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/gtk3-3.24.43-h0c6a113_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/gts-0.7.6-h977cf35_4.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-11.5.0-h15599e2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/hicolor-icon-theme-0.17-ha770c72_2.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-h1423503_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.0.0-h0aef613_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-hb8b1518_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.24-h86f0d12_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.6-h2dba641_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.1.0-h767d61c_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.1.0-h69a702a_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgd-2.3.3-h6f5c62b_11.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.86.0-h1fed272_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.1.0-h767d61c_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.0-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.50-h421ea60_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.58.4-he92a37e_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.50.4-h0c1763c_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.1.0-h8f9b012_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.1.0-h4852527_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.0-h8261f1e_6.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.1-he9a06e4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.11.0-he8b52b9_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.13.8-h04c0eec_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.5.2-h26f9b46_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.56.4-hadf4263_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.46-h1321c63_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.7-h2b335a9_100_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.24.0-h3e06ad9_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.45-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.12-h4f16b4b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.6-hb9d3cd8_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.6-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.1-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.2-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxinerama-1.1.5-h5888daf_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.4-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda
+ - pypi: https://files.pythonhosted.org/packages/16/54/a295bd8d7ac900c339b2c7024ed0ff9538afb60e92eb0979b8bb49deb20e/aiobotocore-3.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/51/08f32aea872253173f513ba68122f4300966290677c8e59887b4ffd5d957/botocore-1.42.70-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/20/01/b394922252051e97aab231d416c86da3d8a6d781eeadcdca1082867de64e/codespell-2.4.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/73/dd/508420fb47d09d904d962f123221bc249f64b5e56aa93d5f5f7603be475f/coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f5/11/02ebebb09ff2104b690457cb7bc6ed700c9e0ce88cf581486bb0a5d3c88b/faker-37.8.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f2/9f/bf231c2a3fac99d1d7f1d89c76594f158693f981a4aa02be406e9f036832/fonttools-4.59.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/08/2a/5628a99d04acb2d2f2e749cdf4ea571d2575e898df0528a090948018b726/ipython-9.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e5/b8/9eea6630198cb303d131d95d285a024b3b8645b1763a2916fddb44ca8760/matplotlib-3.10.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/89/a3/00260f8df72b51afa1f182dd609533c77fa2407918c4c2813d87b4a56725/minio-7.2.16-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9a/a5/bf3db6e66c4b160d6ea10b534c381a1955dfab34cb1017ea93aa33c70ed3/numpy-2.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8f/52/0634adaace9be2d8cac9ef78f05c47f3a675882e068438b9d7ec7ef0c13f/pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b0/15/fc3e43f3fdf3f20b7dfb5abe871ab6162cf8fb4aeabf4cfad822d5dc4c79/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/53/b8/fbab973592e23ae313042d450fc26fa24282ebffba21ba373786e1ce63b4/pyparsing-3.2.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f3/51/0489a6a5595b7760b5dbac0dd82852b510326e7d88d51dbffcd2e07e3ff3/ruff-0.14.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: ./
+ linux-aarch64:
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-2_gnu.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/at-spi2-atk-2.38.0-h1f2db35_3.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/at-spi2-core-2.40.3-h1f2db35_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/atk-1.0-2.38.0-hedc4a1f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_8.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cairo-1.18.4-h83712da_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/dbus-1.16.2-heda779d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/epoxy-1.5.10-he30d5cf_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/fontconfig-2.15.0-h8dda3cd_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/freetype-2.14.1-h8af1aa0_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/fribidi-1.0.16-he30d5cf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gdk-pixbuf-2.44.4-h90308e0_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/glib-tools-2.86.1-hc87f4d4_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/graphite2-1.3.14-hfae3067_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/graphviz-13.1.2-hdb06ba2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gtk3-3.24.43-h4cd1324_6.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gts-0.7.6-he293c15_4.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/harfbuzz-12.2.0-he4899c9_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/hicolor-icon-theme-0.17-h8af1aa0_2.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/icu-75.1-hf9b3779_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/keyutils-1.6.3-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/krb5-1.21.3-h50a48e9_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.44-hd32f0e1_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/lerc-4.0.0-hfdc4d58_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libcups-2.3.3-h5cdc715_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libdeflate-1.25-h1af38f5_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libdrm-2.4.125-he30d5cf_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libedit-3.1.20250104-pl5321h976ea20_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libegl-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libegl-devel-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.7.1-hfae3067_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-hd65408f_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype-2.14.1-h8af1aa0_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype6-2.14.1-hdae7a39_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-he277a41_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-15.2.0-he9431aa_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgd-2.3.3-hc8d7b1d_11.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgl-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgl-devel-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglib-2.86.1-he84ff74_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglvnd-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglx-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglx-devel-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-he277a41_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libiconv-1.18-h90929bb_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libjpeg-turbo-3.1.2-he30d5cf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.1-h86ecc28_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libpciaccess-0.18-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libpng-1.6.50-h1abf092_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/librsvg-2.60.0-h8171147_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.51.0-h022381a_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-h3f4de04_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-ng-15.2.0-hf1166c9_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtiff-4.7.1-hdb009f0_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.41.2-h3e4203c_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libwebp-base-1.6.0-ha2e29f5_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxcb-1.17.0-h262b8f6_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxkbcommon-1.13.0-h3c6a4c8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-16-2.15.1-h8591a01_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-2.15.1-h788dabe_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h86ecc28_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-ha32ae93_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.5.4-h8e36d6e_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pango-1.56.4-he55ef5b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pcre2-10.46-h15761aa_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pixman-0.46.4-h7ac5ae9_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pthread-stubs-0.4-h86ecc28_1002.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.13.9-h4c0d347_101_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.2-h8382b9d_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h5688188_102.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/wayland-1.24.0-h4f8a99f_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xkeyboard-config-2.46-he30d5cf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libice-1.1.2-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libsm-1.2.6-h0808dbd_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libx11-1.8.12-hca56bd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxau-1.0.12-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxcomposite-0.4.6-h86ecc28_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxcursor-1.2.3-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxdamage-1.1.6-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxdmcp-1.1.5-h57736b2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxext-1.3.6-h57736b2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxfixes-6.0.2-he30d5cf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxi-1.8.2-h57736b2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxinerama-1.1.5-h5ad3122_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrandr-1.5.4-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrender-0.9.12-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxtst-1.2.5-h57736b2_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxxf86vm-1.1.6-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-xorgproto-2024.1-h86ecc28_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-hbcf94c1_2.conda
+ - pypi: https://files.pythonhosted.org/packages/16/54/a295bd8d7ac900c339b2c7024ed0ff9538afb60e92eb0979b8bb49deb20e/aiobotocore-3.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/51/08f32aea872253173f513ba68122f4300966290677c8e59887b4ffd5d957/botocore-1.42.70-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/20/01/b394922252051e97aab231d416c86da3d8a6d781eeadcdca1082867de64e/codespell-2.4.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/2e/7a/34c9402ad12bce609be4be1146a7d22a7fae8e9d752684b6315cce552a65/coverage-7.11.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/04/05/06b1455e4bc653fcb2117ac3ef5fa3a8a14919b93c60742d04440605d058/fonttools-4.60.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/05/aa/62893d6a591d337aa59dcc4c6f6c842f1fe20cd72c8c5c1f980255243252/ipython-9.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/d0/7f/ccdca06f4c2e6c7989270ed7829b8679466682f4cfc0f8c9986241c023b6/matplotlib-3.10.7-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7d/ae/f32695da4f93de50dd7075100dab8cf689a9d96270f58ce6f940fd044a3e/minio-7.2.18-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3d/3e/e65236d9d0d9babfa0ecba593413c06530fca60a8feb8f66243aa5dba92e/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/e9/08840ff5127916bb989c86f18924fd568938b06f58b60e206176f327c0fe/ruff-0.14.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: ./
+ osx-arm64:
+ - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/atk-1.0-2.38.0-hd03087b_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cairo-1.18.4-h6a3b0d2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/epoxy-1.5.10-hc919400_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fontconfig-2.15.0-h1383a14_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.1-hce30654_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fribidi-1.0.16-hc919400_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gdk-pixbuf-2.44.4-h7542897_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-tools-2.86.1-hb9d6e3a_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphite2-1.3.14-hec049ff_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphviz-13.1.2-hcd33d8b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gtk3-3.24.43-h5febe37_6.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gts-0.7.6-he42f4ea_4.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-12.1.0-haf38c7b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hicolor-icon-theme-0.17-hce30654_2.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-75.1-hfee45f7_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lerc-4.0.0-hd64df32_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.4-hf598326_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.24-h5773f1b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.1-hec049ff_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-he5f378a_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.1-hce30654_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.1-h6da58f4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgd-2.3.3-hb2c3a21_11.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.86.1-he69a767_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.1.0-h5505292_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.50-h280e0eb_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/librsvg-2.60.0-h5c55ec3_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.50.4-h4237e3c_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.1-h7dc4979_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h07db88b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.1-h0ff4647_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.5.4-h5503f6c_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pango-1.56.4-h875632e_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.46-h7125dd6_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pixman-0.46.4-h81086ad_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.9-hfc2f54d_101_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-h6491c7d_2.conda
+ - pypi: https://files.pythonhosted.org/packages/16/54/a295bd8d7ac900c339b2c7024ed0ff9538afb60e92eb0979b8bb49deb20e/aiobotocore-3.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/51/08f32aea872253173f513ba68122f4300966290677c8e59887b4ffd5d957/botocore-1.42.70-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl
+ - pypi: https://files.pythonhosted.org/packages/20/01/b394922252051e97aab231d416c86da3d8a6d781eeadcdca1082867de64e/codespell-2.4.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl
+ - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7c/5b/cdd2c612277b7ac7ec8c0c9bc41812c43dc7b2d5f2b0897e15fdf5a1f915/fonttools-4.60.1-cp313-cp313-macosx_10_13_universal2.whl
+ - pypi: https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/48/c5/d5e07995077e48220269c28a221e168c91123ad5ceee44d548f54a057fc0/ipython-9.6.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/bc/d0/b3d3338d467d3fc937f0bb7f256711395cae6f78e22cef0656159950adf0/matplotlib-3.10.7-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7d/ae/f32695da4f93de50dd7075100dab8cf689a9d96270f58ce6f940fd044a3e/minio-7.2.18-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/90/bf/297716b3095fe719be20fcf7af1d2b6ab069c38199bbace2469608a69b3a/polars_runtime_32-1.39.3-cp310-abi3-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7d/f8/2be49047f929d6965401855461e697ab185e1a6a683d914c5c19c7962d9e/ruff-0.14.9-py3-none-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: ./
+ test:
+ channels:
+ - url: https://conda.anaconda.org/conda-forge/
+ indexes:
+ - https://pypi.org/simple
+ options:
+ pypi-prerelease-mode: if-necessary-or-explicit
+ packages:
+ linux-64:
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-48.1-unix_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/at-spi2-atk-2.38.0-h0630a04_3.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/at-spi2-core-2.40.3-h0630a04_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/atk-1.0-2.38.0-h04ea711_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.8.3-hbd8a1cb_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-h3394656_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h3c4dab8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/epoxy-1.5.10-h166bdaf_1.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.15.0-h7e30c49_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.1-ha770c72_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.1-h2b0a6b4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.86.0-hf516916_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/graphviz-13.1.2-h87b6fe6_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/gtk3-3.24.43-h0c6a113_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/gts-0.7.6-h977cf35_4.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-11.5.0-h15599e2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/hicolor-icon-theme-0.17-ha770c72_2.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-h1423503_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.0.0-h0aef613_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-hb8b1518_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.24-h86f0d12_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.6-h2dba641_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.1.0-h767d61c_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.1.0-h69a702a_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgd-2.3.3-h6f5c62b_11.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.86.0-h1fed272_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.1.0-h767d61c_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.0-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.50-h421ea60_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.58.4-he92a37e_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.50.4-h0c1763c_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.1.0-h8f9b012_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.1.0-h4852527_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.0-h8261f1e_6.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.1-he9a06e4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.11.0-he8b52b9_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.13.8-h04c0eec_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.5.2-h26f9b46_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.56.4-hadf4263_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.46-h1321c63_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.7-h2b335a9_100_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.24.0-h3e06ad9_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.45-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.12-h4f16b4b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.6-hb9d3cd8_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.6-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.1-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.2-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxinerama-1.1.5-h5888daf_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.4-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda
+ - pypi: https://files.pythonhosted.org/packages/16/54/a295bd8d7ac900c339b2c7024ed0ff9538afb60e92eb0979b8bb49deb20e/aiobotocore-3.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/51/08f32aea872253173f513ba68122f4300966290677c8e59887b4ffd5d957/botocore-1.42.70-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/73/dd/508420fb47d09d904d962f123221bc249f64b5e56aa93d5f5f7603be475f/coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f5/11/02ebebb09ff2104b690457cb7bc6ed700c9e0ce88cf581486bb0a5d3c88b/faker-37.8.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f2/9f/bf231c2a3fac99d1d7f1d89c76594f158693f981a4aa02be406e9f036832/fonttools-4.59.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/08/2a/5628a99d04acb2d2f2e749cdf4ea571d2575e898df0528a090948018b726/ipython-9.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e5/b8/9eea6630198cb303d131d95d285a024b3b8645b1763a2916fddb44ca8760/matplotlib-3.10.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/89/a3/00260f8df72b51afa1f182dd609533c77fa2407918c4c2813d87b4a56725/minio-7.2.16-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9a/a5/bf3db6e66c4b160d6ea10b534c381a1955dfab34cb1017ea93aa33c70ed3/numpy-2.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8f/52/0634adaace9be2d8cac9ef78f05c47f3a675882e068438b9d7ec7ef0c13f/pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b0/15/fc3e43f3fdf3f20b7dfb5abe871ab6162cf8fb4aeabf4cfad822d5dc4c79/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/53/b8/fbab973592e23ae313042d450fc26fa24282ebffba21ba373786e1ce63b4/pyparsing-3.2.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: ./
+ linux-aarch64:
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-2_gnu.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/at-spi2-atk-2.38.0-h1f2db35_3.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/at-spi2-core-2.40.3-h1f2db35_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/atk-1.0-2.38.0-hedc4a1f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_8.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cairo-1.18.4-h83712da_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/dbus-1.16.2-heda779d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/epoxy-1.5.10-he30d5cf_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/fontconfig-2.15.0-h8dda3cd_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/freetype-2.14.1-h8af1aa0_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/fribidi-1.0.16-he30d5cf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gdk-pixbuf-2.44.4-h90308e0_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/glib-tools-2.86.1-hc87f4d4_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/graphite2-1.3.14-hfae3067_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/graphviz-13.1.2-hdb06ba2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gtk3-3.24.43-h4cd1324_6.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gts-0.7.6-he293c15_4.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/harfbuzz-12.2.0-he4899c9_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/hicolor-icon-theme-0.17-h8af1aa0_2.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/icu-75.1-hf9b3779_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/keyutils-1.6.3-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/krb5-1.21.3-h50a48e9_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.44-hd32f0e1_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/lerc-4.0.0-hfdc4d58_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libcups-2.3.3-h5cdc715_5.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libdeflate-1.25-h1af38f5_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libdrm-2.4.125-he30d5cf_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libedit-3.1.20250104-pl5321h976ea20_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libegl-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libegl-devel-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.7.1-hfae3067_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-hd65408f_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype-2.14.1-h8af1aa0_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype6-2.14.1-hdae7a39_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-he277a41_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-15.2.0-he9431aa_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgd-2.3.3-hc8d7b1d_11.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgl-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgl-devel-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglib-2.86.1-he84ff74_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglvnd-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglx-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglx-devel-1.7.0-hd24410f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-he277a41_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libiconv-1.18-h90929bb_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libjpeg-turbo-3.1.2-he30d5cf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.1-h86ecc28_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libpciaccess-0.18-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libpng-1.6.50-h1abf092_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/librsvg-2.60.0-h8171147_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.51.0-h022381a_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-h3f4de04_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-ng-15.2.0-hf1166c9_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtiff-4.7.1-hdb009f0_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.41.2-h3e4203c_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libwebp-base-1.6.0-ha2e29f5_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxcb-1.17.0-h262b8f6_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxkbcommon-1.13.0-h3c6a4c8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-16-2.15.1-h8591a01_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-2.15.1-h788dabe_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h86ecc28_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-ha32ae93_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.5.4-h8e36d6e_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pango-1.56.4-he55ef5b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pcre2-10.46-h15761aa_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pixman-0.46.4-h7ac5ae9_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pthread-stubs-0.4-h86ecc28_1002.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.13.9-h4c0d347_101_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.2-h8382b9d_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h5688188_102.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/wayland-1.24.0-h4f8a99f_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xkeyboard-config-2.46-he30d5cf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libice-1.1.2-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libsm-1.2.6-h0808dbd_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libx11-1.8.12-hca56bd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxau-1.0.12-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxcomposite-0.4.6-h86ecc28_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxcursor-1.2.3-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxdamage-1.1.6-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxdmcp-1.1.5-h57736b2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxext-1.3.6-h57736b2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxfixes-6.0.2-he30d5cf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxi-1.8.2-h57736b2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxinerama-1.1.5-h5ad3122_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrandr-1.5.4-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrender-0.9.12-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxtst-1.2.5-h57736b2_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxxf86vm-1.1.6-h86ecc28_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-xorgproto-2024.1-h86ecc28_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-hbcf94c1_2.conda
+ - pypi: https://files.pythonhosted.org/packages/16/54/a295bd8d7ac900c339b2c7024ed0ff9538afb60e92eb0979b8bb49deb20e/aiobotocore-3.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/51/08f32aea872253173f513ba68122f4300966290677c8e59887b4ffd5d957/botocore-1.42.70-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/2e/7a/34c9402ad12bce609be4be1146a7d22a7fae8e9d752684b6315cce552a65/coverage-7.11.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/04/05/06b1455e4bc653fcb2117ac3ef5fa3a8a14919b93c60742d04440605d058/fonttools-4.60.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/05/aa/62893d6a591d337aa59dcc4c6f6c842f1fe20cd72c8c5c1f980255243252/ipython-9.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/d0/7f/ccdca06f4c2e6c7989270ed7829b8679466682f4cfc0f8c9986241c023b6/matplotlib-3.10.7-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7d/ae/f32695da4f93de50dd7075100dab8cf689a9d96270f58ce6f940fd044a3e/minio-7.2.18-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3d/3e/e65236d9d0d9babfa0ecba593413c06530fca60a8feb8f66243aa5dba92e/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ - pypi: ./
+ osx-arm64:
+ - conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/atk-1.0-2.38.0-hd03087b_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cairo-1.18.4-h6a3b0d2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/epoxy-1.5.10-hc919400_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fontconfig-2.15.0-h1383a14_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-0.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.1-hce30654_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fribidi-1.0.16-hc919400_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gdk-pixbuf-2.44.4-h7542897_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-tools-2.86.1-hb9d6e3a_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphite2-1.3.14-hec049ff_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphviz-13.1.2-hcd33d8b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gtk3-3.24.43-h5febe37_6.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/gts-0.7.6-he42f4ea_4.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-12.1.0-haf38c7b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/hicolor-icon-theme-0.17-hce30654_2.tar.bz2
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-75.1-hfee45f7_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lerc-4.0.0-hd64df32_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.4-hf598326_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.24-h5773f1b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.1-hec049ff_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-he5f378a_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.1-hce30654_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.1-h6da58f4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgd-2.3.3-hb2c3a21_11.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.86.1-he69a767_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.1.0-h5505292_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.50-h280e0eb_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/librsvg-2.60.0-h5c55ec3_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.50.4-h4237e3c_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.1-h7dc4979_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h07db88b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.1-h0ff4647_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.5.4-h5503f6c_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pango-1.56.4-h875632e_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.46-h7125dd6_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pixman-0.46.4-h81086ad_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.9-hfc2f54d_101_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-h6491c7d_2.conda
+ - pypi: https://files.pythonhosted.org/packages/16/54/a295bd8d7ac900c339b2c7024ed0ff9538afb60e92eb0979b8bb49deb20e/aiobotocore-3.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/51/08f32aea872253173f513ba68122f4300966290677c8e59887b4ffd5d957/botocore-1.42.70-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl
+ - pypi: https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl
+ - pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7c/5b/cdd2c612277b7ac7ec8c0c9bc41812c43dc7b2d5f2b0897e15fdf5a1f915/fonttools-4.60.1-cp313-cp313-macosx_10_13_universal2.whl
+ - pypi: https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/48/c5/d5e07995077e48220269c28a221e168c91123ad5ceee44d548f54a057fc0/ipython-9.6.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/bc/d0/b3d3338d467d3fc937f0bb7f256711395cae6f78e22cef0656159950adf0/matplotlib-3.10.7-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7d/ae/f32695da4f93de50dd7075100dab8cf689a9d96270f58ce6f940fd044a3e/minio-7.2.18-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/90/bf/297716b3095fe719be20fcf7af1d2b6ab069c38199bbace2469608a69b3a/polars_runtime_32-1.39.3-cp310-abi3-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl
+ - pypi: ./
+packages:
+- conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2
+ sha256: fe51de6107f9edc7aa4f786a70f4a883943bc9d39b3bb7307c04c41410990726
+ md5: d7c89558ba9fa0495403155b64376d81
+ license: None
+ purls: []
+ size: 2562
+ timestamp: 1578324546067
+- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2
+ build_number: 16
+ sha256: fbe2c5e56a653bebb982eda4876a9178aedfc2b545f25d0ce9c4c0b508253d22
+ md5: 73aaf86a425cc6e73fcf236a5a46396d
+ depends:
+ - _libgcc_mutex 0.1 conda_forge
+ - libgomp >=7.5.0
+ constrains:
+ - openmp_impl 9999
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 23621
+ timestamp: 1650670423406
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/_openmp_mutex-4.5-2_gnu.tar.bz2
+ build_number: 16
+ sha256: 3702bef2f0a4d38bd8288bbe54aace623602a1343c2cfbefd3fa188e015bebf0
+ md5: 6168d71addc746e8f2b8d57dfd2edcea
+ depends:
+ - libgomp >=7.5.0
+ constrains:
+ - openmp_impl 9999
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 23712
+ timestamp: 1650670790230
+- conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-48.1-unix_1.conda
+ sha256: f52307d3ff839bf4a001cb14b3944f169e46e37982a97c3d52cbf48a0cfe2327
+ md5: 388097ca1f27fc28e0ef1986dd311891
+ depends:
+ - __unix
+ - hicolor-icon-theme
+ - librsvg
+ license: LGPL-3.0-or-later OR CC-BY-SA-3.0
+ license_family: LGPL
+ purls: []
+ size: 621553
+ timestamp: 1755882037787
+- conda: https://conda.anaconda.org/conda-forge/noarch/adwaita-icon-theme-49.0-unix_0.conda
+ sha256: a362b4f5c96a0bf4def96be1a77317e2730af38915eb9bec85e2a92836501ed7
+ md5: b3f0179590f3c0637b7eb5309898f79e
+ depends:
+ - __unix
+ - hicolor-icon-theme
+ - librsvg
+ license: LGPL-3.0-or-later OR CC-BY-SA-3.0
+ license_family: LGPL
+ purls: []
+ size: 631452
+ timestamp: 1758743294412
+- pypi: https://files.pythonhosted.org/packages/16/54/a295bd8d7ac900c339b2c7024ed0ff9538afb60e92eb0979b8bb49deb20e/aiobotocore-3.3.0-py3-none-any.whl
+ name: aiobotocore
+ version: 3.3.0
+ sha256: 9125ab2b63740dfe3b66b8d5a90d13aed9587b850aa53225ef214a04a1aa7fdc
+ requires_dist:
+ - aiohttp>=3.12.0,<4.0.0
+ - aioitertools>=0.5.1,<1.0.0
+ - botocore>=1.42.62,<1.42.71
+ - python-dateutil>=2.1,<3.0.0
+ - jmespath>=0.7.1,<2.0.0
+ - multidict>=6.0.0,<7.0.0
+ - typing-extensions>=4.14.0,<5.0.0 ; python_full_version < '3.11'
+ - wrapt>=1.10.10,<3.0.0
+ - httpx>=0.25.1,<0.29 ; extra == 'httpx'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl
+ name: aiohappyeyeballs
+ version: 2.6.1
+ sha256: f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl
+ name: aiohttp
+ version: 3.13.3
+ sha256: 425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3
+ requires_dist:
+ - aiohappyeyeballs>=2.5.0
+ - aiosignal>=1.4.0
+ - async-timeout>=4.0,<6.0 ; python_full_version < '3.11'
+ - attrs>=17.3.0
+ - frozenlist>=1.1.1
+ - multidict>=4.5,<7.0
+ - propcache>=0.2.0
+ - yarl>=1.17.0,<2.0
+ - aiodns>=3.3.0 ; extra == 'speedups'
+ - brotli>=1.2 ; platform_python_implementation == 'CPython' and extra == 'speedups'
+ - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups'
+ - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and extra == 'speedups'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ name: aiohttp
+ version: 3.13.3
+ sha256: 7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf
+ requires_dist:
+ - aiohappyeyeballs>=2.5.0
+ - aiosignal>=1.4.0
+ - async-timeout>=4.0,<6.0 ; python_full_version < '3.11'
+ - attrs>=17.3.0
+ - frozenlist>=1.1.1
+ - multidict>=4.5,<7.0
+ - propcache>=0.2.0
+ - yarl>=1.17.0,<2.0
+ - aiodns>=3.3.0 ; extra == 'speedups'
+ - brotli>=1.2 ; platform_python_implementation == 'CPython' and extra == 'speedups'
+ - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups'
+ - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and extra == 'speedups'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ name: aiohttp
+ version: 3.13.3
+ sha256: f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0
+ requires_dist:
+ - aiohappyeyeballs>=2.5.0
+ - aiosignal>=1.4.0
+ - async-timeout>=4.0,<6.0 ; python_full_version < '3.11'
+ - attrs>=17.3.0
+ - frozenlist>=1.1.1
+ - multidict>=4.5,<7.0
+ - propcache>=0.2.0
+ - yarl>=1.17.0,<2.0
+ - aiodns>=3.3.0 ; extra == 'speedups'
+ - brotli>=1.2 ; platform_python_implementation == 'CPython' and extra == 'speedups'
+ - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups'
+ - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and extra == 'speedups'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl
+ name: aioitertools
+ version: 0.13.0
+ sha256: 0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be
+ requires_dist:
+ - typing-extensions>=4.0 ; python_full_version < '3.10'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl
+ name: aiosignal
+ version: 1.4.0
+ sha256: 053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e
+ requires_dist:
+ - frozenlist>=1.1.0
+ - typing-extensions>=4.2 ; python_full_version < '3.13'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
+ name: annotated-types
+ version: 0.7.0
+ sha256: 1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53
+ requires_dist:
+ - typing-extensions>=4.0.0 ; python_full_version < '3.9'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl
+ name: argon2-cffi
+ version: 25.1.0
+ sha256: fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741
+ requires_dist:
+ - argon2-cffi-bindings
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl
+ name: argon2-cffi-bindings
+ version: 25.1.0
+ sha256: d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a
+ requires_dist:
+ - cffi>=1.0.1 ; python_full_version < '3.14'
+ - cffi>=2.0.0b1 ; python_full_version >= '3.14'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl
+ name: argon2-cffi-bindings
+ version: 25.1.0
+ sha256: 7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0
+ requires_dist:
+ - cffi>=1.0.1 ; python_full_version < '3.14'
+ - cffi>=2.0.0b1 ; python_full_version >= '3.14'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl
+ name: argon2-cffi-bindings
+ version: 25.1.0
+ sha256: 1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6
+ requires_dist:
+ - cffi>=1.0.1 ; python_full_version < '3.14'
+ - cffi>=2.0.0b1 ; python_full_version >= '3.14'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl
+ name: asttokens
+ version: 3.0.0
+ sha256: e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2
+ requires_dist:
+ - astroid>=2,<4 ; extra == 'astroid'
+ - astroid>=2,<4 ; extra == 'test'
+ - pytest ; extra == 'test'
+ - pytest-cov ; extra == 'test'
+ - pytest-xdist ; extra == 'test'
+ requires_python: '>=3.8'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/at-spi2-atk-2.38.0-h0630a04_3.tar.bz2
+ sha256: 26ab9386e80bf196e51ebe005da77d57decf6d989b4f34d96130560bc133479c
+ md5: 6b889f174df1e0f816276ae69281af4d
+ depends:
+ - at-spi2-core >=2.40.0,<2.41.0a0
+ - atk-1.0 >=2.36.0
+ - dbus >=1.13.6,<2.0a0
+ - libgcc-ng >=9.3.0
+ - libglib >=2.68.1,<3.0a0
+ license: LGPL-2.1-or-later
+ license_family: LGPL
+ purls: []
+ size: 339899
+ timestamp: 1619122953439
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/at-spi2-atk-2.38.0-h1f2db35_3.tar.bz2
+ sha256: c2c2c998d49c061e390537f929e77ce6b023ef22b51a0f55692d6df7327f3358
+ md5: 4ea9d4634f3b054549be5e414291801e
+ depends:
+ - at-spi2-core >=2.40.0,<2.41.0a0
+ - atk-1.0 >=2.36.0
+ - dbus >=1.13.6,<2.0a0
+ - libgcc-ng >=9.3.0
+ - libglib >=2.68.1,<3.0a0
+ license: LGPL-2.1-or-later
+ license_family: LGPL
+ purls: []
+ size: 322172
+ timestamp: 1619123713021
+- conda: https://conda.anaconda.org/conda-forge/linux-64/at-spi2-core-2.40.3-h0630a04_0.tar.bz2
+ sha256: c4f9b66bd94c40d8f1ce1fad2d8b46534bdefda0c86e3337b28f6c25779f258d
+ md5: 8cb2fc4cd6cc63f1369cfa318f581cc3
+ depends:
+ - dbus >=1.13.6,<2.0a0
+ - libgcc-ng >=9.3.0
+ - libglib >=2.68.3,<3.0a0
+ - xorg-libx11
+ - xorg-libxi
+ - xorg-libxtst
+ license: LGPL-2.1-or-later
+ license_family: LGPL
+ purls: []
+ size: 658390
+ timestamp: 1625848454791
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/at-spi2-core-2.40.3-h1f2db35_0.tar.bz2
+ sha256: cd48de9674a20133e70a643476accc1a63360c921ab49477638364877937a40d
+ md5: a12602a94ee402b57063ef74e82016c0
+ depends:
+ - dbus >=1.13.6,<2.0a0
+ - libgcc-ng >=9.3.0
+ - libglib >=2.68.3,<3.0a0
+ - xorg-libx11
+ - xorg-libxi
+ - xorg-libxtst
+ license: LGPL-2.1-or-later
+ license_family: LGPL
+ purls: []
+ size: 622407
+ timestamp: 1625848355776
+- conda: https://conda.anaconda.org/conda-forge/linux-64/atk-1.0-2.38.0-h04ea711_2.conda
+ sha256: df682395d05050cd1222740a42a551281210726a67447e5258968dd55854302e
+ md5: f730d54ba9cd543666d7220c9f7ed563
+ depends:
+ - libgcc-ng >=12
+ - libglib >=2.80.0,<3.0a0
+ - libstdcxx-ng >=12
+ constrains:
+ - atk-1.0 2.38.0
+ license: LGPL-2.0-or-later
+ license_family: LGPL
+ purls: []
+ size: 355900
+ timestamp: 1713896169874
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/atk-1.0-2.38.0-hedc4a1f_2.conda
+ sha256: 69f70048a1a915be7b8ad5d2cbb7bf020baa989b5506e45a676ef4ef5106c4f0
+ md5: 9308557e2328f944bd5809c5630761af
+ depends:
+ - libgcc-ng >=12
+ - libglib >=2.80.0,<3.0a0
+ - libstdcxx-ng >=12
+ constrains:
+ - atk-1.0 2.38.0
+ license: LGPL-2.0-or-later
+ license_family: LGPL
+ purls: []
+ size: 358327
+ timestamp: 1713898303194
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/atk-1.0-2.38.0-hd03087b_2.conda
+ sha256: b0747f9b1bc03d1932b4d8c586f39a35ac97e7e72fe6e63f2b2a2472d466f3c1
+ md5: 57301986d02d30d6805fdce6c99074ee
+ depends:
+ - __osx >=11.0
+ - libcxx >=16
+ - libglib >=2.80.0,<3.0a0
+ - libintl >=0.22.5,<1.0a0
+ constrains:
+ - atk-1.0 2.38.0
+ license: LGPL-2.0-or-later
+ license_family: LGPL
+ purls: []
+ size: 347530
+ timestamp: 1713896411580
+- pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl
+ name: attrs
+ version: 26.1.0
+ sha256: c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/fb/51/08f32aea872253173f513ba68122f4300966290677c8e59887b4ffd5d957/botocore-1.42.70-py3-none-any.whl
+ name: botocore
+ version: 1.42.70
+ sha256: 54ed9d25f05f810efd22b0dfda0bb9178df3ad8952b2e4359e05156c9321bd3c
+ requires_dist:
+ - jmespath>=0.7.1,<2.0.0
+ - python-dateutil>=2.1,<3.0.0
+ - urllib3>=1.25.4,<1.27 ; python_full_version < '3.10'
+ - urllib3>=1.25.4,!=2.2.0,<3 ; python_full_version >= '3.10'
+ - awscrt==0.31.2 ; extra == 'crt'
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda
+ sha256: c30daba32ddebbb7ded490f0e371eae90f51e72db620554089103b4a6934b0d5
+ md5: 51a19bba1b8ebfb60df25cde030b7ebc
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
+ license: bzip2-1.0.6
+ license_family: BSD
+ purls: []
+ size: 260341
+ timestamp: 1757437258798
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/bzip2-1.0.8-h4777abc_8.conda
+ sha256: d2a296aa0b5f38ed9c264def6cf775c0ccb0f110ae156fcde322f3eccebf2e01
+ md5: 2921ac0b541bf37c69e66bd6d9a43bca
+ depends:
+ - libgcc >=14
+ license: bzip2-1.0.6
+ license_family: BSD
+ purls: []
+ size: 192536
+ timestamp: 1757437302703
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda
+ sha256: b456200636bd5fecb2bec63f7e0985ad2097cf1b83d60ce0b6968dffa6d02aa1
+ md5: 58fd217444c2a5701a44244faf518206
+ depends:
+ - __osx >=11.0
+ license: bzip2-1.0.6
+ license_family: BSD
+ purls: []
+ size: 125061
+ timestamp: 1757437486465
+- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda
+ sha256: 3b5ad78b8bb61b6cdc0978a6a99f8dfb2cc789a451378d054698441005ecbdb6
+ md5: f9e5fbc24009179e8b0409624691758a
+ depends:
+ - __unix
+ license: ISC
+ purls: []
+ size: 155907
+ timestamp: 1759649036195
+- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.8.3-hbd8a1cb_0.conda
+ sha256: 837b795a2bb39b75694ba910c13c15fa4998d4bb2a622c214a6a5174b2ae53d1
+ md5: 74784ee3d225fc3dca89edb635b4e5cc
+ depends:
+ - __unix
+ license: ISC
+ purls: []
+ size: 154402
+ timestamp: 1754210968730
+- conda: https://conda.anaconda.org/conda-forge/linux-64/cairo-1.18.4-h3394656_0.conda
+ sha256: 3bd6a391ad60e471de76c0e9db34986c4b5058587fbf2efa5a7f54645e28c2c7
+ md5: 09262e66b19567aff4f592fb53b28760
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - fontconfig >=2.15.0,<3.0a0
+ - fonts-conda-ecosystem
+ - freetype >=2.12.1,<3.0a0
+ - icu >=75.1,<76.0a0
+ - libexpat >=2.6.4,<3.0a0
+ - libgcc >=13
+ - libglib >=2.82.2,<3.0a0
+ - libpng >=1.6.47,<1.7.0a0
+ - libstdcxx >=13
+ - libxcb >=1.17.0,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - pixman >=0.44.2,<1.0a0
+ - xorg-libice >=1.1.2,<2.0a0
+ - xorg-libsm >=1.2.5,<2.0a0
+ - xorg-libx11 >=1.8.11,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ - xorg-libxrender >=0.9.12,<0.10.0a0
+ license: LGPL-2.1-only or MPL-1.1
+ purls: []
+ size: 978114
+ timestamp: 1741554591855
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/cairo-1.18.4-h83712da_0.conda
+ sha256: 37cfff940d2d02259afdab75eb2dbac42cf830adadee78d3733d160a1de2cc66
+ md5: cd55953a67ec727db5dc32b167201aa6
+ depends:
+ - fontconfig >=2.15.0,<3.0a0
+ - fonts-conda-ecosystem
+ - freetype >=2.12.1,<3.0a0
+ - icu >=75.1,<76.0a0
+ - libexpat >=2.6.4,<3.0a0
+ - libgcc >=13
+ - libglib >=2.82.2,<3.0a0
+ - libpng >=1.6.47,<1.7.0a0
+ - libstdcxx >=13
+ - libxcb >=1.17.0,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - pixman >=0.44.2,<1.0a0
+ - xorg-libice >=1.1.2,<2.0a0
+ - xorg-libsm >=1.2.5,<2.0a0
+ - xorg-libx11 >=1.8.11,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ - xorg-libxrender >=0.9.12,<0.10.0a0
+ license: LGPL-2.1-only or MPL-1.1
+ purls: []
+ size: 966667
+ timestamp: 1741554768968
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cairo-1.18.4-h6a3b0d2_0.conda
+ sha256: 00439d69bdd94eaf51656fdf479e0c853278439d22ae151cabf40eb17399d95f
+ md5: 38f6df8bc8c668417b904369a01ba2e2
+ depends:
+ - __osx >=11.0
+ - fontconfig >=2.15.0,<3.0a0
+ - fonts-conda-ecosystem
+ - freetype >=2.12.1,<3.0a0
+ - icu >=75.1,<76.0a0
+ - libcxx >=18
+ - libexpat >=2.6.4,<3.0a0
+ - libglib >=2.82.2,<3.0a0
+ - libpng >=1.6.47,<1.7.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - pixman >=0.44.2,<1.0a0
+ license: LGPL-2.1-only or MPL-1.1
+ purls: []
+ size: 896173
+ timestamp: 1741554795915
+- pypi: https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl
+ name: certifi
+ version: 2025.8.3
+ sha256: f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl
+ name: certifi
+ version: 2025.10.5
+ sha256: 0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl
+ name: cffi
+ version: 2.0.0
+ sha256: 45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca
+ requires_dist:
+ - pycparser ; implementation_name != 'PyPy'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ name: cffi
+ version: 2.0.0
+ sha256: c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26
+ requires_dist:
+ - pycparser ; implementation_name != 'PyPy'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
+ name: cffi
+ version: 2.0.0
+ sha256: d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b
+ requires_dist:
+ - pycparser ; implementation_name != 'PyPy'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl
+ name: cfgv
+ version: 3.4.0
+ sha256: b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ name: charset-normalizer
+ version: 3.4.3
+ sha256: 416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ name: charset-normalizer
+ version: 3.4.4
+ sha256: 6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl
+ name: charset-normalizer
+ version: 3.4.4
+ sha256: e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/20/01/b394922252051e97aab231d416c86da3d8a6d781eeadcdca1082867de64e/codespell-2.4.1-py3-none-any.whl
+ name: codespell
+ version: 2.4.1
+ sha256: 3dadafa67df7e4a3dbf51e0d7315061b80d265f9552ebd699b3dd6834b47e425
+ requires_dist:
+ - build ; extra == 'dev'
+ - chardet ; extra == 'dev'
+ - pre-commit ; extra == 'dev'
+ - pytest ; extra == 'dev'
+ - pytest-cov ; extra == 'dev'
+ - pytest-dependency ; extra == 'dev'
+ - pygments ; extra == 'dev'
+ - ruff ; extra == 'dev'
+ - tomli ; extra == 'dev'
+ - twine ; extra == 'dev'
+ - chardet ; extra == 'hard-encoding-detection'
+ - tomli ; python_full_version < '3.11' and extra == 'toml'
+ - chardet>=5.1.0 ; extra == 'types'
+ - mypy ; extra == 'types'
+ - pytest ; extra == 'types'
+ - pytest-cov ; extra == 'types'
+ - pytest-dependency ; extra == 'types'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ name: contourpy
+ version: 1.3.3
+ sha256: 4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9
+ requires_dist:
+ - numpy>=1.25
+ - furo ; extra == 'docs'
+ - sphinx>=7.2 ; extra == 'docs'
+ - sphinx-copybutton ; extra == 'docs'
+ - bokeh ; extra == 'bokeh'
+ - selenium ; extra == 'bokeh'
+ - contourpy[bokeh,docs] ; extra == 'mypy'
+ - bokeh ; extra == 'mypy'
+ - docutils-stubs ; extra == 'mypy'
+ - mypy==1.17.0 ; extra == 'mypy'
+ - types-pillow ; extra == 'mypy'
+ - contourpy[test-no-images] ; extra == 'test'
+ - matplotlib ; extra == 'test'
+ - pillow ; extra == 'test'
+ - pytest ; extra == 'test-no-images'
+ - pytest-cov ; extra == 'test-no-images'
+ - pytest-rerunfailures ; extra == 'test-no-images'
+ - pytest-xdist ; extra == 'test-no-images'
+ - wurlitzer ; extra == 'test-no-images'
+ requires_python: '>=3.11'
+- pypi: https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl
+ name: contourpy
+ version: 1.3.3
+ sha256: 348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286
+ requires_dist:
+ - numpy>=1.25
+ - furo ; extra == 'docs'
+ - sphinx>=7.2 ; extra == 'docs'
+ - sphinx-copybutton ; extra == 'docs'
+ - bokeh ; extra == 'bokeh'
+ - selenium ; extra == 'bokeh'
+ - contourpy[bokeh,docs] ; extra == 'mypy'
+ - bokeh ; extra == 'mypy'
+ - docutils-stubs ; extra == 'mypy'
+ - mypy==1.17.0 ; extra == 'mypy'
+ - types-pillow ; extra == 'mypy'
+ - contourpy[test-no-images] ; extra == 'test'
+ - matplotlib ; extra == 'test'
+ - pillow ; extra == 'test'
+ - pytest ; extra == 'test-no-images'
+ - pytest-cov ; extra == 'test-no-images'
+ - pytest-rerunfailures ; extra == 'test-no-images'
+ - pytest-xdist ; extra == 'test-no-images'
+ - wurlitzer ; extra == 'test-no-images'
+ requires_python: '>=3.11'
+- pypi: https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl
+ name: contourpy
+ version: 1.3.3
+ sha256: d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1
+ requires_dist:
+ - numpy>=1.25
+ - furo ; extra == 'docs'
+ - sphinx>=7.2 ; extra == 'docs'
+ - sphinx-copybutton ; extra == 'docs'
+ - bokeh ; extra == 'bokeh'
+ - selenium ; extra == 'bokeh'
+ - contourpy[bokeh,docs] ; extra == 'mypy'
+ - bokeh ; extra == 'mypy'
+ - docutils-stubs ; extra == 'mypy'
+ - mypy==1.17.0 ; extra == 'mypy'
+ - types-pillow ; extra == 'mypy'
+ - contourpy[test-no-images] ; extra == 'test'
+ - matplotlib ; extra == 'test'
+ - pillow ; extra == 'test'
+ - pytest ; extra == 'test-no-images'
+ - pytest-cov ; extra == 'test-no-images'
+ - pytest-rerunfailures ; extra == 'test-no-images'
+ - pytest-xdist ; extra == 'test-no-images'
+ - wurlitzer ; extra == 'test-no-images'
+ requires_python: '>=3.11'
+- pypi: https://files.pythonhosted.org/packages/73/dd/508420fb47d09d904d962f123221bc249f64b5e56aa93d5f5f7603be475f/coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ name: coverage
+ version: 7.10.6
+ sha256: 0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27
+ requires_dist:
+ - tomli ; python_full_version <= '3.11' and extra == 'toml'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl
+ name: coverage
+ version: 7.11.0
+ sha256: f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be
+ requires_dist:
+ - tomli ; python_full_version <= '3.11' and extra == 'toml'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/2e/7a/34c9402ad12bce609be4be1146a7d22a7fae8e9d752684b6315cce552a65/coverage-7.11.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ name: coverage
+ version: 7.11.2
+ sha256: 811bff1f93566a8556a9aeb078bd82573e37f4d802a185fba4cbe75468615050
+ requires_dist:
+ - tomli ; python_full_version <= '3.11' and extra == 'toml'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl
+ name: cryptography
+ version: 46.0.6
+ sha256: 22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97
+ requires_dist:
+ - cffi>=1.14 ; python_full_version == '3.8.*' and platform_python_implementation != 'PyPy'
+ - cffi>=2.0.0 ; python_full_version >= '3.9' and platform_python_implementation != 'PyPy'
+ - typing-extensions>=4.13.2 ; python_full_version < '3.11'
+ - bcrypt>=3.1.5 ; extra == 'ssh'
+ - nox[uv]>=2024.4.15 ; extra == 'nox'
+ - cryptography-vectors==46.0.6 ; extra == 'test'
+ - pytest>=7.4.0 ; extra == 'test'
+ - pytest-benchmark>=4.0 ; extra == 'test'
+ - pytest-cov>=2.10.1 ; extra == 'test'
+ - pytest-xdist>=3.5.0 ; extra == 'test'
+ - pretend>=0.7 ; extra == 'test'
+ - certifi>=2024 ; extra == 'test'
+ - pytest-randomly ; extra == 'test-randomorder'
+ - sphinx>=5.3.0 ; extra == 'docs'
+ - sphinx-rtd-theme>=3.0.0 ; extra == 'docs'
+ - sphinx-inline-tabs ; extra == 'docs'
+ - pyenchant>=3 ; extra == 'docstest'
+ - readme-renderer>=30.0 ; extra == 'docstest'
+ - sphinxcontrib-spelling>=7.3.1 ; extra == 'docstest'
+ - build>=1.0.0 ; extra == 'sdist'
+ - ruff>=0.11.11 ; extra == 'pep8test'
+ - mypy>=1.14 ; extra == 'pep8test'
+ - check-sdist ; extra == 'pep8test'
+ - click>=8.0.1 ; extra == 'pep8test'
+ requires_python: '>=3.8,!=3.9.0,!=3.9.1'
+- pypi: https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl
+ name: cryptography
+ version: 46.0.6
+ sha256: 64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8
+ requires_dist:
+ - cffi>=1.14 ; python_full_version == '3.8.*' and platform_python_implementation != 'PyPy'
+ - cffi>=2.0.0 ; python_full_version >= '3.9' and platform_python_implementation != 'PyPy'
+ - typing-extensions>=4.13.2 ; python_full_version < '3.11'
+ - bcrypt>=3.1.5 ; extra == 'ssh'
+ - nox[uv]>=2024.4.15 ; extra == 'nox'
+ - cryptography-vectors==46.0.6 ; extra == 'test'
+ - pytest>=7.4.0 ; extra == 'test'
+ - pytest-benchmark>=4.0 ; extra == 'test'
+ - pytest-cov>=2.10.1 ; extra == 'test'
+ - pytest-xdist>=3.5.0 ; extra == 'test'
+ - pretend>=0.7 ; extra == 'test'
+ - certifi>=2024 ; extra == 'test'
+ - pytest-randomly ; extra == 'test-randomorder'
+ - sphinx>=5.3.0 ; extra == 'docs'
+ - sphinx-rtd-theme>=3.0.0 ; extra == 'docs'
+ - sphinx-inline-tabs ; extra == 'docs'
+ - pyenchant>=3 ; extra == 'docstest'
+ - readme-renderer>=30.0 ; extra == 'docstest'
+ - sphinxcontrib-spelling>=7.3.1 ; extra == 'docstest'
+ - build>=1.0.0 ; extra == 'sdist'
+ - ruff>=0.11.11 ; extra == 'pep8test'
+ - mypy>=1.14 ; extra == 'pep8test'
+ - check-sdist ; extra == 'pep8test'
+ - click>=8.0.1 ; extra == 'pep8test'
+ requires_python: '>=3.8,!=3.9.0,!=3.9.1'
+- pypi: https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl
+ name: cryptography
+ version: 46.0.6
+ sha256: 67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175
+ requires_dist:
+ - cffi>=1.14 ; python_full_version == '3.8.*' and platform_python_implementation != 'PyPy'
+ - cffi>=2.0.0 ; python_full_version >= '3.9' and platform_python_implementation != 'PyPy'
+ - typing-extensions>=4.13.2 ; python_full_version < '3.11'
+ - bcrypt>=3.1.5 ; extra == 'ssh'
+ - nox[uv]>=2024.4.15 ; extra == 'nox'
+ - cryptography-vectors==46.0.6 ; extra == 'test'
+ - pytest>=7.4.0 ; extra == 'test'
+ - pytest-benchmark>=4.0 ; extra == 'test'
+ - pytest-cov>=2.10.1 ; extra == 'test'
+ - pytest-xdist>=3.5.0 ; extra == 'test'
+ - pretend>=0.7 ; extra == 'test'
+ - certifi>=2024 ; extra == 'test'
+ - pytest-randomly ; extra == 'test-randomorder'
+ - sphinx>=5.3.0 ; extra == 'docs'
+ - sphinx-rtd-theme>=3.0.0 ; extra == 'docs'
+ - sphinx-inline-tabs ; extra == 'docs'
+ - pyenchant>=3 ; extra == 'docstest'
+ - readme-renderer>=30.0 ; extra == 'docstest'
+ - sphinxcontrib-spelling>=7.3.1 ; extra == 'docstest'
+ - build>=1.0.0 ; extra == 'sdist'
+ - ruff>=0.11.11 ; extra == 'pep8test'
+ - mypy>=1.14 ; extra == 'pep8test'
+ - check-sdist ; extra == 'pep8test'
+ - click>=8.0.1 ; extra == 'pep8test'
+ requires_python: '>=3.8,!=3.9.0,!=3.9.1'
+- pypi: https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl
+ name: cycler
+ version: 0.12.1
+ sha256: 85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30
+ requires_dist:
+ - ipython ; extra == 'docs'
+ - matplotlib ; extra == 'docs'
+ - numpydoc ; extra == 'docs'
+ - sphinx ; extra == 'docs'
+ - pytest ; extra == 'tests'
+ - pytest-cov ; extra == 'tests'
+ - pytest-xdist ; extra == 'tests'
+ requires_python: '>=3.8'
+- pypi: ./
+ name: datajoint
+ version: 2.2.0.dev0
+ sha256: 48335cedf96fa3b5efd3ddf880bd5065813f2baea43cad01a2fddbba94e561ec
+ requires_dist:
+ - deepdiff
+ - fsspec>=2023.1.0
+ - networkx
+ - numpy
+ - pandas
+ - pydantic-settings>=2.0.0
+ - pydot
+ - pymysql>=0.7.2
+ - pyparsing
+ - tqdm
+ - pyarrow>=14.0.0 ; extra == 'arrow'
+ - adlfs>=2023.1.0 ; extra == 'azure'
+ - codespell ; extra == 'dev'
+ - polars>=0.20.0 ; extra == 'dev'
+ - pre-commit ; extra == 'dev'
+ - pyarrow>=14.0.0 ; extra == 'dev'
+ - pytest ; extra == 'dev'
+ - pytest-cov ; extra == 'dev'
+ - ruff ; extra == 'dev'
+ - gcsfs>=2023.1.0 ; extra == 'gcs'
+ - polars>=0.20.0 ; extra == 'polars'
+ - psycopg2-binary>=2.9.0 ; extra == 'postgres'
+ - s3fs>=2023.1.0 ; extra == 's3'
+ - faker ; extra == 'test'
+ - ipython ; extra == 'test'
+ - matplotlib ; extra == 'test'
+ - polars>=0.20.0 ; extra == 'test'
+ - psycopg2-binary>=2.9.0 ; extra == 'test'
+ - pyarrow>=14.0.0 ; extra == 'test'
+ - pytest ; extra == 'test'
+ - pytest-cov ; extra == 'test'
+ - requests ; extra == 'test'
+ - s3fs>=2023.1.0 ; extra == 'test'
+ - testcontainers[minio,mysql,postgres]>=4.0 ; extra == 'test'
+ - ipython ; extra == 'viz'
+ - matplotlib ; extra == 'viz'
+ requires_python: '>=3.10,<3.14'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.16.2-h3c4dab8_0.conda
+ sha256: 3b988146a50e165f0fa4e839545c679af88e4782ec284cc7b6d07dd226d6a068
+ md5: 679616eb5ad4e521c83da4650860aba7
+ depends:
+ - libstdcxx >=13
+ - libgcc >=13
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - libexpat >=2.7.0,<3.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - libglib >=2.84.2,<3.0a0
+ license: GPL-2.0-or-later
+ license_family: GPL
+ purls: []
+ size: 437860
+ timestamp: 1747855126005
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/dbus-1.16.2-heda779d_0.conda
+ sha256: 5c9166bbbe1ea7d0685a1549aad4ea887b1eb3a07e752389f86b185ef8eac99a
+ md5: 9203b74bb1f3fa0d6f308094b3b44c1e
+ depends:
+ - libgcc >=13
+ - libstdcxx >=13
+ - libgcc >=13
+ - libexpat >=2.7.0,<3.0a0
+ - libglib >=2.84.2,<3.0a0
+ - libzlib >=1.3.1,<2.0a0
+ license: GPL-2.0-or-later
+ license_family: GPL
+ purls: []
+ size: 469781
+ timestamp: 1747855172617
+- pypi: https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl
+ name: decorator
+ version: 5.2.1
+ sha256: d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/f7/e6/efe534ef0952b531b630780e19cabd416e2032697019d5295defc6ef9bd9/deepdiff-8.6.1-py3-none-any.whl
+ name: deepdiff
+ version: 8.6.1
+ sha256: ee8708a7f7d37fb273a541fa24ad010ed484192cd0c4ffc0fa0ed5e2d4b9e78b
+ requires_dist:
+ - orderly-set>=5.4.1,<6
+ - click~=8.1.0 ; extra == 'cli'
+ - pyyaml~=6.0.0 ; extra == 'cli'
+ - coverage~=7.6.0 ; extra == 'coverage'
+ - bump2version~=1.0.0 ; extra == 'dev'
+ - jsonpickle~=4.0.0 ; extra == 'dev'
+ - ipdb~=0.13.0 ; extra == 'dev'
+ - numpy~=2.2.0 ; python_full_version >= '3.10' and extra == 'dev'
+ - numpy~=2.0 ; python_full_version < '3.10' and extra == 'dev'
+ - python-dateutil~=2.9.0 ; extra == 'dev'
+ - orjson~=3.10.0 ; extra == 'dev'
+ - tomli~=2.2.0 ; extra == 'dev'
+ - tomli-w~=1.2.0 ; extra == 'dev'
+ - pandas~=2.2.0 ; extra == 'dev'
+ - polars~=1.21.0 ; extra == 'dev'
+ - nox==2025.5.1 ; extra == 'dev'
+ - uuid6==2025.0.1 ; extra == 'dev'
+ - sphinx~=6.2.0 ; extra == 'docs'
+ - sphinx-sitemap~=2.6.0 ; extra == 'docs'
+ - sphinxemoji~=0.3.0 ; extra == 'docs'
+ - orjson ; extra == 'optimize'
+ - flake8~=7.1.0 ; extra == 'static'
+ - flake8-pyproject~=1.2.3 ; extra == 'static'
+ - pydantic~=2.10.0 ; extra == 'static'
+ - pytest~=8.3.0 ; extra == 'test'
+ - pytest-benchmark~=5.1.0 ; extra == 'test'
+ - pytest-cov~=6.0.0 ; extra == 'test'
+ - python-dotenv~=1.0.0 ; extra == 'test'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl
+ name: distlib
+ version: 0.4.0
+ sha256: 9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16
+- pypi: https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl
+ name: docker
+ version: 7.1.0
+ sha256: c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0
+ requires_dist:
+ - pywin32>=304 ; sys_platform == 'win32'
+ - requests>=2.26.0
+ - urllib3>=1.26.0
+ - coverage==7.2.7 ; extra == 'dev'
+ - pytest-cov==4.1.0 ; extra == 'dev'
+ - pytest-timeout==2.1.0 ; extra == 'dev'
+ - pytest==7.4.2 ; extra == 'dev'
+ - ruff==0.1.8 ; extra == 'dev'
+ - myst-parser==0.18.0 ; extra == 'docs'
+ - sphinx==5.1.1 ; extra == 'docs'
+ - paramiko>=2.4.3 ; extra == 'ssh'
+ - websocket-client>=1.3.0 ; extra == 'websockets'
+ requires_python: '>=3.8'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/epoxy-1.5.10-h166bdaf_1.tar.bz2
+ sha256: 1e58ee2ed0f4699be202f23d49b9644b499836230da7dd5b2f63e6766acff89e
+ md5: a089d06164afd2d511347d3f87214e0b
+ depends:
+ - libgcc-ng >=10.3.0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 1440699
+ timestamp: 1648505042260
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/epoxy-1.5.10-he30d5cf_2.conda
+ sha256: aa562cdd72d2d15b0f2ee4565c8e34f18b52f7135a3f3b1ce727c202425c3bec
+ md5: 1c50e7c46ccefffe918ac974fa1a6752
+ depends:
+ - libdrm >=2.4.125,<2.5.0a0
+ - libegl >=1.7.0,<2.0a0
+ - libegl-devel
+ - libgcc >=14
+ - libgl >=1.7.0,<2.0a0
+ - libgl-devel
+ - libglx >=1.7.0,<2.0a0
+ - libglx-devel
+ - xorg-libx11 >=1.8.12,<2.0a0
+ - xorg-libxdamage >=1.1.6,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ - xorg-libxfixes >=6.0.1,<7.0a0
+ - xorg-libxxf86vm >=1.1.6,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 422103
+ timestamp: 1758743388115
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/epoxy-1.5.10-hc919400_2.conda
+ sha256: ba685b87529c95a4bf9de140a33d703d57dc46b036e9586ed26890de65c1c0d5
+ md5: 3b87dabebe54c6d66a07b97b53ac5874
+ depends:
+ - __osx >=11.0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 296347
+ timestamp: 1758743805063
+- pypi: https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl
+ name: executing
+ version: 2.2.1
+ sha256: 760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017
+ requires_dist:
+ - asttokens>=2.1.0 ; extra == 'tests'
+ - ipython ; extra == 'tests'
+ - pytest ; extra == 'tests'
+ - coverage ; extra == 'tests'
+ - coverage-enable-subprocess ; extra == 'tests'
+ - littleutils ; extra == 'tests'
+ - rich ; python_full_version >= '3.11' and extra == 'tests'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/f5/11/02ebebb09ff2104b690457cb7bc6ed700c9e0ce88cf581486bb0a5d3c88b/faker-37.8.0-py3-none-any.whl
+ name: faker
+ version: 37.8.0
+ sha256: b08233118824423b5fc239f7dd51f145e7018082b4164f8da6a9994e1f1ae793
+ requires_dist:
+ - tzdata
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl
+ name: faker
+ version: 37.12.0
+ sha256: afe7ccc038da92f2fbae30d8e16d19d91e92e242f8401ce9caf44de892bab4c4
+ requires_dist:
+ - tzdata
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl
+ name: filelock
+ version: 3.19.1
+ sha256: d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl
+ name: filelock
+ version: 3.20.0
+ sha256: 339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2
+ requires_python: '>=3.10'
+- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-dejavu-sans-mono-2.37-hab24e00_0.tar.bz2
+ sha256: 58d7f40d2940dd0a8aa28651239adbf5613254df0f75789919c4e6762054403b
+ md5: 0c96522c6bdaed4b1566d11387caaf45
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 397370
+ timestamp: 1566932522327
+- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-inconsolata-3.000-h77eed37_0.tar.bz2
+ sha256: c52a29fdac682c20d252facc50f01e7c2e7ceac52aa9817aaf0bb83f7559ec5c
+ md5: 34893075a5c9e55cdafac56607368fc6
+ license: OFL-1.1
+ license_family: Other
+ purls: []
+ size: 96530
+ timestamp: 1620479909603
+- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-source-code-pro-2.038-h77eed37_0.tar.bz2
+ sha256: 00925c8c055a2275614b4d983e1df637245e19058d79fc7dd1a93b8d9fb4b139
+ md5: 4d59c254e01d9cde7957100457e2d5fb
+ license: OFL-1.1
+ license_family: Other
+ purls: []
+ size: 700814
+ timestamp: 1620479612257
+- conda: https://conda.anaconda.org/conda-forge/noarch/font-ttf-ubuntu-0.83-h77eed37_3.conda
+ sha256: 2821ec1dc454bd8b9a31d0ed22a7ce22422c0aef163c59f49dfdf915d0f0ca14
+ md5: 49023d73832ef61042f6a237cb2687e7
+ license: LicenseRef-Ubuntu-Font-Licence-Version-1.0
+ license_family: Other
+ purls: []
+ size: 1620504
+ timestamp: 1727511233259
+- conda: https://conda.anaconda.org/conda-forge/linux-64/fontconfig-2.15.0-h7e30c49_1.conda
+ sha256: 7093aa19d6df5ccb6ca50329ef8510c6acb6b0d8001191909397368b65b02113
+ md5: 8f5b0b297b59e1ac160ad4beec99dbee
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - freetype >=2.12.1,<3.0a0
+ - libexpat >=2.6.3,<3.0a0
+ - libgcc >=13
+ - libuuid >=2.38.1,<3.0a0
+ - libzlib >=1.3.1,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 265599
+ timestamp: 1730283881107
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/fontconfig-2.15.0-h8dda3cd_1.conda
+ sha256: fe023bb8917c8a3138af86ef537b70c8c5d60c44f93946a87d1e8bb1a6634b55
+ md5: 112b71b6af28b47c624bcbeefeea685b
+ depends:
+ - freetype >=2.12.1,<3.0a0
+ - libexpat >=2.6.3,<3.0a0
+ - libgcc >=13
+ - libuuid >=2.38.1,<3.0a0
+ - libzlib >=1.3.1,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 277832
+ timestamp: 1730284967179
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/fontconfig-2.15.0-h1383a14_1.conda
+ sha256: f79d3d816fafbd6a2b0f75ebc3251a30d3294b08af9bb747194121f5efa364bc
+ md5: 7b29f48742cea5d1ccb5edd839cb5621
+ depends:
+ - __osx >=11.0
+ - freetype >=2.12.1,<3.0a0
+ - libexpat >=2.6.3,<3.0a0
+ - libzlib >=1.3.1,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 234227
+ timestamp: 1730284037572
+- conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-ecosystem-1-0.tar.bz2
+ sha256: a997f2f1921bb9c9d76e6fa2f6b408b7fa549edd349a77639c9fe7a23ea93e61
+ md5: fee5683a3f04bd15cbd8318b096a27ab
+ depends:
+ - fonts-conda-forge
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 3667
+ timestamp: 1566974674465
+- conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-0.tar.bz2
+ sha256: 53f23a3319466053818540bcdf2091f253cbdbab1e0e9ae7b9e509dcaa2a5e38
+ md5: f766549260d6815b0c52253f1fb1bb29
+ depends:
+ - font-ttf-dejavu-sans-mono
+ - font-ttf-inconsolata
+ - font-ttf-source-code-pro
+ - font-ttf-ubuntu
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 4102
+ timestamp: 1566932280397
+- conda: https://conda.anaconda.org/conda-forge/noarch/fonts-conda-forge-1-hc364b38_1.conda
+ sha256: 54eea8469786bc2291cc40bca5f46438d3e062a399e8f53f013b6a9f50e98333
+ md5: a7970cd949a077b7cb9696379d338681
+ depends:
+ - font-ttf-ubuntu
+ - font-ttf-inconsolata
+ - font-ttf-dejavu-sans-mono
+ - font-ttf-source-code-pro
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 4059
+ timestamp: 1762351264405
+- pypi: https://files.pythonhosted.org/packages/f2/9f/bf231c2a3fac99d1d7f1d89c76594f158693f981a4aa02be406e9f036832/fonttools-4.59.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl
+ name: fonttools
+ version: 4.59.2
+ sha256: 6235fc06bcbdb40186f483ba9d5d68f888ea68aa3c8dac347e05a7c54346fbc8
+ requires_dist:
+ - lxml>=4.0 ; extra == 'lxml'
+ - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff'
+ - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff'
+ - zopfli>=0.1.4 ; extra == 'woff'
+ - unicodedata2>=15.1.0 ; python_full_version < '3.13' and extra == 'unicode'
+ - lz4>=1.7.4.2 ; extra == 'graphite'
+ - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable'
+ - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable'
+ - pycairo ; extra == 'interpolatable'
+ - matplotlib ; extra == 'plot'
+ - sympy ; extra == 'symfont'
+ - xattr ; sys_platform == 'darwin' and extra == 'type1'
+ - skia-pathops>=0.5.0 ; extra == 'pathops'
+ - uharfbuzz>=0.23.0 ; extra == 'repacker'
+ - lxml>=4.0 ; extra == 'all'
+ - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all'
+ - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all'
+ - zopfli>=0.1.4 ; extra == 'all'
+ - unicodedata2>=15.1.0 ; python_full_version < '3.13' and extra == 'all'
+ - lz4>=1.7.4.2 ; extra == 'all'
+ - scipy ; platform_python_implementation != 'PyPy' and extra == 'all'
+ - munkres ; platform_python_implementation == 'PyPy' and extra == 'all'
+ - pycairo ; extra == 'all'
+ - matplotlib ; extra == 'all'
+ - sympy ; extra == 'all'
+ - xattr ; sys_platform == 'darwin' and extra == 'all'
+ - skia-pathops>=0.5.0 ; extra == 'all'
+ - uharfbuzz>=0.23.0 ; extra == 'all'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/04/05/06b1455e4bc653fcb2117ac3ef5fa3a8a14919b93c60742d04440605d058/fonttools-4.60.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ name: fonttools
+ version: 4.60.1
+ sha256: 2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77
+ requires_dist:
+ - lxml>=4.0 ; extra == 'lxml'
+ - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff'
+ - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff'
+ - zopfli>=0.1.4 ; extra == 'woff'
+ - unicodedata2>=15.1.0 ; python_full_version < '3.13' and extra == 'unicode'
+ - lz4>=1.7.4.2 ; extra == 'graphite'
+ - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable'
+ - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable'
+ - pycairo ; extra == 'interpolatable'
+ - matplotlib ; extra == 'plot'
+ - sympy ; extra == 'symfont'
+ - xattr ; sys_platform == 'darwin' and extra == 'type1'
+ - skia-pathops>=0.5.0 ; extra == 'pathops'
+ - uharfbuzz>=0.23.0 ; extra == 'repacker'
+ - lxml>=4.0 ; extra == 'all'
+ - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all'
+ - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all'
+ - zopfli>=0.1.4 ; extra == 'all'
+ - unicodedata2>=15.1.0 ; python_full_version < '3.13' and extra == 'all'
+ - lz4>=1.7.4.2 ; extra == 'all'
+ - scipy ; platform_python_implementation != 'PyPy' and extra == 'all'
+ - munkres ; platform_python_implementation == 'PyPy' and extra == 'all'
+ - pycairo ; extra == 'all'
+ - matplotlib ; extra == 'all'
+ - sympy ; extra == 'all'
+ - xattr ; sys_platform == 'darwin' and extra == 'all'
+ - skia-pathops>=0.5.0 ; extra == 'all'
+ - uharfbuzz>=0.23.0 ; extra == 'all'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/7c/5b/cdd2c612277b7ac7ec8c0c9bc41812c43dc7b2d5f2b0897e15fdf5a1f915/fonttools-4.60.1-cp313-cp313-macosx_10_13_universal2.whl
+ name: fonttools
+ version: 4.60.1
+ sha256: 6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb
+ requires_dist:
+ - lxml>=4.0 ; extra == 'lxml'
+ - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'woff'
+ - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'woff'
+ - zopfli>=0.1.4 ; extra == 'woff'
+ - unicodedata2>=15.1.0 ; python_full_version < '3.13' and extra == 'unicode'
+ - lz4>=1.7.4.2 ; extra == 'graphite'
+ - scipy ; platform_python_implementation != 'PyPy' and extra == 'interpolatable'
+ - munkres ; platform_python_implementation == 'PyPy' and extra == 'interpolatable'
+ - pycairo ; extra == 'interpolatable'
+ - matplotlib ; extra == 'plot'
+ - sympy ; extra == 'symfont'
+ - xattr ; sys_platform == 'darwin' and extra == 'type1'
+ - skia-pathops>=0.5.0 ; extra == 'pathops'
+ - uharfbuzz>=0.23.0 ; extra == 'repacker'
+ - lxml>=4.0 ; extra == 'all'
+ - brotli>=1.0.1 ; platform_python_implementation == 'CPython' and extra == 'all'
+ - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'all'
+ - zopfli>=0.1.4 ; extra == 'all'
+ - unicodedata2>=15.1.0 ; python_full_version < '3.13' and extra == 'all'
+ - lz4>=1.7.4.2 ; extra == 'all'
+ - scipy ; platform_python_implementation != 'PyPy' and extra == 'all'
+ - munkres ; platform_python_implementation == 'PyPy' and extra == 'all'
+ - pycairo ; extra == 'all'
+ - matplotlib ; extra == 'all'
+ - sympy ; extra == 'all'
+ - xattr ; sys_platform == 'darwin' and extra == 'all'
+ - skia-pathops>=0.5.0 ; extra == 'all'
+ - uharfbuzz>=0.23.0 ; extra == 'all'
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.1-ha770c72_0.conda
+ sha256: bf8e4dffe46f7d25dc06f31038cacb01672c47b9f45201f065b0f4d00ab0a83e
+ md5: 4afc585cd97ba8a23809406cd8a9eda8
+ depends:
+ - libfreetype 2.14.1 ha770c72_0
+ - libfreetype6 2.14.1 h73754d4_0
+ license: GPL-2.0-only OR FTL
+ purls: []
+ size: 173114
+ timestamp: 1757945422243
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/freetype-2.14.1-h8af1aa0_0.conda
+ sha256: 9f8de35e95ce301cecfe01bc9d539c7cc045146ffba55efe9733ff77ad1cfb21
+ md5: 0c8f36ebd3678eed1685f0fc93fc2175
+ depends:
+ - libfreetype 2.14.1 h8af1aa0_0
+ - libfreetype6 2.14.1 hdae7a39_0
+ license: GPL-2.0-only OR FTL
+ purls: []
+ size: 173174
+ timestamp: 1757945489158
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/freetype-2.14.1-hce30654_0.conda
+ sha256: 14427aecd72e973a73d5f9dfd0e40b6bc3791d253de09b7bf233f6a9a190fd17
+ md5: 1ec9a1ee7a2c9339774ad9bb6fe6caec
+ depends:
+ - libfreetype 2.14.1 hce30654_0
+ - libfreetype6 2.14.1 h6da58f4_0
+ license: GPL-2.0-only OR FTL
+ purls: []
+ size: 173399
+ timestamp: 1757947175403
+- conda: https://conda.anaconda.org/conda-forge/linux-64/fribidi-1.0.16-hb03c661_0.conda
+ sha256: 858283ff33d4c033f4971bf440cebff217d5552a5222ba994c49be990dacd40d
+ md5: f9f81ea472684d75b9dd8d0b328cf655
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 61244
+ timestamp: 1757438574066
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/fribidi-1.0.16-he30d5cf_0.conda
+ sha256: 1bfcd715bcb49a0b22d5d1899a22c6ff884b06f8e141eb746f3949752469a422
+ md5: f3ac54914f7d3e1d68cb8d891765e5f9
+ depends:
+ - libgcc >=14
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 62909
+ timestamp: 1757438620177
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/fribidi-1.0.16-hc919400_0.conda
+ sha256: d856dc6744ecfba78c5f7df3378f03a75c911aadac803fa2b41a583667b4b600
+ md5: 04bdce8d93a4ed181d1d726163c2d447
+ depends:
+ - __osx >=11.0
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 59391
+ timestamp: 1757438897523
+- pypi: https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl
+ name: frozenlist
+ version: 1.8.0
+ sha256: f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ name: frozenlist
+ version: 1.8.0
+ sha256: eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ name: frozenlist
+ version: 1.8.0
+ sha256: fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl
+ name: fsspec
+ version: 2026.3.0
+ sha256: d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4
+ requires_dist:
+ - adlfs ; extra == 'abfs'
+ - adlfs ; extra == 'adl'
+ - pyarrow>=1 ; extra == 'arrow'
+ - dask ; extra == 'dask'
+ - distributed ; extra == 'dask'
+ - pre-commit ; extra == 'dev'
+ - ruff>=0.5 ; extra == 'dev'
+ - numpydoc ; extra == 'doc'
+ - sphinx ; extra == 'doc'
+ - sphinx-design ; extra == 'doc'
+ - sphinx-rtd-theme ; extra == 'doc'
+ - yarl ; extra == 'doc'
+ - dropbox ; extra == 'dropbox'
+ - dropboxdrivefs ; extra == 'dropbox'
+ - requests ; extra == 'dropbox'
+ - adlfs ; extra == 'full'
+ - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'full'
+ - dask ; extra == 'full'
+ - distributed ; extra == 'full'
+ - dropbox ; extra == 'full'
+ - dropboxdrivefs ; extra == 'full'
+ - fusepy ; extra == 'full'
+ - gcsfs>2024.2.0 ; extra == 'full'
+ - libarchive-c ; extra == 'full'
+ - ocifs ; extra == 'full'
+ - panel ; extra == 'full'
+ - paramiko ; extra == 'full'
+ - pyarrow>=1 ; extra == 'full'
+ - pygit2 ; extra == 'full'
+ - requests ; extra == 'full'
+ - s3fs>2024.2.0 ; extra == 'full'
+ - smbprotocol ; extra == 'full'
+ - tqdm ; extra == 'full'
+ - fusepy ; extra == 'fuse'
+ - gcsfs>2024.2.0 ; extra == 'gcs'
+ - pygit2 ; extra == 'git'
+ - requests ; extra == 'github'
+ - gcsfs ; extra == 'gs'
+ - panel ; extra == 'gui'
+ - pyarrow>=1 ; extra == 'hdfs'
+ - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'http'
+ - libarchive-c ; extra == 'libarchive'
+ - ocifs ; extra == 'oci'
+ - s3fs>2024.2.0 ; extra == 's3'
+ - paramiko ; extra == 'sftp'
+ - smbprotocol ; extra == 'smb'
+ - paramiko ; extra == 'ssh'
+ - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test'
+ - numpy ; extra == 'test'
+ - pytest ; extra == 'test'
+ - pytest-asyncio!=0.22.0 ; extra == 'test'
+ - pytest-benchmark ; extra == 'test'
+ - pytest-cov ; extra == 'test'
+ - pytest-mock ; extra == 'test'
+ - pytest-recording ; extra == 'test'
+ - pytest-rerunfailures ; extra == 'test'
+ - requests ; extra == 'test'
+ - aiobotocore>=2.5.4,<3.0.0 ; extra == 'test-downstream'
+ - dask[dataframe,test] ; extra == 'test-downstream'
+ - moto[server]>4,<5 ; extra == 'test-downstream'
+ - pytest-timeout ; extra == 'test-downstream'
+ - xarray ; extra == 'test-downstream'
+ - adlfs ; extra == 'test-full'
+ - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test-full'
+ - backports-zstd ; python_full_version < '3.14' and extra == 'test-full'
+ - cloudpickle ; extra == 'test-full'
+ - dask ; extra == 'test-full'
+ - distributed ; extra == 'test-full'
+ - dropbox ; extra == 'test-full'
+ - dropboxdrivefs ; extra == 'test-full'
+ - fastparquet ; extra == 'test-full'
+ - fusepy ; extra == 'test-full'
+ - gcsfs ; extra == 'test-full'
+ - jinja2 ; extra == 'test-full'
+ - kerchunk ; extra == 'test-full'
+ - libarchive-c ; extra == 'test-full'
+ - lz4 ; extra == 'test-full'
+ - notebook ; extra == 'test-full'
+ - numpy ; extra == 'test-full'
+ - ocifs ; extra == 'test-full'
+ - pandas<3.0.0 ; extra == 'test-full'
+ - panel ; extra == 'test-full'
+ - paramiko ; extra == 'test-full'
+ - pyarrow ; extra == 'test-full'
+ - pyarrow>=1 ; extra == 'test-full'
+ - pyftpdlib ; extra == 'test-full'
+ - pygit2 ; extra == 'test-full'
+ - pytest ; extra == 'test-full'
+ - pytest-asyncio!=0.22.0 ; extra == 'test-full'
+ - pytest-benchmark ; extra == 'test-full'
+ - pytest-cov ; extra == 'test-full'
+ - pytest-mock ; extra == 'test-full'
+ - pytest-recording ; extra == 'test-full'
+ - pytest-rerunfailures ; extra == 'test-full'
+ - python-snappy ; extra == 'test-full'
+ - requests ; extra == 'test-full'
+ - smbprotocol ; extra == 'test-full'
+ - tqdm ; extra == 'test-full'
+ - urllib3 ; extra == 'test-full'
+ - zarr ; extra == 'test-full'
+ - zstandard ; python_full_version < '3.14' and extra == 'test-full'
+ - tqdm ; extra == 'tqdm'
+ requires_python: '>=3.10'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/gdk-pixbuf-2.44.1-h2b0a6b4_0.conda
+ sha256: b827285fe001806beeddcc30953d2bd07869aeb0efe4581d56432c92c06b0c48
+ md5: 2935d9c0526277bd42373cf23d49d51f
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
+ - libglib >=2.86.0,<3.0a0
+ - libjpeg-turbo >=3.1.0,<4.0a0
+ - liblzma >=5.8.1,<6.0a0
+ - libpng >=1.6.50,<1.7.0a0
+ - libtiff >=4.7.0,<4.8.0a0
+ license: LGPL-2.1-or-later
+ license_family: LGPL
+ purls: []
+ size: 579596
+ timestamp: 1757867209855
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gdk-pixbuf-2.44.4-h90308e0_0.conda
+ sha256: 78a1d69c3d0da73b4d54a35001abd4e273605180d21365b4f31e9a241d9fb715
+ md5: 4c8c0d2f7620467869d41f29304362dc
+ depends:
+ - libgcc >=14
+ - libglib >=2.86.0,<3.0a0
+ - libjpeg-turbo >=3.1.0,<4.0a0
+ - liblzma >=5.8.1,<6.0a0
+ - libpng >=1.6.50,<1.7.0a0
+ - libtiff >=4.7.1,<4.8.0a0
+ license: LGPL-2.1-or-later
+ license_family: LGPL
+ purls: []
+ size: 580454
+ timestamp: 1761083738779
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gdk-pixbuf-2.44.4-h7542897_0.conda
+ sha256: 1164ba63360736439c6e50f2d390e93f04df86901e7711de41072a32d9b8bfc9
+ md5: 0b349c0400357e701cf2fa69371e5d39
+ depends:
+ - __osx >=11.0
+ - libglib >=2.86.0,<3.0a0
+ - libintl >=0.25.1,<1.0a0
+ - libjpeg-turbo >=3.1.0,<4.0a0
+ - liblzma >=5.8.1,<6.0a0
+ - libpng >=1.6.50,<1.7.0a0
+ - libtiff >=4.7.1,<4.8.0a0
+ license: LGPL-2.1-or-later
+ license_family: LGPL
+ purls: []
+ size: 544149
+ timestamp: 1761082904334
+- conda: https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.86.0-hf516916_0.conda
+ sha256: b77316bd5c8680bde4e5a7ab7013c8f0f10c1702cc6c3b0fd0fac3923a31fec3
+ md5: 1a8e49615381c381659de1bc6a3bf9ec
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
+ - libglib 2.86.0 h1fed272_0
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 117284
+ timestamp: 1757403341964
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/glib-tools-2.86.1-hc87f4d4_1.conda
+ sha256: 59d89ed84223775b4354c2bc0fc51c465ee1caf53607bf7eae868b0aca4b5a9e
+ md5: eabd2c76bb4cbf80fd78bb5e7d8122d7
+ depends:
+ - libgcc >=14
+ - libglib 2.86.1 he84ff74_1
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 126254
+ timestamp: 1761874152194
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/glib-tools-2.86.1-hb9d6e3a_1.conda
+ sha256: 6492472d76db47d85699c895acbe6b578ee0d4a964490388e71aec8777c0e9ec
+ md5: 5a90e74e57c0d1e2381ce1246b0a2125
+ depends:
+ - __osx >=11.0
+ - libglib 2.86.1 he69a767_1
+ - libintl >=0.25.1,<1.0a0
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 101419
+ timestamp: 1761875708283
+- conda: https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda
+ sha256: 25ba37da5c39697a77fce2c9a15e48cf0a84f1464ad2aafbe53d8357a9f6cc8c
+ md5: 2cd94587f3a401ae05e03a6caf09539d
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
+ - libstdcxx >=14
+ license: LGPL-2.0-or-later
+ license_family: LGPL
+ purls: []
+ size: 99596
+ timestamp: 1755102025473
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/graphite2-1.3.14-hfae3067_2.conda
+ sha256: c9b1781fe329e0b77c5addd741e58600f50bef39321cae75eba72f2f381374b7
+ md5: 4aa540e9541cc9d6581ab23ff2043f13
+ depends:
+ - libgcc >=14
+ - libstdcxx >=14
+ license: LGPL-2.0-or-later
+ license_family: LGPL
+ purls: []
+ size: 102400
+ timestamp: 1755102000043
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphite2-1.3.14-hec049ff_2.conda
+ sha256: c507ae9989dbea7024aa6feaebb16cbf271faac67ac3f0342ef1ab747c20475d
+ md5: 0fc46fee39e88bbcf5835f71a9d9a209
+ depends:
+ - __osx >=11.0
+ - libcxx >=19
+ license: LGPL-2.0-or-later
+ license_family: LGPL
+ purls: []
+ size: 81202
+ timestamp: 1755102333712
+- pypi: https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl
+ name: graphviz
+ version: '0.21'
+ sha256: 54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42
+ requires_dist:
+ - build ; extra == 'dev'
+ - wheel ; extra == 'dev'
+ - twine ; extra == 'dev'
+ - flake8 ; extra == 'dev'
+ - flake8-pyproject ; extra == 'dev'
+ - pep8-naming ; extra == 'dev'
+ - tox>=3 ; extra == 'dev'
+ - pytest>=7,<8.1 ; extra == 'test'
+ - pytest-mock>=3 ; extra == 'test'
+ - pytest-cov ; extra == 'test'
+ - coverage ; extra == 'test'
+ - sphinx>=5,<7 ; extra == 'docs'
+ - sphinx-autodoc-typehints ; extra == 'docs'
+ - sphinx-rtd-theme>=0.2.5 ; extra == 'docs'
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/graphviz-13.1.2-h87b6fe6_0.conda
+ sha256: efbd7d483f3d79b7882515ccf229eceb7f4ff636ea2019044e98243722f428be
+ md5: 0adddc9b820f596638d8b0ff9e3b4823
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - adwaita-icon-theme
+ - cairo >=1.18.4,<2.0a0
+ - fonts-conda-ecosystem
+ - gdk-pixbuf >=2.42.12,<3.0a0
+ - gtk3 >=3.24.43,<4.0a0
+ - gts >=0.7.6,<0.8.0a0
+ - libexpat >=2.7.1,<3.0a0
+ - libgcc >=14
+ - libgd >=2.3.3,<2.4.0a0
+ - libglib >=2.84.3,<3.0a0
+ - librsvg >=2.58.4,<3.0a0
+ - libstdcxx >=14
+ - libwebp-base >=1.6.0,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - pango >=1.56.4,<2.0a0
+ license: EPL-1.0
+ license_family: Other
+ purls: []
+ size: 2427887
+ timestamp: 1754732581595
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/graphviz-13.1.2-hdb06ba2_0.conda
+ sha256: 15f0f8bc5b5fc1c51be13f0dd4e2dcfb4cd6555e75b18656d51def0d8b7e4db2
+ md5: 52fc4ad5de8b211077edfa9e657f6cab
+ depends:
+ - adwaita-icon-theme
+ - cairo >=1.18.4,<2.0a0
+ - fonts-conda-ecosystem
+ - gdk-pixbuf >=2.42.12,<3.0a0
+ - gtk3 >=3.24.43,<4.0a0
+ - gts >=0.7.6,<0.8.0a0
+ - libexpat >=2.7.1,<3.0a0
+ - libgcc >=14
+ - libgd >=2.3.3,<2.4.0a0
+ - libglib >=2.84.3,<3.0a0
+ - librsvg >=2.58.4,<3.0a0
+ - libstdcxx >=14
+ - libwebp-base >=1.6.0,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - pango >=1.56.4,<2.0a0
+ license: EPL-1.0
+ license_family: Other
+ purls: []
+ size: 2557826
+ timestamp: 1754732391605
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/graphviz-13.1.2-hcd33d8b_0.conda
+ sha256: f25e1828d02ebd78214966f483cfca5ac6a7b18824369c748d8cda99c66ff588
+ md5: 81ab85a5a8481667660c7ce6e84bd681
+ depends:
+ - __osx >=11.0
+ - adwaita-icon-theme
+ - cairo >=1.18.4,<2.0a0
+ - fonts-conda-ecosystem
+ - gdk-pixbuf >=2.42.12,<3.0a0
+ - gtk3 >=3.24.43,<4.0a0
+ - gts >=0.7.6,<0.8.0a0
+ - libcxx >=19
+ - libexpat >=2.7.1,<3.0a0
+ - libgd >=2.3.3,<2.4.0a0
+ - libglib >=2.84.3,<3.0a0
+ - librsvg >=2.58.4,<3.0a0
+ - libwebp-base >=1.6.0,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - pango >=1.56.4,<2.0a0
+ license: EPL-1.0
+ license_family: Other
+ purls: []
+ size: 2201370
+ timestamp: 1754732518951
+- pypi: https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl
+ name: greenlet
+ version: 3.3.2
+ sha256: ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986
+ requires_dist:
+ - sphinx ; extra == 'docs'
+ - furo ; extra == 'docs'
+ - objgraph ; extra == 'test'
+ - psutil ; extra == 'test'
+ - setuptools ; extra == 'test'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ name: greenlet
+ version: 3.3.2
+ sha256: b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab
+ requires_dist:
+ - sphinx ; extra == 'docs'
+ - furo ; extra == 'docs'
+ - objgraph ; extra == 'test'
+ - psutil ; extra == 'test'
+ - setuptools ; extra == 'test'
+ requires_python: '>=3.10'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/gtk3-3.24.43-h0c6a113_5.conda
+ sha256: d36263cbcbce34ec463ce92bd72efa198b55d987959eab6210cc256a0e79573b
+ md5: 67d00e9cfe751cfe581726c5eff7c184
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - at-spi2-atk >=2.38.0,<3.0a0
+ - atk-1.0 >=2.38.0
+ - cairo >=1.18.4,<2.0a0
+ - epoxy >=1.5.10,<1.6.0a0
+ - fontconfig >=2.15.0,<3.0a0
+ - fonts-conda-ecosystem
+ - fribidi >=1.0.10,<2.0a0
+ - gdk-pixbuf >=2.42.12,<3.0a0
+ - glib-tools
+ - harfbuzz >=11.0.0,<12.0a0
+ - hicolor-icon-theme
+ - libcups >=2.3.3,<2.4.0a0
+ - libcups >=2.3.3,<3.0a0
+ - libexpat >=2.6.4,<3.0a0
+ - libgcc >=13
+ - libglib >=2.84.0,<3.0a0
+ - liblzma >=5.6.4,<6.0a0
+ - libxkbcommon >=1.8.1,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - pango >=1.56.3,<2.0a0
+ - wayland >=1.23.1,<2.0a0
+ - xorg-libx11 >=1.8.12,<2.0a0
+ - xorg-libxcomposite >=0.4.6,<1.0a0
+ - xorg-libxcursor >=1.2.3,<2.0a0
+ - xorg-libxdamage >=1.1.6,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ - xorg-libxfixes >=6.0.1,<7.0a0
+ - xorg-libxi >=1.8.2,<2.0a0
+ - xorg-libxinerama >=1.1.5,<1.2.0a0
+ - xorg-libxrandr >=1.5.4,<2.0a0
+ - xorg-libxrender >=0.9.12,<0.10.0a0
+ license: LGPL-2.0-or-later
+ license_family: LGPL
+ purls: []
+ size: 5585389
+ timestamp: 1743405684985
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gtk3-3.24.43-h4cd1324_6.conda
+ sha256: 5b8c5255d88d97083095790765dfafda6ce99daa8dcaaa8c0b668e82c5b73187
+ md5: 124842a6e0b59cbd121233346bd56e33
+ depends:
+ - at-spi2-atk >=2.38.0,<3.0a0
+ - atk-1.0 >=2.38.0
+ - cairo >=1.18.4,<2.0a0
+ - epoxy >=1.5.10,<1.6.0a0
+ - fontconfig >=2.15.0,<3.0a0
+ - fonts-conda-ecosystem
+ - fribidi >=1.0.16,<2.0a0
+ - gdk-pixbuf >=2.44.4,<3.0a0
+ - glib-tools
+ - harfbuzz >=11.5.1
+ - hicolor-icon-theme
+ - libcups >=2.3.3,<2.4.0a0
+ - libcups >=2.3.3,<3.0a0
+ - libexpat >=2.7.1,<3.0a0
+ - libgcc >=14
+ - libglib >=2.86.0,<3.0a0
+ - liblzma >=5.8.1,<6.0a0
+ - libxkbcommon >=1.12.2,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - pango >=1.56.4,<2.0a0
+ - wayland >=1.24.0,<2.0a0
+ - xorg-libx11 >=1.8.12,<2.0a0
+ - xorg-libxcomposite >=0.4.6,<1.0a0
+ - xorg-libxcursor >=1.2.3,<2.0a0
+ - xorg-libxdamage >=1.1.6,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ - xorg-libxfixes >=6.0.2,<7.0a0
+ - xorg-libxi >=1.8.2,<2.0a0
+ - xorg-libxinerama >=1.1.5,<1.2.0a0
+ - xorg-libxrandr >=1.5.4,<2.0a0
+ - xorg-libxrender >=0.9.12,<0.10.0a0
+ license: LGPL-2.0-or-later
+ license_family: LGPL
+ purls: []
+ size: 5660172
+ timestamp: 1761334356772
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gtk3-3.24.43-h5febe37_6.conda
+ sha256: bd66a3325bf3ce63ada3bf12eaafcfe036698741ee4bb595e83e5fdd3dba9f3d
+ md5: a99f96906158ebae5e3c0904bcd45145
+ depends:
+ - __osx >=11.0
+ - atk-1.0 >=2.38.0
+ - cairo >=1.18.4,<2.0a0
+ - epoxy >=1.5.10,<1.6.0a0
+ - fribidi >=1.0.16,<2.0a0
+ - gdk-pixbuf >=2.44.4,<3.0a0
+ - glib-tools
+ - harfbuzz >=11.5.1
+ - hicolor-icon-theme
+ - libexpat >=2.7.1,<3.0a0
+ - libglib >=2.86.0,<3.0a0
+ - libintl >=0.25.1,<1.0a0
+ - liblzma >=5.8.1,<6.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - pango >=1.56.4,<2.0a0
+ license: LGPL-2.0-or-later
+ license_family: LGPL
+ purls: []
+ size: 4768791
+ timestamp: 1761328318680
+- conda: https://conda.anaconda.org/conda-forge/linux-64/gts-0.7.6-h977cf35_4.conda
+ sha256: b5cd16262fefb836f69dc26d879b6508d29f8a5c5948a966c47fe99e2e19c99b
+ md5: 4d8df0b0db060d33c9a702ada998a8fe
+ depends:
+ - libgcc-ng >=12
+ - libglib >=2.76.3,<3.0a0
+ - libstdcxx-ng >=12
+ license: LGPL-2.0-or-later
+ license_family: LGPL
+ purls: []
+ size: 318312
+ timestamp: 1686545244763
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/gts-0.7.6-he293c15_4.conda
+ sha256: 1e9cc30d1c746d5a3399a279f5f642a953f37d9f9c82fd4d55b301e9c2a23f7c
+ md5: 2aeaeddbd89e84b60165463225814cfc
+ depends:
+ - libgcc-ng >=12
+ - libglib >=2.76.3,<3.0a0
+ - libstdcxx-ng >=12
+ license: LGPL-2.0-or-later
+ license_family: LGPL
+ purls: []
+ size: 332673
+ timestamp: 1686545222091
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/gts-0.7.6-he42f4ea_4.conda
+ sha256: e0f8c7bc1b9ea62ded78ffa848e37771eeaaaf55b3146580513c7266862043ba
+ md5: 21b4dd3098f63a74cf2aa9159cbef57d
+ depends:
+ - libcxx >=15.0.7
+ - libglib >=2.76.3,<3.0a0
+ license: LGPL-2.0-or-later
+ license_family: LGPL
+ purls: []
+ size: 304331
+ timestamp: 1686545503242
+- conda: https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-11.5.0-h15599e2_0.conda
+ sha256: 04d33cef3345ce6e3fbbfb5539ebc8a3730026ea94ce6ace1f8f8d3551fa079c
+ md5: 47599428437d622bfee24fbd06a2d0b4
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - cairo >=1.18.4,<2.0a0
+ - graphite2 >=1.3.14,<2.0a0
+ - icu >=75.1,<76.0a0
+ - libexpat >=2.7.1,<3.0a0
+ - libfreetype >=2.14.0
+ - libfreetype6 >=2.14.0
+ - libgcc >=14
+ - libglib >=2.86.0,<3.0a0
+ - libstdcxx >=14
+ - libzlib >=1.3.1,<2.0a0
+ license: MIT
+ purls: []
+ size: 2048134
+ timestamp: 1757867460348
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/harfbuzz-12.2.0-he4899c9_0.conda
+ sha256: 5cfd74a3fbce0921af5beff93a3fe7edc5b1344d9b9668b2de1c1be932b54993
+ md5: 1437bf9690976948f90175a65407b65f
+ depends:
+ - cairo >=1.18.4,<2.0a0
+ - graphite2 >=1.3.14,<2.0a0
+ - icu >=75.1,<76.0a0
+ - libexpat >=2.7.1,<3.0a0
+ - libfreetype >=2.14.1
+ - libfreetype6 >=2.14.1
+ - libgcc >=14
+ - libglib >=2.86.1,<3.0a0
+ - libstdcxx >=14
+ - libzlib >=1.3.1,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 2156041
+ timestamp: 1762376447693
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/harfbuzz-12.1.0-haf38c7b_0.conda
+ sha256: 8f2fac3e74608af55334ab9e77e9db9112c9078858aa938d191481d873a902d3
+ md5: 3fd0b257d246ddedd1f1496e5246958d
+ depends:
+ - __osx >=11.0
+ - cairo >=1.18.4,<2.0a0
+ - graphite2 >=1.3.14,<2.0a0
+ - icu >=75.1,<76.0a0
+ - libcxx >=19
+ - libexpat >=2.7.1,<3.0a0
+ - libfreetype >=2.14.1
+ - libfreetype6 >=2.14.1
+ - libglib >=2.86.0,<3.0a0
+ - libzlib >=1.3.1,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 1548996
+ timestamp: 1759366687572
+- conda: https://conda.anaconda.org/conda-forge/linux-64/hicolor-icon-theme-0.17-ha770c72_2.tar.bz2
+ sha256: 336f29ceea9594f15cc8ec4c45fdc29e10796573c697ee0d57ebb7edd7e92043
+ md5: bbf6f174dcd3254e19a2f5d2295ce808
+ license: GPL-2.0-or-later
+ license_family: GPL
+ purls: []
+ size: 13841
+ timestamp: 1605162808667
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/hicolor-icon-theme-0.17-h8af1aa0_2.tar.bz2
+ sha256: 479a0f95cf3e7d7db795fb7a14337cab73c2c926a5599c8512a3e8f8466f9e54
+ md5: 331add9f855e921695d7b569aa23d5ec
+ license: GPL-2.0-or-later
+ license_family: GPL
+ purls: []
+ size: 13896
+ timestamp: 1605162856037
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/hicolor-icon-theme-0.17-hce30654_2.tar.bz2
+ sha256: 286e33fb452f61133a3a61d002890235d1d1378554218ab063d6870416440281
+ md5: 237b05b7eb284d7eebc3c5d93f5e4bca
+ license: GPL-2.0-or-later
+ license_family: GPL
+ purls: []
+ size: 13800
+ timestamp: 1611053664863
+- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-75.1-he02047a_0.conda
+ sha256: 71e750d509f5fa3421087ba88ef9a7b9be11c53174af3aa4d06aff4c18b38e8e
+ md5: 8b189310083baabfb622af68fd9d3ae3
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc-ng >=12
+ - libstdcxx-ng >=12
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 12129203
+ timestamp: 1720853576813
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/icu-75.1-hf9b3779_0.conda
+ sha256: 813298f2e54ef087dbfc9cc2e56e08ded41de65cff34c639cc8ba4e27e4540c9
+ md5: 268203e8b983fddb6412b36f2024e75c
+ depends:
+ - libgcc-ng >=12
+ - libstdcxx-ng >=12
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 12282786
+ timestamp: 1720853454991
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-75.1-hfee45f7_0.conda
+ sha256: 9ba12c93406f3df5ab0a43db8a4b4ef67a5871dfd401010fbe29b218b2cbe620
+ md5: 5eb22c1d7b3fc4abb50d92d621583137
+ depends:
+ - __osx >=11.0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 11857802
+ timestamp: 1720853997952
+- pypi: https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl
+ name: identify
+ version: 2.6.14
+ sha256: 11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e
+ requires_dist:
+ - ukkonen ; extra == 'license'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl
+ name: identify
+ version: 2.6.15
+ sha256: 1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757
+ requires_dist:
+ - ukkonen ; extra == 'license'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl
+ name: idna
+ version: '3.10'
+ sha256: 946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
+ requires_dist:
+ - ruff>=0.6.2 ; extra == 'all'
+ - mypy>=1.11.2 ; extra == 'all'
+ - pytest>=8.3.2 ; extra == 'all'
+ - flake8>=7.1.1 ; extra == 'all'
+ requires_python: '>=3.6'
+- pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl
+ name: idna
+ version: '3.11'
+ sha256: 771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea
+ requires_dist:
+ - ruff>=0.6.2 ; extra == 'all'
+ - mypy>=1.11.2 ; extra == 'all'
+ - pytest>=8.3.2 ; extra == 'all'
+ - flake8>=7.1.1 ; extra == 'all'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl
+ name: iniconfig
+ version: 2.1.0
+ sha256: 9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl
+ name: iniconfig
+ version: 2.3.0
+ sha256: f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/08/2a/5628a99d04acb2d2f2e749cdf4ea571d2575e898df0528a090948018b726/ipython-9.5.0-py3-none-any.whl
+ name: ipython
+ version: 9.5.0
+ sha256: 88369ffa1d5817d609120daa523a6da06d02518e582347c29f8451732a9c5e72
+ requires_dist:
+ - colorama ; sys_platform == 'win32'
+ - decorator
+ - ipython-pygments-lexers
+ - jedi>=0.16
+ - matplotlib-inline
+ - pexpect>4.3 ; sys_platform != 'emscripten' and sys_platform != 'win32'
+ - prompt-toolkit>=3.0.41,<3.1.0
+ - pygments>=2.4.0
+ - stack-data
+ - traitlets>=5.13.0
+ - typing-extensions>=4.6 ; python_full_version < '3.12'
+ - black ; extra == 'black'
+ - docrepr ; extra == 'doc'
+ - exceptiongroup ; extra == 'doc'
+ - intersphinx-registry ; extra == 'doc'
+ - ipykernel ; extra == 'doc'
+ - ipython[test] ; extra == 'doc'
+ - matplotlib ; extra == 'doc'
+ - setuptools>=18.5 ; extra == 'doc'
+ - sphinx-toml==0.0.4 ; extra == 'doc'
+ - sphinx-rtd-theme ; extra == 'doc'
+ - sphinx>=1.3 ; extra == 'doc'
+ - typing-extensions ; extra == 'doc'
+ - pytest ; extra == 'test'
+ - pytest-asyncio ; extra == 'test'
+ - testpath ; extra == 'test'
+ - packaging ; extra == 'test'
+ - ipython[test] ; extra == 'test-extra'
+ - curio ; extra == 'test-extra'
+ - jupyter-ai ; extra == 'test-extra'
+ - matplotlib!=3.2.0 ; extra == 'test-extra'
+ - nbformat ; extra == 'test-extra'
+ - nbclient ; extra == 'test-extra'
+ - ipykernel ; extra == 'test-extra'
+ - numpy>=1.23 ; extra == 'test-extra'
+ - pandas ; extra == 'test-extra'
+ - trio ; extra == 'test-extra'
+ - matplotlib ; extra == 'matplotlib'
+ - ipython[doc,matplotlib,test,test-extra] ; extra == 'all'
+ requires_python: '>=3.11'
+- pypi: https://files.pythonhosted.org/packages/48/c5/d5e07995077e48220269c28a221e168c91123ad5ceee44d548f54a057fc0/ipython-9.6.0-py3-none-any.whl
+ name: ipython
+ version: 9.6.0
+ sha256: 5f77efafc886d2f023442479b8149e7d86547ad0a979e9da9f045d252f648196
+ requires_dist:
+ - colorama ; sys_platform == 'win32'
+ - decorator
+ - ipython-pygments-lexers
+ - jedi>=0.16
+ - matplotlib-inline
+ - pexpect>4.3 ; sys_platform != 'emscripten' and sys_platform != 'win32'
+ - prompt-toolkit>=3.0.41,<3.1.0
+ - pygments>=2.4.0
+ - stack-data
+ - traitlets>=5.13.0
+ - typing-extensions>=4.6 ; python_full_version < '3.12'
+ - black ; extra == 'black'
+ - docrepr ; extra == 'doc'
+ - exceptiongroup ; extra == 'doc'
+ - intersphinx-registry ; extra == 'doc'
+ - ipykernel ; extra == 'doc'
+ - ipython[matplotlib,test] ; extra == 'doc'
+ - setuptools>=61.2 ; extra == 'doc'
+ - sphinx-toml==0.0.4 ; extra == 'doc'
+ - sphinx-rtd-theme ; extra == 'doc'
+ - sphinx>=1.3 ; extra == 'doc'
+ - typing-extensions ; extra == 'doc'
+ - pytest ; extra == 'test'
+ - pytest-asyncio ; extra == 'test'
+ - testpath ; extra == 'test'
+ - packaging ; extra == 'test'
+ - ipython[test] ; extra == 'test-extra'
+ - curio ; extra == 'test-extra'
+ - jupyter-ai ; extra == 'test-extra'
+ - ipython[matplotlib] ; extra == 'test-extra'
+ - nbformat ; extra == 'test-extra'
+ - nbclient ; extra == 'test-extra'
+ - ipykernel ; extra == 'test-extra'
+ - numpy>=1.25 ; extra == 'test-extra'
+ - pandas>2.0 ; extra == 'test-extra'
+ - trio ; extra == 'test-extra'
+ - matplotlib>3.7 ; extra == 'matplotlib'
+ - ipython[doc,matplotlib,test,test-extra] ; extra == 'all'
+ requires_python: '>=3.11'
+- pypi: https://files.pythonhosted.org/packages/05/aa/62893d6a591d337aa59dcc4c6f6c842f1fe20cd72c8c5c1f980255243252/ipython-9.7.0-py3-none-any.whl
+ name: ipython
+ version: 9.7.0
+ sha256: bce8ac85eb9521adc94e1845b4c03d88365fd6ac2f4908ec4ed1eb1b0a065f9f
+ requires_dist:
+ - colorama>=0.4.4 ; sys_platform == 'win32'
+ - decorator>=4.3.2
+ - ipython-pygments-lexers>=1.0.0
+ - jedi>=0.18.1
+ - matplotlib-inline>=0.1.5
+ - pexpect>4.3 ; sys_platform != 'emscripten' and sys_platform != 'win32'
+ - prompt-toolkit>=3.0.41,<3.1.0
+ - pygments>=2.11.0
+ - stack-data>=0.6.0
+ - traitlets>=5.13.0
+ - typing-extensions>=4.6 ; python_full_version < '3.12'
+ - black ; extra == 'black'
+ - docrepr ; extra == 'doc'
+ - exceptiongroup ; extra == 'doc'
+ - intersphinx-registry ; extra == 'doc'
+ - ipykernel ; extra == 'doc'
+ - ipython[matplotlib,test] ; extra == 'doc'
+ - setuptools>=70.0 ; extra == 'doc'
+ - sphinx-toml==0.0.4 ; extra == 'doc'
+ - sphinx-rtd-theme>=0.1.8 ; extra == 'doc'
+ - sphinx>=8.0 ; extra == 'doc'
+ - typing-extensions ; extra == 'doc'
+ - pytest>=7.0.0 ; extra == 'test'
+ - pytest-asyncio>=1.0.0 ; extra == 'test'
+ - testpath>=0.2 ; extra == 'test'
+ - packaging>=20.1.0 ; extra == 'test'
+ - setuptools>=61.2 ; extra == 'test'
+ - ipython[test] ; extra == 'test-extra'
+ - curio ; extra == 'test-extra'
+ - jupyter-ai ; extra == 'test-extra'
+ - ipython[matplotlib] ; extra == 'test-extra'
+ - nbformat ; extra == 'test-extra'
+ - nbclient ; extra == 'test-extra'
+ - ipykernel>6.30 ; extra == 'test-extra'
+ - numpy>=1.27 ; extra == 'test-extra'
+ - pandas>2.1 ; extra == 'test-extra'
+ - trio>=0.1.0 ; extra == 'test-extra'
+ - matplotlib>3.9 ; extra == 'matplotlib'
+ - ipython[doc,matplotlib,test,test-extra] ; extra == 'all'
+ requires_python: '>=3.11'
+- pypi: https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl
+ name: ipython-pygments-lexers
+ version: 1.1.1
+ sha256: a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c
+ requires_dist:
+ - pygments
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl
+ name: jedi
+ version: 0.19.2
+ sha256: a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9
+ requires_dist:
+ - parso>=0.8.4,<0.9.0
+ - jinja2==2.11.3 ; extra == 'docs'
+ - markupsafe==1.1.1 ; extra == 'docs'
+ - pygments==2.8.1 ; extra == 'docs'
+ - alabaster==0.7.12 ; extra == 'docs'
+ - babel==2.9.1 ; extra == 'docs'
+ - chardet==4.0.0 ; extra == 'docs'
+ - commonmark==0.8.1 ; extra == 'docs'
+ - docutils==0.17.1 ; extra == 'docs'
+ - future==0.18.2 ; extra == 'docs'
+ - idna==2.10 ; extra == 'docs'
+ - imagesize==1.2.0 ; extra == 'docs'
+ - mock==1.0.1 ; extra == 'docs'
+ - packaging==20.9 ; extra == 'docs'
+ - pyparsing==2.4.7 ; extra == 'docs'
+ - pytz==2021.1 ; extra == 'docs'
+ - readthedocs-sphinx-ext==2.1.4 ; extra == 'docs'
+ - recommonmark==0.5.0 ; extra == 'docs'
+ - requests==2.25.1 ; extra == 'docs'
+ - six==1.15.0 ; extra == 'docs'
+ - snowballstemmer==2.1.0 ; extra == 'docs'
+ - sphinx-rtd-theme==0.4.3 ; extra == 'docs'
+ - sphinx==1.8.5 ; extra == 'docs'
+ - sphinxcontrib-serializinghtml==1.1.4 ; extra == 'docs'
+ - sphinxcontrib-websupport==1.2.4 ; extra == 'docs'
+ - urllib3==1.26.4 ; extra == 'docs'
+ - flake8==5.0.4 ; extra == 'qa'
+ - mypy==0.971 ; extra == 'qa'
+ - types-setuptools==67.2.0.1 ; extra == 'qa'
+ - django ; extra == 'testing'
+ - attrs ; extra == 'testing'
+ - colorama ; extra == 'testing'
+ - docopt ; extra == 'testing'
+ - pytest<9.0.0 ; extra == 'testing'
+ requires_python: '>=3.6'
+- pypi: https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl
+ name: jmespath
+ version: 1.1.0
+ sha256: a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda
+ sha256: 0960d06048a7185d3542d850986d807c6e37ca2e644342dd0c72feefcf26c2a4
+ md5: b38117a3c920364aff79f870c984b4a3
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 134088
+ timestamp: 1754905959823
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/keyutils-1.6.3-h86ecc28_0.conda
+ sha256: 5ce830ca274b67de11a7075430a72020c1fb7d486161a82839be15c2b84e9988
+ md5: e7df0aab10b9cbb73ab2a467ebfaf8c7
+ depends:
+ - libgcc >=13
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 129048
+ timestamp: 1754906002667
+- pypi: https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl
+ name: kiwisolver
+ version: 1.4.9
+ sha256: 1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl
+ name: kiwisolver
+ version: 1.4.9
+ sha256: 5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ name: kiwisolver
+ version: 1.4.9
+ sha256: b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098
+ requires_python: '>=3.10'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.21.3-h659f571_0.conda
+ sha256: 99df692f7a8a5c27cd14b5fb1374ee55e756631b9c3d659ed3ee60830249b238
+ md5: 3f43953b7d3fb3aaa1d0d0723d91e368
+ depends:
+ - keyutils >=1.6.1,<2.0a0
+ - libedit >=3.1.20191231,<3.2.0a0
+ - libedit >=3.1.20191231,<4.0a0
+ - libgcc-ng >=12
+ - libstdcxx-ng >=12
+ - openssl >=3.3.1,<4.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 1370023
+ timestamp: 1719463201255
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/krb5-1.21.3-h50a48e9_0.conda
+ sha256: 0ec272afcf7ea7fbf007e07a3b4678384b7da4047348107b2ae02630a570a815
+ md5: 29c10432a2ca1472b53f299ffb2ffa37
+ depends:
+ - keyutils >=1.6.1,<2.0a0
+ - libedit >=3.1.20191231,<3.2.0a0
+ - libedit >=3.1.20191231,<4.0a0
+ - libgcc-ng >=12
+ - libstdcxx-ng >=12
+ - openssl >=3.3.1,<4.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 1474620
+ timestamp: 1719463205834
+- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-h1423503_1.conda
+ sha256: 1a620f27d79217c1295049ba214c2f80372062fd251b569e9873d4a953d27554
+ md5: 0be7c6e070c19105f966d3758448d018
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ constrains:
+ - binutils_impl_linux-64 2.44
+ license: GPL-3.0-only
+ license_family: GPL
+ purls: []
+ size: 676044
+ timestamp: 1752032747103
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ld_impl_linux-aarch64-2.44-hd32f0e1_5.conda
+ sha256: cc03f3e2d5d48f1193a2d0822971b085d583327d6e20f2a5cf7d030ffdb35f9a
+ md5: 7c87c0b72575b30626a6dc5b49229f0c
+ depends:
+ - zstd >=1.5.7,<1.6.0a0
+ constrains:
+ - binutils_impl_linux-aarch64 2.44
+ license: GPL-3.0-only
+ purls: []
+ size: 782949
+ timestamp: 1762674873740
+- conda: https://conda.anaconda.org/conda-forge/linux-64/lerc-4.0.0-h0aef613_1.conda
+ sha256: 412381a43d5ff9bbed82cd52a0bbca5b90623f62e41007c9c42d3870c60945ff
+ md5: 9344155d33912347b37f0ae6c410a835
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - libstdcxx >=13
+ license: Apache-2.0
+ license_family: Apache
+ purls: []
+ size: 264243
+ timestamp: 1745264221534
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/lerc-4.0.0-hfdc4d58_1.conda
+ sha256: f01df5bbf97783fac9b89be602b4d02f94353f5221acfd80c424ec1c9a8d276c
+ md5: 60dceb7e876f4d74a9cbd42bbbc6b9cf
+ depends:
+ - libgcc >=13
+ - libstdcxx >=13
+ license: Apache-2.0
+ license_family: Apache
+ purls: []
+ size: 227184
+ timestamp: 1745265544057
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lerc-4.0.0-hd64df32_1.conda
+ sha256: 12361697f8ffc9968907d1a7b5830e34c670e4a59b638117a2cdfed8f63a38f8
+ md5: a74332d9b60b62905e3d30709df08bf1
+ depends:
+ - __osx >=11.0
+ - libcxx >=18
+ license: Apache-2.0
+ license_family: Apache
+ purls: []
+ size: 188306
+ timestamp: 1745264362794
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libcups-2.3.3-hb8b1518_5.conda
+ sha256: cb83980c57e311783ee831832eb2c20ecb41e7dee6e86e8b70b8cef0e43eab55
+ md5: d4a250da4737ee127fb1fa6452a9002e
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - krb5 >=1.21.3,<1.22.0a0
+ - libgcc >=13
+ - libstdcxx >=13
+ - libzlib >=1.3.1,<2.0a0
+ license: Apache-2.0
+ license_family: Apache
+ purls: []
+ size: 4523621
+ timestamp: 1749905341688
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libcups-2.3.3-h5cdc715_5.conda
+ sha256: f3282d27be35e5d29b5b798e5136427ec798916ee6374499be7b7682c8582b72
+ md5: ac0333d338076ef19170938bbaf97582
+ depends:
+ - krb5 >=1.21.3,<1.22.0a0
+ - libgcc >=13
+ - libstdcxx >=13
+ - libzlib >=1.3.1,<2.0a0
+ license: Apache-2.0
+ license_family: Apache
+ purls: []
+ size: 4550533
+ timestamp: 1749906839681
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.4-hf598326_2.conda
+ sha256: 0a0765cc8b6000e7f7be879c12825583d046ef22ab95efc7c5f8622e4b3302d5
+ md5: 4346830dcc0c0e930328fddb0b829f63
+ depends:
+ - __osx >=11.0
+ license: Apache-2.0 WITH LLVM-exception
+ license_family: Apache
+ purls: []
+ size: 568742
+ timestamp: 1761852287381
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.24-h86f0d12_0.conda
+ sha256: 8420748ea1cc5f18ecc5068b4f24c7a023cc9b20971c99c824ba10641fb95ddf
+ md5: 64f0c503da58ec25ebd359e4d990afa8
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 72573
+ timestamp: 1747040452262
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libdeflate-1.25-h1af38f5_0.conda
+ sha256: 48814b73bd462da6eed2e697e30c060ae16af21e9fbed30d64feaf0aad9da392
+ md5: a9138815598fe6b91a1d6782ca657b0c
+ depends:
+ - libgcc >=14
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 71117
+ timestamp: 1761979776756
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libdeflate-1.24-h5773f1b_0.conda
+ sha256: 417d52b19c679e1881cce3f01cad3a2d542098fa2d6df5485aac40f01aede4d1
+ md5: 3baf58a5a87e7c2f4d243ce2f8f2fe5c
+ depends:
+ - __osx >=11.0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 54790
+ timestamp: 1747040549847
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libdrm-2.4.125-he30d5cf_1.conda
+ sha256: 4e6cdb5dd37db794b88bec714b4418a0435b04d14e9f7afc8cc32f2a3ced12f2
+ md5: 2079727b538f6dd16f3fa579d4c3c53f
+ depends:
+ - libgcc >=14
+ - libpciaccess >=0.18,<0.19.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 344548
+ timestamp: 1757212128414
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda
+ sha256: d789471216e7aba3c184cd054ed61ce3f6dac6f87a50ec69291b9297f8c18724
+ md5: c277e0a4d549b03ac1e9d6cbbe3d017b
+ depends:
+ - ncurses
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - ncurses >=6.5,<7.0a0
+ license: BSD-2-Clause
+ license_family: BSD
+ purls: []
+ size: 134676
+ timestamp: 1738479519902
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libedit-3.1.20250104-pl5321h976ea20_0.conda
+ sha256: c0b27546aa3a23d47919226b3a1635fccdb4f24b94e72e206a751b33f46fd8d6
+ md5: fb640d776fc92b682a14e001980825b1
+ depends:
+ - ncurses
+ - libgcc >=13
+ - ncurses >=6.5,<7.0a0
+ license: BSD-2-Clause
+ license_family: BSD
+ purls: []
+ size: 148125
+ timestamp: 1738479808948
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libegl-1.7.0-hd24410f_2.conda
+ sha256: 8962abf38a58c235611ce356b9899f6caeb0352a8bce631b0bcc59352fda455e
+ md5: cf105bce884e4ef8c8ccdca9fe6695e7
+ depends:
+ - libglvnd 1.7.0 hd24410f_2
+ license: LicenseRef-libglvnd
+ purls: []
+ size: 53551
+ timestamp: 1731330990477
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libegl-devel-1.7.0-hd24410f_2.conda
+ sha256: 9c8e9d2289316741d037f0c5003de42488780d181453543f75497dd5a4891c7c
+ md5: cd8877e3833ba1bfac2fbaa5ae72c226
+ depends:
+ - libegl 1.7.0 hd24410f_2
+ - libgl-devel 1.7.0 hd24410f_2
+ - xorg-libx11
+ license: LicenseRef-libglvnd
+ purls: []
+ size: 30397
+ timestamp: 1731331017398
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda
+ sha256: da2080da8f0288b95dd86765c801c6e166c4619b910b11f9a8446fb852438dc2
+ md5: 4211416ecba1866fab0c6470986c22d6
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
+ constrains:
+ - expat 2.7.1.*
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 74811
+ timestamp: 1752719572741
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libexpat-2.7.1-hfae3067_0.conda
+ sha256: 378cabff44ea83ce4d9f9c59f47faa8d822561d39166608b3e65d1e06c927415
+ md5: f75d19f3755461db2eb69401f5514f4c
+ depends:
+ - libgcc >=14
+ constrains:
+ - expat 2.7.1.*
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 74309
+ timestamp: 1752719762749
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.1-hec049ff_0.conda
+ sha256: 8fbb17a56f51e7113ed511c5787e0dec0d4b10ef9df921c4fd1cccca0458f648
+ md5: b1ca5f21335782f71a8bd69bdc093f67
+ depends:
+ - __osx >=11.0
+ constrains:
+ - expat 2.7.1.*
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 65971
+ timestamp: 1752719657566
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.6-h2dba641_1.conda
+ sha256: 764432d32db45466e87f10621db5b74363a9f847d2b8b1f9743746cd160f06ab
+ md5: ede4673863426c0883c0063d853bbd85
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 57433
+ timestamp: 1743434498161
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libffi-3.5.2-hd65408f_0.conda
+ sha256: 6c3332e78a975e092e54f87771611db81dcb5515a3847a3641021621de76caea
+ md5: 0c5ad486dcfb188885e3cf8ba209b97b
+ depends:
+ - libgcc >=14
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 55586
+ timestamp: 1760295405021
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-he5f378a_0.conda
+ sha256: 9b8acdf42df61b7bfe8bdc545c016c29e61985e79748c64ad66df47dbc2e295f
+ md5: 411ff7cd5d1472bba0f55c0faf04453b
+ depends:
+ - __osx >=11.0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 40251
+ timestamp: 1760295839166
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.1-ha770c72_0.conda
+ sha256: 4641d37faeb97cf8a121efafd6afd040904d4bca8c46798122f417c31d5dfbec
+ md5: f4084e4e6577797150f9b04a4560ceb0
+ depends:
+ - libfreetype6 >=2.14.1
+ license: GPL-2.0-only OR FTL
+ purls: []
+ size: 7664
+ timestamp: 1757945417134
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype-2.14.1-h8af1aa0_0.conda
+ sha256: 342c07e4be3d09d04b531c889182a11a488e7e9ba4b75f642040e4681c1e9b98
+ md5: 1e61fb236ccd3d6ccaf9e91cb2d7e12d
+ depends:
+ - libfreetype6 >=2.14.1
+ license: GPL-2.0-only OR FTL
+ purls: []
+ size: 7753
+ timestamp: 1757945484817
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype-2.14.1-hce30654_0.conda
+ sha256: 9de25a86066f078822d8dd95a83048d7dc2897d5d655c0e04a8a54fca13ef1ef
+ md5: f35fb38e89e2776994131fbf961fa44b
+ depends:
+ - libfreetype6 >=2.14.1
+ license: GPL-2.0-only OR FTL
+ purls: []
+ size: 7810
+ timestamp: 1757947168537
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.1-h73754d4_0.conda
+ sha256: 4a7af818a3179fafb6c91111752954e29d3a2a950259c14a2fc7ba40a8b03652
+ md5: 8e7251989bca326a28f4a5ffbd74557a
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
+ - libpng >=1.6.50,<1.7.0a0
+ - libzlib >=1.3.1,<2.0a0
+ constrains:
+ - freetype >=2.14.1
+ license: GPL-2.0-only OR FTL
+ purls: []
+ size: 386739
+ timestamp: 1757945416744
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libfreetype6-2.14.1-hdae7a39_0.conda
+ sha256: cedc83d9733363aca353872c3bfed2e188aa7caf57b57842ba0c6d2765652b7c
+ md5: 9c2f56b6e011c6d8010ff43b796aab2f
+ depends:
+ - libgcc >=14
+ - libpng >=1.6.50,<1.7.0a0
+ - libzlib >=1.3.1,<2.0a0
+ constrains:
+ - freetype >=2.14.1
+ license: GPL-2.0-only OR FTL
+ purls: []
+ size: 423210
+ timestamp: 1757945484108
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libfreetype6-2.14.1-h6da58f4_0.conda
+ sha256: cc4aec4c490123c0f248c1acd1aeab592afb6a44b1536734e20937cda748f7cd
+ md5: 6d4ede03e2a8e20eb51f7f681d2a2550
+ depends:
+ - __osx >=11.0
+ - libpng >=1.6.50,<1.7.0a0
+ - libzlib >=1.3.1,<2.0a0
+ constrains:
+ - freetype >=2.14.1
+ license: GPL-2.0-only OR FTL
+ purls: []
+ size: 346703
+ timestamp: 1757947166116
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.1.0-h767d61c_5.conda
+ sha256: 0caed73aac3966bfbf5710e06c728a24c6c138605121a3dacb2e03440e8baa6a
+ md5: 264fbfba7fb20acf3b29cde153e345ce
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - _openmp_mutex >=4.5
+ constrains:
+ - libgomp 15.1.0 h767d61c_5
+ - libgcc-ng ==15.1.0=*_5
+ license: GPL-3.0-only WITH GCC-exception-3.1
+ license_family: GPL
+ purls: []
+ size: 824191
+ timestamp: 1757042543820
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-15.2.0-he277a41_7.conda
+ sha256: 616f5960930ad45b48c57f49c3adddefd9423674b331887ef0e69437798c214b
+ md5: afa05d91f8d57dd30985827a09c21464
+ depends:
+ - _openmp_mutex >=4.5
+ constrains:
+ - libgomp 15.2.0 he277a41_7
+ - libgcc-ng ==15.2.0=*_7
+ license: GPL-3.0-only WITH GCC-exception-3.1
+ license_family: GPL
+ purls: []
+ size: 510719
+ timestamp: 1759967448307
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.1.0-h69a702a_5.conda
+ sha256: f54bb9c3be12b24be327f4c1afccc2969712e0b091cdfbd1d763fb3e61cda03f
+ md5: 069afdf8ea72504e48d23ae1171d951c
+ depends:
+ - libgcc 15.1.0 h767d61c_5
+ license: GPL-3.0-only WITH GCC-exception-3.1
+ license_family: GPL
+ purls: []
+ size: 29187
+ timestamp: 1757042549554
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgcc-ng-15.2.0-he9431aa_7.conda
+ sha256: 7d98979b2b5698330007b0146b8b4b95b3790378de12129ce13c9fc88c1ef45a
+ md5: a5ce1f0a32f02c75c11580c5b2f9258a
+ depends:
+ - libgcc 15.2.0 he277a41_7
+ license: GPL-3.0-only WITH GCC-exception-3.1
+ license_family: GPL
+ purls: []
+ size: 29261
+ timestamp: 1759967452303
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libgd-2.3.3-h6f5c62b_11.conda
+ sha256: 19e5be91445db119152217e8e8eec4fd0499d854acc7d8062044fb55a70971cd
+ md5: 68fc66282364981589ef36868b1a7c78
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - fontconfig >=2.15.0,<3.0a0
+ - fonts-conda-ecosystem
+ - freetype >=2.12.1,<3.0a0
+ - icu >=75.1,<76.0a0
+ - libexpat >=2.6.4,<3.0a0
+ - libgcc >=13
+ - libjpeg-turbo >=3.0.0,<4.0a0
+ - libpng >=1.6.45,<1.7.0a0
+ - libtiff >=4.7.0,<4.8.0a0
+ - libwebp-base >=1.5.0,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ license: GD
+ license_family: BSD
+ purls: []
+ size: 177082
+ timestamp: 1737548051015
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgd-2.3.3-hc8d7b1d_11.conda
+ sha256: 7e199bb390f985b34aee38cdb1f0d166abc09ed44bd703a1b91a3c6cd9912d45
+ md5: d256b0311b7a207a2c6b68d2b399f707
+ depends:
+ - fontconfig >=2.15.0,<3.0a0
+ - fonts-conda-ecosystem
+ - freetype >=2.12.1,<3.0a0
+ - icu >=75.1,<76.0a0
+ - libexpat >=2.6.4,<3.0a0
+ - libgcc >=13
+ - libjpeg-turbo >=3.0.0,<4.0a0
+ - libpng >=1.6.45,<1.7.0a0
+ - libtiff >=4.7.0,<4.8.0a0
+ - libwebp-base >=1.5.0,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ license: GD
+ license_family: BSD
+ purls: []
+ size: 191033
+ timestamp: 1737548098172
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgd-2.3.3-hb2c3a21_11.conda
+ sha256: be038eb8dfe296509aee2df21184c72cb76285b0340448525664bc396aa6146d
+ md5: 4581aa3cfcd1a90967ed02d4a9f3db4b
+ depends:
+ - __osx >=11.0
+ - fontconfig >=2.15.0,<3.0a0
+ - fonts-conda-ecosystem
+ - freetype >=2.12.1,<3.0a0
+ - icu >=75.1,<76.0a0
+ - libexpat >=2.6.4,<3.0a0
+ - libiconv >=1.17,<2.0a0
+ - libjpeg-turbo >=3.0.0,<4.0a0
+ - libpng >=1.6.45,<1.7.0a0
+ - libtiff >=4.7.0,<4.8.0a0
+ - libwebp-base >=1.5.0,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ license: GD
+ license_family: BSD
+ purls: []
+ size: 156868
+ timestamp: 1737548290283
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgl-1.7.0-hd24410f_2.conda
+ sha256: 3e954380f16255d1c8ae5da3bd3044d3576a0e1ac2e3c3ff2fe8f2f1ad2e467a
+ md5: 0d00176464ebb25af83d40736a2cd3bb
+ depends:
+ - libglvnd 1.7.0 hd24410f_2
+ - libglx 1.7.0 hd24410f_2
+ license: LicenseRef-libglvnd
+ purls: []
+ size: 145442
+ timestamp: 1731331005019
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgl-devel-1.7.0-hd24410f_2.conda
+ sha256: ec5c3125b38295bad8acc80f793b8ee217ccb194338d73858be278db50ea82f1
+ md5: 5d8323dff6a93596fb6f985cf6e8521a
+ depends:
+ - libgl 1.7.0 hd24410f_2
+ - libglx-devel 1.7.0 hd24410f_2
+ license: LicenseRef-libglvnd
+ purls: []
+ size: 113925
+ timestamp: 1731331014056
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.86.0-h1fed272_0.conda
+ sha256: 33336bd55981be938f4823db74291e1323454491623de0be61ecbe6cf3a4619c
+ md5: b8e4c93f4ab70c3b6f6499299627dbdc
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libffi >=3.4.6,<3.5.0a0
+ - libgcc >=14
+ - libiconv >=1.18,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - pcre2 >=10.46,<10.47.0a0
+ constrains:
+ - glib 2.86.0 *_0
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 3978602
+ timestamp: 1757403291664
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglib-2.86.1-he84ff74_1.conda
+ sha256: 5212c30d9e14a9480c7d25bf93ccca4db23d3794430c9be90e13124d9a8b1687
+ md5: f0fc1b2fa2e68b1309852e5c3c8e011d
+ depends:
+ - libffi >=3.5.2,<3.6.0a0
+ - libgcc >=14
+ - libiconv >=1.18,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - pcre2 >=10.46,<10.47.0a0
+ constrains:
+ - glib 2.86.1 *_1
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 4040523
+ timestamp: 1761874121589
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.86.1-he69a767_1.conda
+ sha256: 253ac4eca90006b19571f8c4766e8ebdad0f01f44de1bfa0472d3df9be9c8ac8
+ md5: acff031bb5b97602d2b7ef913a8ea076
+ depends:
+ - __osx >=11.0
+ - libffi >=3.5.2,<3.6.0a0
+ - libiconv >=1.18,<2.0a0
+ - libintl >=0.25.1,<1.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - pcre2 >=10.46,<10.47.0a0
+ constrains:
+ - glib 2.86.1 *_1
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 3677659
+ timestamp: 1761875607047
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglvnd-1.7.0-hd24410f_2.conda
+ sha256: 57ec3898a923d4bcc064669e90e8abfc4d1d945a13639470ba5f3748bd3090da
+ md5: 9e115653741810778c9a915a2f8439e7
+ license: LicenseRef-libglvnd
+ purls: []
+ size: 152135
+ timestamp: 1731330986070
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglx-1.7.0-hd24410f_2.conda
+ sha256: 6591af640cb05a399fab47646025f8b1e1a06a0d4bbb4d2e320d6629b47a1c61
+ md5: 1d4269e233636148696a67e2d30dad2a
+ depends:
+ - libglvnd 1.7.0 hd24410f_2
+ - xorg-libx11 >=1.8.9,<2.0a0
+ license: LicenseRef-libglvnd
+ purls: []
+ size: 77736
+ timestamp: 1731330998960
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libglx-devel-1.7.0-hd24410f_2.conda
+ sha256: 4bc28ecc38f30ca1ac66a8fb6c5703f4d888381ec46d3938b7c3383210061ec5
+ md5: 1f9ddbb175a63401662d1c6222cef6ff
+ depends:
+ - libglx 1.7.0 hd24410f_2
+ - xorg-libx11 >=1.8.9,<2.0a0
+ - xorg-xorgproto
+ license: LicenseRef-libglvnd
+ purls: []
+ size: 26362
+ timestamp: 1731331008489
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.1.0-h767d61c_5.conda
+ sha256: 125051d51a8c04694d0830f6343af78b556dd88cc249dfec5a97703ebfb1832d
+ md5: dcd5ff1940cd38f6df777cac86819d60
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ license: GPL-3.0-only WITH GCC-exception-3.1
+ license_family: GPL
+ purls: []
+ size: 447215
+ timestamp: 1757042483384
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libgomp-15.2.0-he277a41_7.conda
+ sha256: 0a024f1e4796f5d90fb8e8555691dad1b3bdfc6ac3c2cd14d876e30f805fcac7
+ md5: 34cef4753287c36441f907d5fdd78d42
+ license: GPL-3.0-only WITH GCC-exception-3.1
+ license_family: GPL
+ purls: []
+ size: 450308
+ timestamp: 1759967379407
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda
+ sha256: c467851a7312765447155e071752d7bf9bf44d610a5687e32706f480aad2833f
+ md5: 915f5995e94f60e9a4826e0b0920ee88
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
+ license: LGPL-2.1-only
+ purls: []
+ size: 790176
+ timestamp: 1754908768807
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libiconv-1.18-h90929bb_2.conda
+ sha256: 1473451cd282b48d24515795a595801c9b65b567fe399d7e12d50b2d6cdb04d9
+ md5: 5a86bf847b9b926f3a4f203339748d78
+ depends:
+ - libgcc >=14
+ license: LGPL-2.1-only
+ purls: []
+ size: 791226
+ timestamp: 1754910975665
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda
+ sha256: de0336e800b2af9a40bdd694b03870ac4a848161b35c8a2325704f123f185f03
+ md5: 4d5a7445f0b25b6a3ddbb56e790f5251
+ depends:
+ - __osx >=11.0
+ license: LGPL-2.1-only
+ purls: []
+ size: 750379
+ timestamp: 1754909073836
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda
+ sha256: 99d2cebcd8f84961b86784451b010f5f0a795ed1c08f1e7c76fbb3c22abf021a
+ md5: 5103f6a6b210a3912faf8d7db516918c
+ depends:
+ - __osx >=11.0
+ - libiconv >=1.18,<2.0a0
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 90957
+ timestamp: 1751558394144
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.0-hb9d3cd8_0.conda
+ sha256: 98b399287e27768bf79d48faba8a99a2289748c65cd342ca21033fab1860d4a4
+ md5: 9fa334557db9f63da6c9285fd2a48638
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ constrains:
+ - jpeg <0.0.0a
+ license: IJG AND BSD-3-Clause AND Zlib
+ purls: []
+ size: 628947
+ timestamp: 1745268527144
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libjpeg-turbo-3.1.2-he30d5cf_0.conda
+ sha256: 84064c7c53a64291a585d7215fe95ec42df74203a5bf7615d33d49a3b0f08bb6
+ md5: 5109d7f837a3dfdf5c60f60e311b041f
+ depends:
+ - libgcc >=14
+ constrains:
+ - jpeg <0.0.0a
+ license: IJG AND BSD-3-Clause AND Zlib
+ purls: []
+ size: 691818
+ timestamp: 1762094728337
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libjpeg-turbo-3.1.0-h5505292_0.conda
+ sha256: 78df2574fa6aa5b6f5fc367c03192f8ddf8e27dc23641468d54e031ff560b9d4
+ md5: 01caa4fbcaf0e6b08b3aef1151e91745
+ depends:
+ - __osx >=11.0
+ constrains:
+ - jpeg <0.0.0a
+ license: IJG AND BSD-3-Clause AND Zlib
+ purls: []
+ size: 553624
+ timestamp: 1745268405713
+- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda
+ sha256: f2591c0069447bbe28d4d696b7fcb0c5bd0b4ac582769b89addbcf26fb3430d8
+ md5: 1a580f7796c7bf6393fddb8bbbde58dc
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ constrains:
+ - xz 5.8.1.*
+ license: 0BSD
+ purls: []
+ size: 112894
+ timestamp: 1749230047870
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/liblzma-5.8.1-h86ecc28_2.conda
+ sha256: 498ea4b29155df69d7f20990a7028d75d91dbea24d04b2eb8a3d6ef328806849
+ md5: 7d362346a479256857ab338588190da0
+ depends:
+ - libgcc >=13
+ constrains:
+ - xz 5.8.1.*
+ license: 0BSD
+ purls: []
+ size: 125103
+ timestamp: 1749232230009
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.1-h39f12f2_2.conda
+ sha256: 0cb92a9e026e7bd4842f410a5c5c665c89b2eb97794ffddba519a626b8ce7285
+ md5: d6df911d4564d77c4374b02552cb17d1
+ depends:
+ - __osx >=11.0
+ constrains:
+ - xz 5.8.1.*
+ license: 0BSD
+ purls: []
+ size: 92286
+ timestamp: 1749230283517
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb9d3cd8_0.conda
+ sha256: 3aa92d4074d4063f2a162cd8ecb45dccac93e543e565c01a787e16a43501f7ee
+ md5: c7e925f37e3b40d893459e625f6a53f1
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ license: BSD-2-Clause
+ license_family: BSD
+ purls: []
+ size: 91183
+ timestamp: 1748393666725
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libmpdec-4.0.0-h86ecc28_0.conda
+ sha256: ef8697f934c80b347bf9d7ed45650928079e303bad01bd064995b0e3166d6e7a
+ md5: 78cfed3f76d6f3f279736789d319af76
+ depends:
+ - libgcc >=13
+ license: BSD-2-Clause
+ license_family: BSD
+ purls: []
+ size: 114064
+ timestamp: 1748393729243
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h5505292_0.conda
+ sha256: 0a1875fc1642324ebd6c4ac864604f3f18f57fbcf558a8264f6ced028a3c75b2
+ md5: 85ccccb47823dd9f7a99d2c7f530342f
+ depends:
+ - __osx >=11.0
+ license: BSD-2-Clause
+ license_family: BSD
+ purls: []
+ size: 71829
+ timestamp: 1748393749336
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libpciaccess-0.18-h86ecc28_0.conda
+ sha256: 7641dfdfe9bda7069ae94379e9924892f0b6604c1a016a3f76b230433bb280f2
+ md5: 5044e160c5306968d956c2a0a2a440d6
+ depends:
+ - libgcc >=13
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 29512
+ timestamp: 1749901899881
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.50-h421ea60_1.conda
+ sha256: e75a2723000ce3a4b9fd9b9b9ce77553556c93e475a4657db6ed01abc02ea347
+ md5: 7af8e91b0deb5f8e25d1a595dea79614
+ depends:
+ - libgcc >=14
+ - __glibc >=2.17,<3.0.a0
+ - libzlib >=1.3.1,<2.0a0
+ license: zlib-acknowledgement
+ purls: []
+ size: 317390
+ timestamp: 1753879899951
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libpng-1.6.50-h1abf092_1.conda
+ sha256: e1effd7335ec101bb124f41a5f79fabb5e7b858eafe0f2db4401fb90c51505a7
+ md5: ed42935ac048d73109163d653d9445a0
+ depends:
+ - libgcc >=14
+ - libzlib >=1.3.1,<2.0a0
+ license: zlib-acknowledgement
+ purls: []
+ size: 339168
+ timestamp: 1753879915462
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpng-1.6.50-h280e0eb_1.conda
+ sha256: a2e0240fb0c79668047b528976872307ea80cb330baf8bf6624ac2c6443449df
+ md5: 4d0f5ce02033286551a32208a5519884
+ depends:
+ - __osx >=11.0
+ - libzlib >=1.3.1,<2.0a0
+ license: zlib-acknowledgement
+ purls: []
+ size: 287056
+ timestamp: 1753879907258
+- conda: https://conda.anaconda.org/conda-forge/linux-64/librsvg-2.58.4-he92a37e_3.conda
+ sha256: a45ef03e6e700cc6ac6c375e27904531cf8ade27eb3857e080537ff283fb0507
+ md5: d27665b20bc4d074b86e628b3ba5ab8b
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - cairo >=1.18.4,<2.0a0
+ - freetype >=2.13.3,<3.0a0
+ - gdk-pixbuf >=2.42.12,<3.0a0
+ - harfbuzz >=11.0.0,<12.0a0
+ - libgcc >=13
+ - libglib >=2.84.0,<3.0a0
+ - libpng >=1.6.47,<1.7.0a0
+ - libxml2 >=2.13.7,<2.14.0a0
+ - pango >=1.56.3,<2.0a0
+ constrains:
+ - __glibc >=2.17
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 6543651
+ timestamp: 1743368725313
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/librsvg-2.60.0-h8171147_0.conda
+ sha256: b6cb38e95a447a04e624b6070981899e18c03f71915476fe024dadf384f48f15
+ md5: 7e4a8318e73ba685615f90bff926bfe4
+ depends:
+ - cairo >=1.18.4,<2.0a0
+ - gdk-pixbuf >=2.44.3,<3.0a0
+ - libgcc >=14
+ - libglib >=2.86.0,<3.0a0
+ - libxml2-16 >=2.14.6
+ - pango >=1.56.4,<2.0a0
+ constrains:
+ - __glibc >=2.17
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 2995492
+ timestamp: 1759335330016
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/librsvg-2.60.0-h5c55ec3_0.conda
+ sha256: ca5a2de5d3f68e8d6443ea1bf193c1596a278e6f86018017c0ccd4928eaf8971
+ md5: 05ad1d6b6fb3b384f7a07128025725cb
+ depends:
+ - __osx >=11.0
+ - cairo >=1.18.4,<2.0a0
+ - gdk-pixbuf >=2.44.3,<3.0a0
+ - libglib >=2.86.0,<3.0a0
+ - libxml2-16 >=2.14.6
+ - pango >=1.56.4,<2.0a0
+ constrains:
+ - __osx >=11.0
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 2344343
+ timestamp: 1759328503184
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.50.4-h0c1763c_0.conda
+ sha256: 6d9c32fc369af5a84875725f7ddfbfc2ace795c28f246dc70055a79f9b2003da
+ md5: 0b367fad34931cb79e0d6b7e5c06bb1c
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
+ - libzlib >=1.3.1,<2.0a0
+ license: blessing
+ purls: []
+ size: 932581
+ timestamp: 1753948484112
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libsqlite-3.51.0-h022381a_0.conda
+ sha256: f66a40b6e07a6f8ce6ccbd38d079b7394217d8f8ae0a05efa644aa0a40140671
+ md5: 8920ce2226463a3815e2183c8b5008b8
+ depends:
+ - libgcc >=14
+ - libzlib >=1.3.1,<2.0a0
+ license: blessing
+ purls: []
+ size: 938476
+ timestamp: 1762299829629
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.50.4-h4237e3c_0.conda
+ sha256: 802ebe62e6bc59fc26b26276b793e0542cfff2d03c086440aeaf72fb8bbcec44
+ md5: 1dcb0468f5146e38fae99aef9656034b
+ depends:
+ - __osx >=11.0
+ - icu >=75.1,<76.0a0
+ - libzlib >=1.3.1,<2.0a0
+ license: blessing
+ purls: []
+ size: 902645
+ timestamp: 1753948599139
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.1.0-h8f9b012_5.conda
+ sha256: 0f5f61cab229b6043541c13538d75ce11bd96fb2db76f94ecf81997b1fde6408
+ md5: 4e02a49aaa9d5190cb630fa43528fbe6
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc 15.1.0 h767d61c_5
+ license: GPL-3.0-only WITH GCC-exception-3.1
+ license_family: GPL
+ purls: []
+ size: 3896432
+ timestamp: 1757042571458
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-15.2.0-h3f4de04_7.conda
+ sha256: 4c6d1a2ae58044112233a57103bbf06000bd4c2aad44a0fd3b464b05fa8df514
+ md5: 6a2f0ee17851251a85fbebafbe707d2d
+ depends:
+ - libgcc 15.2.0 he277a41_7
+ constrains:
+ - libstdcxx-ng ==15.2.0=*_7
+ license: GPL-3.0-only WITH GCC-exception-3.1
+ license_family: GPL
+ purls: []
+ size: 3831785
+ timestamp: 1759967470295
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.1.0-h4852527_5.conda
+ sha256: 7b8cabbf0ab4fe3581ca28fe8ca319f964078578a51dd2ca3f703c1d21ba23ff
+ md5: 8bba50c7f4679f08c861b597ad2bda6b
+ depends:
+ - libstdcxx 15.1.0 h8f9b012_5
+ license: GPL-3.0-only WITH GCC-exception-3.1
+ license_family: GPL
+ purls: []
+ size: 29233
+ timestamp: 1757042603319
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libstdcxx-ng-15.2.0-hf1166c9_7.conda
+ sha256: 26fc1bdb39042f27302b363785fea6f6b9607f9c2f5eb949c6ae0bdbb8599574
+ md5: 9e5deec886ad32f3c6791b3b75c78681
+ depends:
+ - libstdcxx 15.2.0 h3f4de04_7
+ license: GPL-3.0-only WITH GCC-exception-3.1
+ license_family: GPL
+ purls: []
+ size: 29341
+ timestamp: 1759967498023
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.0-h8261f1e_6.conda
+ sha256: c62694cd117548d810d2803da6d9063f78b1ffbf7367432c5388ce89474e9ebe
+ md5: b6093922931b535a7ba566b6f384fbe6
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - lerc >=4.0.0,<5.0a0
+ - libdeflate >=1.24,<1.25.0a0
+ - libgcc >=14
+ - libjpeg-turbo >=3.1.0,<4.0a0
+ - liblzma >=5.8.1,<6.0a0
+ - libstdcxx >=14
+ - libwebp-base >=1.6.0,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - zstd >=1.5.7,<1.6.0a0
+ license: HPND
+ purls: []
+ size: 433078
+ timestamp: 1755011934951
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libtiff-4.7.1-hdb009f0_1.conda
+ sha256: 7ff79470db39e803e21b8185bc8f19c460666d5557b1378d1b1e857d929c6b39
+ md5: 8c6fd84f9c87ac00636007c6131e457d
+ depends:
+ - lerc >=4.0.0,<5.0a0
+ - libdeflate >=1.25,<1.26.0a0
+ - libgcc >=14
+ - libjpeg-turbo >=3.1.0,<4.0a0
+ - liblzma >=5.8.1,<6.0a0
+ - libstdcxx >=14
+ - libwebp-base >=1.6.0,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - zstd >=1.5.7,<1.6.0a0
+ license: HPND
+ purls: []
+ size: 488407
+ timestamp: 1762022048105
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libtiff-4.7.1-h7dc4979_0.conda
+ sha256: 6bc1b601f0d3ee853acd23884a007ac0a0290f3609dabb05a47fc5a0295e2b53
+ md5: 2bb9e04e2da869125e2dc334d665f00d
+ depends:
+ - __osx >=11.0
+ - lerc >=4.0.0,<5.0a0
+ - libcxx >=19
+ - libdeflate >=1.24,<1.25.0a0
+ - libjpeg-turbo >=3.1.0,<4.0a0
+ - liblzma >=5.8.1,<6.0a0
+ - libwebp-base >=1.6.0,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - zstd >=1.5.7,<1.6.0a0
+ license: HPND
+ purls: []
+ size: 373640
+ timestamp: 1758278641520
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.1-he9a06e4_0.conda
+ sha256: 776e28735cee84b97e4d05dd5d67b95221a3e2c09b8b13e3d6dbe6494337d527
+ md5: af930c65e9a79a3423d6d36e265cef65
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 37087
+ timestamp: 1757334557450
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libuuid-2.41.2-h3e4203c_0.conda
+ sha256: 7aed28ac04e0298bf8f7ad44a23d6f8ee000aa0445807344b16fceedc67cce0f
+ md5: 3a68e44fdf2a2811672520fdd62996bd
+ depends:
+ - libgcc >=14
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 39172
+ timestamp: 1758626850999
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda
+ sha256: 3aed21ab28eddffdaf7f804f49be7a7d701e8f0e46c856d801270b470820a37b
+ md5: aea31d2e5b1091feca96fcfe945c3cf9
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
+ constrains:
+ - libwebp 1.6.0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 429011
+ timestamp: 1752159441324
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libwebp-base-1.6.0-ha2e29f5_0.conda
+ sha256: b03700a1f741554e8e5712f9b06dd67e76f5301292958cd3cb1ac8c6fdd9ed25
+ md5: 24e92d0942c799db387f5c9d7b81f1af
+ depends:
+ - libgcc >=14
+ constrains:
+ - libwebp 1.6.0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 359496
+ timestamp: 1752160685488
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libwebp-base-1.6.0-h07db88b_0.conda
+ sha256: a4de3f371bb7ada325e1f27a4ef7bcc81b2b6a330e46fac9c2f78ac0755ea3dd
+ md5: e5e7d467f80da752be17796b87fe6385
+ depends:
+ - __osx >=11.0
+ constrains:
+ - libwebp 1.6.0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 294974
+ timestamp: 1752159906788
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda
+ sha256: 666c0c431b23c6cec6e492840b176dde533d48b7e6fb8883f5071223433776aa
+ md5: 92ed62436b625154323d40d5f2f11dd7
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - pthread-stubs
+ - xorg-libxau >=1.0.11,<2.0a0
+ - xorg-libxdmcp
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 395888
+ timestamp: 1727278577118
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxcb-1.17.0-h262b8f6_0.conda
+ sha256: 461cab3d5650ac6db73a367de5c8eca50363966e862dcf60181d693236b1ae7b
+ md5: cd14ee5cca2464a425b1dbfc24d90db2
+ depends:
+ - libgcc >=13
+ - pthread-stubs
+ - xorg-libxau >=1.0.11,<2.0a0
+ - xorg-libxdmcp
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 397493
+ timestamp: 1727280745441
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.11.0-he8b52b9_0.conda
+ sha256: 23f47e86cc1386e7f815fa9662ccedae151471862e971ea511c5c886aa723a54
+ md5: 74e91c36d0eef3557915c68b6c2bef96
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
+ - libstdcxx >=14
+ - libxcb >=1.17.0,<2.0a0
+ - libxml2 >=2.13.8,<2.14.0a0
+ - xkeyboard-config
+ - xorg-libxau >=1.0.12,<2.0a0
+ license: MIT/X11 Derivative
+ license_family: MIT
+ purls: []
+ size: 791328
+ timestamp: 1754703902365
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxkbcommon-1.13.0-h3c6a4c8_0.conda
+ sha256: c197e58ba06fa9ac73fcbdc20f9a78ba0164f61879d127bb2f7d0d4be346216a
+ md5: a7c78be36bf59b4ba44ad2f2f8b92b37
+ depends:
+ - libgcc >=14
+ - libstdcxx >=14
+ - libxcb >=1.17.0,<2.0a0
+ - libxml2
+ - libxml2-16 >=2.14.6
+ - xkeyboard-config
+ - xorg-libxau >=1.0.12,<2.0a0
+ license: MIT/X11 Derivative
+ license_family: MIT
+ purls: []
+ size: 862682
+ timestamp: 1762341934465
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.13.8-h04c0eec_1.conda
+ sha256: 03deb1ec6edfafc5aaeecadfc445ee436fecffcda11fcd97fde9b6632acb583f
+ md5: 10bcbd05e1c1c9d652fccb42b776a9fa
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - icu >=75.1,<76.0a0
+ - libgcc >=14
+ - libiconv >=1.18,<2.0a0
+ - liblzma >=5.8.1,<6.0a0
+ - libzlib >=1.3.1,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 698448
+ timestamp: 1754315344761
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-2.15.1-h788dabe_0.conda
+ sha256: db0a568e0853ee38b7a4db1cb4ee76e57fe7c32ccb1d5b75f6618a1041d3c6e4
+ md5: a0e7779b7625b88e37df9bd73f0638dc
+ depends:
+ - icu >=75.1,<76.0a0
+ - libgcc >=14
+ - libiconv >=1.18,<2.0a0
+ - liblzma >=5.8.1,<6.0a0
+ - libxml2-16 2.15.1 h8591a01_0
+ - libzlib >=1.3.1,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 47192
+ timestamp: 1761015739999
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libxml2-16-2.15.1-h8591a01_0.conda
+ sha256: 7a13450bce2eeba8f8fb691868b79bf0891377b707493a527bd930d64d9b98af
+ md5: e7177c6fbbf815da7b215b4cc3e70208
+ depends:
+ - icu >=75.1,<76.0a0
+ - libgcc >=14
+ - libiconv >=1.18,<2.0a0
+ - liblzma >=5.8.1,<6.0a0
+ - libzlib >=1.3.1,<2.0a0
+ constrains:
+ - libxml2 2.15.1
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 597078
+ timestamp: 1761015734476
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.1-h0ff4647_0.conda
+ sha256: ebe2dd9da94280ad43da936efa7127d329b559f510670772debc87602b49b06d
+ md5: 438c97d1e9648dd7342f86049dd44638
+ depends:
+ - __osx >=11.0
+ - icu >=75.1,<76.0a0
+ - libiconv >=1.18,<2.0a0
+ - liblzma >=5.8.1,<6.0a0
+ - libzlib >=1.3.1,<2.0a0
+ constrains:
+ - libxml2 2.15.1
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 464952
+ timestamp: 1761016087733
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda
+ sha256: d4bfe88d7cb447768e31650f06257995601f89076080e76df55e3112d4e47dc4
+ md5: edb0dca6bc32e4f4789199455a1dbeb8
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ constrains:
+ - zlib 1.3.1 *_2
+ license: Zlib
+ license_family: Other
+ purls: []
+ size: 60963
+ timestamp: 1727963148474
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/libzlib-1.3.1-h86ecc28_2.conda
+ sha256: 5a2c1eeef69342e88a98d1d95bff1603727ab1ff4ee0e421522acd8813439b84
+ md5: 08aad7cbe9f5a6b460d0976076b6ae64
+ depends:
+ - libgcc >=13
+ constrains:
+ - zlib 1.3.1 *_2
+ license: Zlib
+ license_family: Other
+ purls: []
+ size: 66657
+ timestamp: 1727963199518
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda
+ sha256: ce34669eadaba351cd54910743e6a2261b67009624dbc7daeeafdef93616711b
+ md5: 369964e85dc26bfe78f41399b366c435
+ depends:
+ - __osx >=11.0
+ constrains:
+ - zlib 1.3.1 *_2
+ license: Zlib
+ license_family: Other
+ purls: []
+ size: 46438
+ timestamp: 1727963202283
+- pypi: https://files.pythonhosted.org/packages/e5/b8/9eea6630198cb303d131d95d285a024b3b8645b1763a2916fddb44ca8760/matplotlib-3.10.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ name: matplotlib
+ version: 3.10.6
+ sha256: 84e82d9e0fd70c70bc55739defbd8055c54300750cbacf4740c9673a24d6933a
+ requires_dist:
+ - contourpy>=1.0.1
+ - cycler>=0.10
+ - fonttools>=4.22.0
+ - kiwisolver>=1.3.1
+ - numpy>=1.23
+ - packaging>=20.0
+ - pillow>=8
+ - pyparsing>=2.3.1
+ - python-dateutil>=2.7
+ - meson-python>=0.13.1,<0.17.0 ; extra == 'dev'
+ - pybind11>=2.13.2,!=2.13.3 ; extra == 'dev'
+ - setuptools-scm>=7 ; extra == 'dev'
+ - setuptools>=64 ; extra == 'dev'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/bc/d0/b3d3338d467d3fc937f0bb7f256711395cae6f78e22cef0656159950adf0/matplotlib-3.10.7-cp313-cp313-macosx_11_0_arm64.whl
+ name: matplotlib
+ version: 3.10.7
+ sha256: 37a1fea41153dd6ee061d21ab69c9cf2cf543160b1b85d89cd3d2e2a7902ca4c
+ requires_dist:
+ - contourpy>=1.0.1
+ - cycler>=0.10
+ - fonttools>=4.22.0
+ - kiwisolver>=1.3.1
+ - numpy>=1.23
+ - packaging>=20.0
+ - pillow>=8
+ - pyparsing>=3
+ - python-dateutil>=2.7
+ - meson-python>=0.13.1,<0.17.0 ; extra == 'dev'
+ - pybind11>=2.13.2,!=2.13.3 ; extra == 'dev'
+ - setuptools-scm>=7 ; extra == 'dev'
+ - setuptools>=64 ; extra == 'dev'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/d0/7f/ccdca06f4c2e6c7989270ed7829b8679466682f4cfc0f8c9986241c023b6/matplotlib-3.10.7-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
+ name: matplotlib
+ version: 3.10.7
+ sha256: 22df30ffaa89f6643206cf13877191c63a50e8f800b038bc39bee9d2d4957632
+ requires_dist:
+ - contourpy>=1.0.1
+ - cycler>=0.10
+ - fonttools>=4.22.0
+ - kiwisolver>=1.3.1
+ - numpy>=1.23
+ - packaging>=20.0
+ - pillow>=8
+ - pyparsing>=3
+ - python-dateutil>=2.7
+ - meson-python>=0.13.1,<0.17.0 ; extra == 'dev'
+ - pybind11>=2.13.2,!=2.13.3 ; extra == 'dev'
+ - setuptools-scm>=7 ; extra == 'dev'
+ - setuptools>=64 ; extra == 'dev'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl
+ name: matplotlib-inline
+ version: 0.1.7
+ sha256: df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca
+ requires_dist:
+ - traitlets
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl
+ name: matplotlib-inline
+ version: 0.2.1
+ sha256: d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76
+ requires_dist:
+ - traitlets
+ - flake8 ; extra == 'test'
+ - nbdime ; extra == 'test'
+ - nbval ; extra == 'test'
+ - notebook ; extra == 'test'
+ - pytest ; extra == 'test'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/89/a3/00260f8df72b51afa1f182dd609533c77fa2407918c4c2813d87b4a56725/minio-7.2.16-py3-none-any.whl
+ name: minio
+ version: 7.2.16
+ sha256: 9288ab988ca57c181eb59a4c96187b293131418e28c164392186c2b89026b223
+ requires_dist:
+ - argon2-cffi
+ - certifi
+ - pycryptodome
+ - typing-extensions
+ - urllib3
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/7d/ae/f32695da4f93de50dd7075100dab8cf689a9d96270f58ce6f940fd044a3e/minio-7.2.18-py3-none-any.whl
+ name: minio
+ version: 7.2.18
+ sha256: f23a6edbff8d0bc4b5c1a61b2628a01c5a3342aefc613ff9c276012e6321108f
+ requires_dist:
+ - argon2-cffi
+ - certifi
+ - pycryptodome
+ - typing-extensions
+ - urllib3
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl
+ name: multidict
+ version: 6.7.1
+ sha256: 935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445
+ requires_dist:
+ - typing-extensions>=4.1.0 ; python_full_version < '3.11'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ name: multidict
+ version: 6.7.1
+ sha256: 9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429
+ requires_dist:
+ - typing-extensions>=4.1.0 ; python_full_version < '3.11'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ name: multidict
+ version: 6.7.1
+ sha256: e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23
+ requires_dist:
+ - typing-extensions>=4.1.0 ; python_full_version < '3.11'
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda
+ sha256: 3fde293232fa3fca98635e1167de6b7c7fda83caf24b9d6c91ec9eefb4f4d586
+ md5: 47e340acb35de30501a76c7c799c41d7
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ license: X11 AND BSD-3-Clause
+ purls: []
+ size: 891641
+ timestamp: 1738195959188
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/ncurses-6.5-ha32ae93_3.conda
+ sha256: 91cfb655a68b0353b2833521dc919188db3d8a7f4c64bea2c6a7557b24747468
+ md5: 182afabe009dc78d8b73100255ee6868
+ depends:
+ - libgcc >=13
+ license: X11 AND BSD-3-Clause
+ purls: []
+ size: 926034
+ timestamp: 1738196018799
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda
+ sha256: 2827ada40e8d9ca69a153a45f7fd14f32b2ead7045d3bbb5d10964898fe65733
+ md5: 068d497125e4bf8a66bf707254fff5ae
+ depends:
+ - __osx >=11.0
+ license: X11 AND BSD-3-Clause
+ purls: []
+ size: 797030
+ timestamp: 1738196177597
+- pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
+ name: networkx
+ version: '3.5'
+ sha256: 0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec
+ requires_dist:
+ - numpy>=1.25 ; extra == 'default'
+ - scipy>=1.11.2 ; extra == 'default'
+ - matplotlib>=3.8 ; extra == 'default'
+ - pandas>=2.0 ; extra == 'default'
+ - pre-commit>=4.1 ; extra == 'developer'
+ - mypy>=1.15 ; extra == 'developer'
+ - sphinx>=8.0 ; extra == 'doc'
+ - pydata-sphinx-theme>=0.16 ; extra == 'doc'
+ - sphinx-gallery>=0.18 ; extra == 'doc'
+ - numpydoc>=1.8.0 ; extra == 'doc'
+ - pillow>=10 ; extra == 'doc'
+ - texext>=0.6.7 ; extra == 'doc'
+ - myst-nb>=1.1 ; extra == 'doc'
+ - intersphinx-registry ; extra == 'doc'
+ - osmnx>=2.0.0 ; extra == 'example'
+ - momepy>=0.7.2 ; extra == 'example'
+ - contextily>=1.6 ; extra == 'example'
+ - seaborn>=0.13 ; extra == 'example'
+ - cairocffi>=1.7 ; extra == 'example'
+ - igraph>=0.11 ; extra == 'example'
+ - scikit-learn>=1.5 ; extra == 'example'
+ - lxml>=4.6 ; extra == 'extra'
+ - pygraphviz>=1.14 ; extra == 'extra'
+ - pydot>=3.0.1 ; extra == 'extra'
+ - sympy>=1.10 ; extra == 'extra'
+ - pytest>=7.2 ; extra == 'test'
+ - pytest-cov>=4.0 ; extra == 'test'
+ - pytest-xdist>=3.0 ; extra == 'test'
+ - pytest-mpl ; extra == 'test-extras'
+ - pytest-randomly ; extra == 'test-extras'
+ requires_python: '>=3.11'
+- pypi: https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl
+ name: nodeenv
+ version: 1.9.1
+ sha256: ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
+ requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*'
+- pypi: https://files.pythonhosted.org/packages/9a/a5/bf3db6e66c4b160d6ea10b534c381a1955dfab34cb1017ea93aa33c70ed3/numpy-2.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ name: numpy
+ version: 2.3.3
+ sha256: 5b83648633d46f77039c29078751f80da65aa64d5622a3cd62aaef9d835b6c93
+ requires_python: '>=3.11'
+- pypi: https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl
+ name: numpy
+ version: 2.3.4
+ sha256: a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3
+ requires_python: '>=3.11'
+- pypi: https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
+ name: numpy
+ version: 2.3.4
+ sha256: 4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7
+ requires_python: '>=3.11'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.5.2-h26f9b46_0.conda
+ sha256: c9f54d4e8212f313be7b02eb962d0cb13a8dae015683a403d3accd4add3e520e
+ md5: ffffb341206dd0dab0c36053c048d621
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - ca-certificates
+ - libgcc >=14
+ license: Apache-2.0
+ license_family: Apache
+ purls: []
+ size: 3128847
+ timestamp: 1754465526100
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/openssl-3.5.4-h8e36d6e_0.conda
+ sha256: a24b318733c98903e2689adc7ef73448e27cbb10806852032c023f0ea4446fc5
+ md5: 9303e8887afe539f78517951ce25cd13
+ depends:
+ - ca-certificates
+ - libgcc >=14
+ license: Apache-2.0
+ license_family: Apache
+ purls: []
+ size: 3644584
+ timestamp: 1759326000128
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.5.4-h5503f6c_0.conda
+ sha256: f0512629f9589392c2fb9733d11e753d0eab8fc7602f96e4d7f3bd95c783eb07
+ md5: 71118318f37f717eefe55841adb172fd
+ depends:
+ - __osx >=11.0
+ - ca-certificates
+ license: Apache-2.0
+ license_family: Apache
+ purls: []
+ size: 3067808
+ timestamp: 1759324763146
+- pypi: https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl
+ name: orderly-set
+ version: 5.5.0
+ sha256: 46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7
+ requires_dist:
+ - coverage~=7.6.0 ; extra == 'coverage'
+ - bump2version~=1.0.0 ; extra == 'dev'
+ - ipdb~=0.13.0 ; extra == 'dev'
+ - orjson ; extra == 'optimize'
+ - flake8~=7.1.0 ; extra == 'static'
+ - flake8-pyproject~=1.2.3 ; extra == 'static'
+ - pytest~=8.3.0 ; extra == 'test'
+ - pytest-benchmark~=5.1.0 ; extra == 'test'
+ - pytest-cov~=6.0.0 ; extra == 'test'
+ - python-dotenv~=1.0.0 ; extra == 'test'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl
+ name: packaging
+ version: '25.0'
+ sha256: 29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/8f/52/0634adaace9be2d8cac9ef78f05c47f3a675882e068438b9d7ec7ef0c13f/pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ name: pandas
+ version: 2.3.2
+ sha256: 4ac8c320bded4718b298281339c1a50fb00a6ba78cb2a63521c39bec95b0209b
+ requires_dist:
+ - numpy>=1.22.4 ; python_full_version < '3.11'
+ - numpy>=1.23.2 ; python_full_version == '3.11.*'
+ - numpy>=1.26.0 ; python_full_version >= '3.12'
+ - python-dateutil>=2.8.2
+ - pytz>=2020.1
+ - tzdata>=2022.7
+ - hypothesis>=6.46.1 ; extra == 'test'
+ - pytest>=7.3.2 ; extra == 'test'
+ - pytest-xdist>=2.2.0 ; extra == 'test'
+ - pyarrow>=10.0.1 ; extra == 'pyarrow'
+ - bottleneck>=1.3.6 ; extra == 'performance'
+ - numba>=0.56.4 ; extra == 'performance'
+ - numexpr>=2.8.4 ; extra == 'performance'
+ - scipy>=1.10.0 ; extra == 'computation'
+ - xarray>=2022.12.0 ; extra == 'computation'
+ - fsspec>=2022.11.0 ; extra == 'fss'
+ - s3fs>=2022.11.0 ; extra == 'aws'
+ - gcsfs>=2022.11.0 ; extra == 'gcp'
+ - pandas-gbq>=0.19.0 ; extra == 'gcp'
+ - odfpy>=1.4.1 ; extra == 'excel'
+ - openpyxl>=3.1.0 ; extra == 'excel'
+ - python-calamine>=0.1.7 ; extra == 'excel'
+ - pyxlsb>=1.0.10 ; extra == 'excel'
+ - xlrd>=2.0.1 ; extra == 'excel'
+ - xlsxwriter>=3.0.5 ; extra == 'excel'
+ - pyarrow>=10.0.1 ; extra == 'parquet'
+ - pyarrow>=10.0.1 ; extra == 'feather'
+ - tables>=3.8.0 ; extra == 'hdf5'
+ - pyreadstat>=1.2.0 ; extra == 'spss'
+ - sqlalchemy>=2.0.0 ; extra == 'postgresql'
+ - psycopg2>=2.9.6 ; extra == 'postgresql'
+ - adbc-driver-postgresql>=0.8.0 ; extra == 'postgresql'
+ - sqlalchemy>=2.0.0 ; extra == 'mysql'
+ - pymysql>=1.0.2 ; extra == 'mysql'
+ - sqlalchemy>=2.0.0 ; extra == 'sql-other'
+ - adbc-driver-postgresql>=0.8.0 ; extra == 'sql-other'
+ - adbc-driver-sqlite>=0.8.0 ; extra == 'sql-other'
+ - beautifulsoup4>=4.11.2 ; extra == 'html'
+ - html5lib>=1.1 ; extra == 'html'
+ - lxml>=4.9.2 ; extra == 'html'
+ - lxml>=4.9.2 ; extra == 'xml'
+ - matplotlib>=3.6.3 ; extra == 'plot'
+ - jinja2>=3.1.2 ; extra == 'output-formatting'
+ - tabulate>=0.9.0 ; extra == 'output-formatting'
+ - pyqt5>=5.15.9 ; extra == 'clipboard'
+ - qtpy>=2.3.0 ; extra == 'clipboard'
+ - zstandard>=0.19.0 ; extra == 'compression'
+ - dataframe-api-compat>=0.1.7 ; extra == 'consortium-standard'
+ - adbc-driver-postgresql>=0.8.0 ; extra == 'all'
+ - adbc-driver-sqlite>=0.8.0 ; extra == 'all'
+ - beautifulsoup4>=4.11.2 ; extra == 'all'
+ - bottleneck>=1.3.6 ; extra == 'all'
+ - dataframe-api-compat>=0.1.7 ; extra == 'all'
+ - fastparquet>=2022.12.0 ; extra == 'all'
+ - fsspec>=2022.11.0 ; extra == 'all'
+ - gcsfs>=2022.11.0 ; extra == 'all'
+ - html5lib>=1.1 ; extra == 'all'
+ - hypothesis>=6.46.1 ; extra == 'all'
+ - jinja2>=3.1.2 ; extra == 'all'
+ - lxml>=4.9.2 ; extra == 'all'
+ - matplotlib>=3.6.3 ; extra == 'all'
+ - numba>=0.56.4 ; extra == 'all'
+ - numexpr>=2.8.4 ; extra == 'all'
+ - odfpy>=1.4.1 ; extra == 'all'
+ - openpyxl>=3.1.0 ; extra == 'all'
+ - pandas-gbq>=0.19.0 ; extra == 'all'
+ - psycopg2>=2.9.6 ; extra == 'all'
+ - pyarrow>=10.0.1 ; extra == 'all'
+ - pymysql>=1.0.2 ; extra == 'all'
+ - pyqt5>=5.15.9 ; extra == 'all'
+ - pyreadstat>=1.2.0 ; extra == 'all'
+ - pytest>=7.3.2 ; extra == 'all'
+ - pytest-xdist>=2.2.0 ; extra == 'all'
+ - python-calamine>=0.1.7 ; extra == 'all'
+ - pyxlsb>=1.0.10 ; extra == 'all'
+ - qtpy>=2.3.0 ; extra == 'all'
+ - scipy>=1.10.0 ; extra == 'all'
+ - s3fs>=2022.11.0 ; extra == 'all'
+ - sqlalchemy>=2.0.0 ; extra == 'all'
+ - tables>=3.8.0 ; extra == 'all'
+ - tabulate>=0.9.0 ; extra == 'all'
+ - xarray>=2022.12.0 ; extra == 'all'
+ - xlrd>=2.0.1 ; extra == 'all'
+ - xlsxwriter>=3.0.5 ; extra == 'all'
+ - zstandard>=0.19.0 ; extra == 'all'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl
+ name: pandas
+ version: 2.3.3
+ sha256: e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d
+ requires_dist:
+ - numpy>=1.22.4 ; python_full_version < '3.11'
+ - numpy>=1.23.2 ; python_full_version == '3.11.*'
+ - numpy>=1.26.0 ; python_full_version >= '3.12'
+ - python-dateutil>=2.8.2
+ - pytz>=2020.1
+ - tzdata>=2022.7
+ - hypothesis>=6.46.1 ; extra == 'test'
+ - pytest>=7.3.2 ; extra == 'test'
+ - pytest-xdist>=2.2.0 ; extra == 'test'
+ - pyarrow>=10.0.1 ; extra == 'pyarrow'
+ - bottleneck>=1.3.6 ; extra == 'performance'
+ - numba>=0.56.4 ; extra == 'performance'
+ - numexpr>=2.8.4 ; extra == 'performance'
+ - scipy>=1.10.0 ; extra == 'computation'
+ - xarray>=2022.12.0 ; extra == 'computation'
+ - fsspec>=2022.11.0 ; extra == 'fss'
+ - s3fs>=2022.11.0 ; extra == 'aws'
+ - gcsfs>=2022.11.0 ; extra == 'gcp'
+ - pandas-gbq>=0.19.0 ; extra == 'gcp'
+ - odfpy>=1.4.1 ; extra == 'excel'
+ - openpyxl>=3.1.0 ; extra == 'excel'
+ - python-calamine>=0.1.7 ; extra == 'excel'
+ - pyxlsb>=1.0.10 ; extra == 'excel'
+ - xlrd>=2.0.1 ; extra == 'excel'
+ - xlsxwriter>=3.0.5 ; extra == 'excel'
+ - pyarrow>=10.0.1 ; extra == 'parquet'
+ - pyarrow>=10.0.1 ; extra == 'feather'
+ - tables>=3.8.0 ; extra == 'hdf5'
+ - pyreadstat>=1.2.0 ; extra == 'spss'
+ - sqlalchemy>=2.0.0 ; extra == 'postgresql'
+ - psycopg2>=2.9.6 ; extra == 'postgresql'
+ - adbc-driver-postgresql>=0.8.0 ; extra == 'postgresql'
+ - sqlalchemy>=2.0.0 ; extra == 'mysql'
+ - pymysql>=1.0.2 ; extra == 'mysql'
+ - sqlalchemy>=2.0.0 ; extra == 'sql-other'
+ - adbc-driver-postgresql>=0.8.0 ; extra == 'sql-other'
+ - adbc-driver-sqlite>=0.8.0 ; extra == 'sql-other'
+ - beautifulsoup4>=4.11.2 ; extra == 'html'
+ - html5lib>=1.1 ; extra == 'html'
+ - lxml>=4.9.2 ; extra == 'html'
+ - lxml>=4.9.2 ; extra == 'xml'
+ - matplotlib>=3.6.3 ; extra == 'plot'
+ - jinja2>=3.1.2 ; extra == 'output-formatting'
+ - tabulate>=0.9.0 ; extra == 'output-formatting'
+ - pyqt5>=5.15.9 ; extra == 'clipboard'
+ - qtpy>=2.3.0 ; extra == 'clipboard'
+ - zstandard>=0.19.0 ; extra == 'compression'
+ - dataframe-api-compat>=0.1.7 ; extra == 'consortium-standard'
+ - adbc-driver-postgresql>=0.8.0 ; extra == 'all'
+ - adbc-driver-sqlite>=0.8.0 ; extra == 'all'
+ - beautifulsoup4>=4.11.2 ; extra == 'all'
+ - bottleneck>=1.3.6 ; extra == 'all'
+ - dataframe-api-compat>=0.1.7 ; extra == 'all'
+ - fastparquet>=2022.12.0 ; extra == 'all'
+ - fsspec>=2022.11.0 ; extra == 'all'
+ - gcsfs>=2022.11.0 ; extra == 'all'
+ - html5lib>=1.1 ; extra == 'all'
+ - hypothesis>=6.46.1 ; extra == 'all'
+ - jinja2>=3.1.2 ; extra == 'all'
+ - lxml>=4.9.2 ; extra == 'all'
+ - matplotlib>=3.6.3 ; extra == 'all'
+ - numba>=0.56.4 ; extra == 'all'
+ - numexpr>=2.8.4 ; extra == 'all'
+ - odfpy>=1.4.1 ; extra == 'all'
+ - openpyxl>=3.1.0 ; extra == 'all'
+ - pandas-gbq>=0.19.0 ; extra == 'all'
+ - psycopg2>=2.9.6 ; extra == 'all'
+ - pyarrow>=10.0.1 ; extra == 'all'
+ - pymysql>=1.0.2 ; extra == 'all'
+ - pyqt5>=5.15.9 ; extra == 'all'
+ - pyreadstat>=1.2.0 ; extra == 'all'
+ - pytest>=7.3.2 ; extra == 'all'
+ - pytest-xdist>=2.2.0 ; extra == 'all'
+ - python-calamine>=0.1.7 ; extra == 'all'
+ - pyxlsb>=1.0.10 ; extra == 'all'
+ - qtpy>=2.3.0 ; extra == 'all'
+ - scipy>=1.10.0 ; extra == 'all'
+ - s3fs>=2022.11.0 ; extra == 'all'
+ - sqlalchemy>=2.0.0 ; extra == 'all'
+ - tables>=3.8.0 ; extra == 'all'
+ - tabulate>=0.9.0 ; extra == 'all'
+ - xarray>=2022.12.0 ; extra == 'all'
+ - xlrd>=2.0.1 ; extra == 'all'
+ - xlsxwriter>=3.0.5 ; extra == 'all'
+ - zstandard>=0.19.0 ; extra == 'all'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl
+ name: pandas
+ version: 2.3.3
+ sha256: bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8
+ requires_dist:
+ - numpy>=1.22.4 ; python_full_version < '3.11'
+ - numpy>=1.23.2 ; python_full_version == '3.11.*'
+ - numpy>=1.26.0 ; python_full_version >= '3.12'
+ - python-dateutil>=2.8.2
+ - pytz>=2020.1
+ - tzdata>=2022.7
+ - hypothesis>=6.46.1 ; extra == 'test'
+ - pytest>=7.3.2 ; extra == 'test'
+ - pytest-xdist>=2.2.0 ; extra == 'test'
+ - pyarrow>=10.0.1 ; extra == 'pyarrow'
+ - bottleneck>=1.3.6 ; extra == 'performance'
+ - numba>=0.56.4 ; extra == 'performance'
+ - numexpr>=2.8.4 ; extra == 'performance'
+ - scipy>=1.10.0 ; extra == 'computation'
+ - xarray>=2022.12.0 ; extra == 'computation'
+ - fsspec>=2022.11.0 ; extra == 'fss'
+ - s3fs>=2022.11.0 ; extra == 'aws'
+ - gcsfs>=2022.11.0 ; extra == 'gcp'
+ - pandas-gbq>=0.19.0 ; extra == 'gcp'
+ - odfpy>=1.4.1 ; extra == 'excel'
+ - openpyxl>=3.1.0 ; extra == 'excel'
+ - python-calamine>=0.1.7 ; extra == 'excel'
+ - pyxlsb>=1.0.10 ; extra == 'excel'
+ - xlrd>=2.0.1 ; extra == 'excel'
+ - xlsxwriter>=3.0.5 ; extra == 'excel'
+ - pyarrow>=10.0.1 ; extra == 'parquet'
+ - pyarrow>=10.0.1 ; extra == 'feather'
+ - tables>=3.8.0 ; extra == 'hdf5'
+ - pyreadstat>=1.2.0 ; extra == 'spss'
+ - sqlalchemy>=2.0.0 ; extra == 'postgresql'
+ - psycopg2>=2.9.6 ; extra == 'postgresql'
+ - adbc-driver-postgresql>=0.8.0 ; extra == 'postgresql'
+ - sqlalchemy>=2.0.0 ; extra == 'mysql'
+ - pymysql>=1.0.2 ; extra == 'mysql'
+ - sqlalchemy>=2.0.0 ; extra == 'sql-other'
+ - adbc-driver-postgresql>=0.8.0 ; extra == 'sql-other'
+ - adbc-driver-sqlite>=0.8.0 ; extra == 'sql-other'
+ - beautifulsoup4>=4.11.2 ; extra == 'html'
+ - html5lib>=1.1 ; extra == 'html'
+ - lxml>=4.9.2 ; extra == 'html'
+ - lxml>=4.9.2 ; extra == 'xml'
+ - matplotlib>=3.6.3 ; extra == 'plot'
+ - jinja2>=3.1.2 ; extra == 'output-formatting'
+ - tabulate>=0.9.0 ; extra == 'output-formatting'
+ - pyqt5>=5.15.9 ; extra == 'clipboard'
+ - qtpy>=2.3.0 ; extra == 'clipboard'
+ - zstandard>=0.19.0 ; extra == 'compression'
+ - dataframe-api-compat>=0.1.7 ; extra == 'consortium-standard'
+ - adbc-driver-postgresql>=0.8.0 ; extra == 'all'
+ - adbc-driver-sqlite>=0.8.0 ; extra == 'all'
+ - beautifulsoup4>=4.11.2 ; extra == 'all'
+ - bottleneck>=1.3.6 ; extra == 'all'
+ - dataframe-api-compat>=0.1.7 ; extra == 'all'
+ - fastparquet>=2022.12.0 ; extra == 'all'
+ - fsspec>=2022.11.0 ; extra == 'all'
+ - gcsfs>=2022.11.0 ; extra == 'all'
+ - html5lib>=1.1 ; extra == 'all'
+ - hypothesis>=6.46.1 ; extra == 'all'
+ - jinja2>=3.1.2 ; extra == 'all'
+ - lxml>=4.9.2 ; extra == 'all'
+ - matplotlib>=3.6.3 ; extra == 'all'
+ - numba>=0.56.4 ; extra == 'all'
+ - numexpr>=2.8.4 ; extra == 'all'
+ - odfpy>=1.4.1 ; extra == 'all'
+ - openpyxl>=3.1.0 ; extra == 'all'
+ - pandas-gbq>=0.19.0 ; extra == 'all'
+ - psycopg2>=2.9.6 ; extra == 'all'
+ - pyarrow>=10.0.1 ; extra == 'all'
+ - pymysql>=1.0.2 ; extra == 'all'
+ - pyqt5>=5.15.9 ; extra == 'all'
+ - pyreadstat>=1.2.0 ; extra == 'all'
+ - pytest>=7.3.2 ; extra == 'all'
+ - pytest-xdist>=2.2.0 ; extra == 'all'
+ - python-calamine>=0.1.7 ; extra == 'all'
+ - pyxlsb>=1.0.10 ; extra == 'all'
+ - qtpy>=2.3.0 ; extra == 'all'
+ - scipy>=1.10.0 ; extra == 'all'
+ - s3fs>=2022.11.0 ; extra == 'all'
+ - sqlalchemy>=2.0.0 ; extra == 'all'
+ - tables>=3.8.0 ; extra == 'all'
+ - tabulate>=0.9.0 ; extra == 'all'
+ - xarray>=2022.12.0 ; extra == 'all'
+ - xlrd>=2.0.1 ; extra == 'all'
+ - xlsxwriter>=3.0.5 ; extra == 'all'
+ - zstandard>=0.19.0 ; extra == 'all'
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/pango-1.56.4-hadf4263_0.conda
+ sha256: 3613774ad27e48503a3a6a9d72017087ea70f1426f6e5541dbdb59a3b626eaaf
+ md5: 79f71230c069a287efe3a8614069ddf1
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - cairo >=1.18.4,<2.0a0
+ - fontconfig >=2.15.0,<3.0a0
+ - fonts-conda-ecosystem
+ - fribidi >=1.0.10,<2.0a0
+ - harfbuzz >=11.0.1
+ - libexpat >=2.7.0,<3.0a0
+ - libfreetype >=2.13.3
+ - libfreetype6 >=2.13.3
+ - libgcc >=13
+ - libglib >=2.84.2,<3.0a0
+ - libpng >=1.6.49,<1.7.0a0
+ - libzlib >=1.3.1,<2.0a0
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 455420
+ timestamp: 1751292466873
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pango-1.56.4-he55ef5b_0.conda
+ sha256: dd36cd5b6bc1c2988291a6db9fa4eb8acade9b487f6f1da4eaa65a1eebb0a12d
+ md5: a22cc88bf6059c9bcc158c94c9aab5b8
+ depends:
+ - cairo >=1.18.4,<2.0a0
+ - fontconfig >=2.15.0,<3.0a0
+ - fonts-conda-ecosystem
+ - fribidi >=1.0.10,<2.0a0
+ - harfbuzz >=11.0.1
+ - libexpat >=2.7.0,<3.0a0
+ - libfreetype >=2.13.3
+ - libfreetype6 >=2.13.3
+ - libgcc >=13
+ - libglib >=2.84.2,<3.0a0
+ - libpng >=1.6.49,<1.7.0a0
+ - libzlib >=1.3.1,<2.0a0
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 468811
+ timestamp: 1751293869070
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pango-1.56.4-h875632e_0.conda
+ sha256: 705484ad60adee86cab1aad3d2d8def03a699ece438c864e8ac995f6f66401a6
+ md5: 7d57f8b4b7acfc75c777bc231f0d31be
+ depends:
+ - __osx >=11.0
+ - cairo >=1.18.4,<2.0a0
+ - fontconfig >=2.15.0,<3.0a0
+ - fonts-conda-ecosystem
+ - fribidi >=1.0.10,<2.0a0
+ - harfbuzz >=11.0.1
+ - libexpat >=2.7.0,<3.0a0
+ - libfreetype >=2.13.3
+ - libfreetype6 >=2.13.3
+ - libglib >=2.84.2,<3.0a0
+ - libpng >=1.6.49,<1.7.0a0
+ - libzlib >=1.3.1,<2.0a0
+ license: LGPL-2.1-or-later
+ purls: []
+ size: 426931
+ timestamp: 1751292636271
+- pypi: https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl
+ name: parso
+ version: 0.8.5
+ sha256: 646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887
+ requires_dist:
+ - pytest ; extra == 'testing'
+ - docopt ; extra == 'testing'
+ - flake8==5.0.4 ; extra == 'qa'
+ - mypy==0.971 ; extra == 'qa'
+ - types-setuptools==67.2.0.1 ; extra == 'qa'
+ requires_python: '>=3.6'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.46-h1321c63_0.conda
+ sha256: 5c7380c8fd3ad5fc0f8039069a45586aa452cf165264bc5a437ad80397b32934
+ md5: 7fa07cb0fb1b625a089ccc01218ee5b1
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - bzip2 >=1.0.8,<2.0a0
+ - libgcc >=14
+ - libzlib >=1.3.1,<2.0a0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 1209177
+ timestamp: 1756742976157
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pcre2-10.46-h15761aa_0.conda
+ sha256: 75800e60e0e44d957c691a964085f56c9ac37dcd75e6c6904809d7b68f39e4ea
+ md5: 5128cb5188b630a58387799ea1366e37
+ depends:
+ - bzip2 >=1.0.8,<2.0a0
+ - libgcc >=14
+ - libzlib >=1.3.1,<2.0a0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 1161914
+ timestamp: 1756742893031
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.46-h7125dd6_0.conda
+ sha256: 5bf2eeaa57aab6e8e95bea6bd6bb2a739f52eb10572d8ed259d25864d3528240
+ md5: 0e6e82c3cc3835f4692022e9b9cd5df8
+ depends:
+ - __osx >=11.0
+ - bzip2 >=1.0.8,<2.0a0
+ - libzlib >=1.3.1,<2.0a0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 835080
+ timestamp: 1756743041908
+- pypi: https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl
+ name: pexpect
+ version: 4.9.0
+ sha256: 7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523
+ requires_dist:
+ - ptyprocess>=0.5
+- pypi: https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ name: pillow
+ version: 11.3.0
+ sha256: 13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8
+ requires_dist:
+ - furo ; extra == 'docs'
+ - olefile ; extra == 'docs'
+ - sphinx>=8.2 ; extra == 'docs'
+ - sphinx-autobuild ; extra == 'docs'
+ - sphinx-copybutton ; extra == 'docs'
+ - sphinx-inline-tabs ; extra == 'docs'
+ - sphinxext-opengraph ; extra == 'docs'
+ - olefile ; extra == 'fpx'
+ - olefile ; extra == 'mic'
+ - pyarrow ; extra == 'test-arrow'
+ - check-manifest ; extra == 'tests'
+ - coverage>=7.4.2 ; extra == 'tests'
+ - defusedxml ; extra == 'tests'
+ - markdown2 ; extra == 'tests'
+ - olefile ; extra == 'tests'
+ - packaging ; extra == 'tests'
+ - pyroma ; extra == 'tests'
+ - pytest ; extra == 'tests'
+ - pytest-cov ; extra == 'tests'
+ - pytest-timeout ; extra == 'tests'
+ - pytest-xdist ; extra == 'tests'
+ - trove-classifiers>=2024.10.12 ; extra == 'tests'
+ - typing-extensions ; python_full_version < '3.10' and extra == 'typing'
+ - defusedxml ; extra == 'xmp'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
+ name: pillow
+ version: 12.0.0
+ sha256: 0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e
+ requires_dist:
+ - furo ; extra == 'docs'
+ - olefile ; extra == 'docs'
+ - sphinx>=8.2 ; extra == 'docs'
+ - sphinx-autobuild ; extra == 'docs'
+ - sphinx-copybutton ; extra == 'docs'
+ - sphinx-inline-tabs ; extra == 'docs'
+ - sphinxext-opengraph ; extra == 'docs'
+ - olefile ; extra == 'fpx'
+ - olefile ; extra == 'mic'
+ - arro3-compute ; extra == 'test-arrow'
+ - arro3-core ; extra == 'test-arrow'
+ - nanoarrow ; extra == 'test-arrow'
+ - pyarrow ; extra == 'test-arrow'
+ - check-manifest ; extra == 'tests'
+ - coverage>=7.4.2 ; extra == 'tests'
+ - defusedxml ; extra == 'tests'
+ - markdown2 ; extra == 'tests'
+ - olefile ; extra == 'tests'
+ - packaging ; extra == 'tests'
+ - pyroma>=5 ; extra == 'tests'
+ - pytest ; extra == 'tests'
+ - pytest-cov ; extra == 'tests'
+ - pytest-timeout ; extra == 'tests'
+ - pytest-xdist ; extra == 'tests'
+ - trove-classifiers>=2024.10.12 ; extra == 'tests'
+ - defusedxml ; extra == 'xmp'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl
+ name: pillow
+ version: 12.0.0
+ sha256: 5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b
+ requires_dist:
+ - furo ; extra == 'docs'
+ - olefile ; extra == 'docs'
+ - sphinx>=8.2 ; extra == 'docs'
+ - sphinx-autobuild ; extra == 'docs'
+ - sphinx-copybutton ; extra == 'docs'
+ - sphinx-inline-tabs ; extra == 'docs'
+ - sphinxext-opengraph ; extra == 'docs'
+ - olefile ; extra == 'fpx'
+ - olefile ; extra == 'mic'
+ - arro3-compute ; extra == 'test-arrow'
+ - arro3-core ; extra == 'test-arrow'
+ - nanoarrow ; extra == 'test-arrow'
+ - pyarrow ; extra == 'test-arrow'
+ - check-manifest ; extra == 'tests'
+ - coverage>=7.4.2 ; extra == 'tests'
+ - defusedxml ; extra == 'tests'
+ - markdown2 ; extra == 'tests'
+ - olefile ; extra == 'tests'
+ - packaging ; extra == 'tests'
+ - pyroma>=5 ; extra == 'tests'
+ - pytest ; extra == 'tests'
+ - pytest-cov ; extra == 'tests'
+ - pytest-timeout ; extra == 'tests'
+ - pytest-xdist ; extra == 'tests'
+ - trove-classifiers>=2024.10.12 ; extra == 'tests'
+ - defusedxml ; extra == 'xmp'
+ requires_python: '>=3.10'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda
+ sha256: 43d37bc9ca3b257c5dd7bf76a8426addbdec381f6786ff441dc90b1a49143b6a
+ md5: c01af13bdc553d1a8fbfff6e8db075f0
+ depends:
+ - libgcc >=14
+ - libstdcxx >=14
+ - libgcc >=14
+ - __glibc >=2.17,<3.0.a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 450960
+ timestamp: 1754665235234
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pixman-0.46.4-h7ac5ae9_1.conda
+ sha256: e6b0846a998f2263629cfeac7bca73565c35af13251969f45d385db537a514e4
+ md5: 1587081d537bd4ae77d1c0635d465ba5
+ depends:
+ - libgcc >=14
+ - libstdcxx >=14
+ - libgcc >=14
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 357913
+ timestamp: 1754665583353
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pixman-0.46.4-h81086ad_1.conda
+ sha256: 29c9b08a9b8b7810f9d4f159aecfd205fce051633169040005c0b7efad4bc718
+ md5: 17c3d745db6ea72ae2fce17e7338547f
+ depends:
+ - __osx >=11.0
+ - libcxx >=19
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 248045
+ timestamp: 1754665282033
+- pypi: https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl
+ name: platformdirs
+ version: 4.4.0
+ sha256: abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85
+ requires_dist:
+ - furo>=2024.8.6 ; extra == 'docs'
+ - proselint>=0.14 ; extra == 'docs'
+ - sphinx-autodoc-typehints>=3 ; extra == 'docs'
+ - sphinx>=8.1.3 ; extra == 'docs'
+ - appdirs==1.4.4 ; extra == 'test'
+ - covdefaults>=2.3 ; extra == 'test'
+ - pytest-cov>=6 ; extra == 'test'
+ - pytest-mock>=3.14 ; extra == 'test'
+ - pytest>=8.3.4 ; extra == 'test'
+ - mypy>=1.14.1 ; extra == 'type'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl
+ name: platformdirs
+ version: 4.5.0
+ sha256: e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
+ requires_dist:
+ - furo>=2025.9.25 ; extra == 'docs'
+ - proselint>=0.14 ; extra == 'docs'
+ - sphinx-autodoc-typehints>=3.2 ; extra == 'docs'
+ - sphinx>=8.2.3 ; extra == 'docs'
+ - appdirs==1.4.4 ; extra == 'test'
+ - covdefaults>=2.3 ; extra == 'test'
+ - pytest-cov>=7 ; extra == 'test'
+ - pytest-mock>=3.15.1 ; extra == 'test'
+ - pytest>=8.4.2 ; extra == 'test'
+ - mypy>=1.18.2 ; extra == 'type'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl
+ name: pluggy
+ version: 1.6.0
+ sha256: e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
+ requires_dist:
+ - pre-commit ; extra == 'dev'
+ - tox ; extra == 'dev'
+ - pytest ; extra == 'testing'
+ - pytest-benchmark ; extra == 'testing'
+ - coverage ; extra == 'testing'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl
+ name: polars
+ version: 1.39.3
+ sha256: c2b955ccc0a08a2bc9259785decf3d5c007b489b523bf2390cf21cec2bb82a56
+ requires_dist:
+ - polars-runtime-32==1.39.3
+ - polars-runtime-64==1.39.3 ; extra == 'rt64'
+ - polars-runtime-compat==1.39.3 ; extra == 'rtcompat'
+ - polars-cloud>=0.4.0 ; extra == 'polars-cloud'
+ - numpy>=1.16.0 ; extra == 'numpy'
+ - pandas ; extra == 'pandas'
+ - polars[pyarrow] ; extra == 'pandas'
+ - pyarrow>=7.0.0 ; extra == 'pyarrow'
+ - pydantic ; extra == 'pydantic'
+ - fastexcel>=0.9 ; extra == 'calamine'
+ - openpyxl>=3.0.0 ; extra == 'openpyxl'
+ - xlsx2csv>=0.8.0 ; extra == 'xlsx2csv'
+ - xlsxwriter ; extra == 'xlsxwriter'
+ - polars[calamine,openpyxl,xlsx2csv,xlsxwriter] ; extra == 'excel'
+ - adbc-driver-manager[dbapi] ; extra == 'adbc'
+ - adbc-driver-sqlite[dbapi] ; extra == 'adbc'
+ - connectorx>=0.3.2 ; extra == 'connectorx'
+ - sqlalchemy ; extra == 'sqlalchemy'
+ - polars[pandas] ; extra == 'sqlalchemy'
+ - polars[adbc,connectorx,sqlalchemy] ; extra == 'database'
+ - fsspec ; extra == 'fsspec'
+ - deltalake>=1.0.0 ; extra == 'deltalake'
+ - pyiceberg>=0.7.1 ; extra == 'iceberg'
+ - gevent ; extra == 'async'
+ - cloudpickle ; extra == 'cloudpickle'
+ - matplotlib ; extra == 'graph'
+ - altair>=5.4.0 ; extra == 'plot'
+ - great-tables>=0.8.0 ; extra == 'style'
+ - tzdata ; sys_platform == 'win32' and extra == 'timezone'
+ - cudf-polars-cu12 ; extra == 'gpu'
+ - polars[async,cloudpickle,database,deltalake,excel,fsspec,graph,iceberg,numpy,pandas,plot,pyarrow,pydantic,style,timezone] ; extra == 'all'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/3d/3e/e65236d9d0d9babfa0ecba593413c06530fca60a8feb8f66243aa5dba92e/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
+ name: polars-runtime-32
+ version: 1.39.3
+ sha256: 06b47f535eb1f97a9a1e5b0053ef50db3a4276e241178e37bbb1a38b1fa53b14
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/90/bf/297716b3095fe719be20fcf7af1d2b6ab069c38199bbace2469608a69b3a/polars_runtime_32-1.39.3-cp310-abi3-macosx_11_0_arm64.whl
+ name: polars-runtime-32
+ version: 1.39.3
+ sha256: ef5884711e3c617d7dc93519a7d038e242f5741cfe5fe9afd32d58845d86c562
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/b0/15/fc3e43f3fdf3f20b7dfb5abe871ab6162cf8fb4aeabf4cfad822d5dc4c79/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ name: polars-runtime-32
+ version: 1.39.3
+ sha256: 8bc9e13dc1d2e828331f2fe8ccbc9757554dc4933a8d3e85e906b988178f95ed
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl
+ name: pre-commit
+ version: 4.3.0
+ sha256: 2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8
+ requires_dist:
+ - cfgv>=2.0.0
+ - identify>=1.0.0
+ - nodeenv>=0.11.1
+ - pyyaml>=5.1
+ - virtualenv>=20.10.0
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl
+ name: pre-commit
+ version: 4.4.0
+ sha256: b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813
+ requires_dist:
+ - cfgv>=2.0.0
+ - identify>=1.0.0
+ - nodeenv>=0.11.1
+ - pyyaml>=5.1
+ - virtualenv>=20.10.0
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl
+ name: prompt-toolkit
+ version: 3.0.52
+ sha256: 9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955
+ requires_dist:
+ - wcwidth
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ name: propcache
+ version: 0.4.1
+ sha256: 333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl
+ name: propcache
+ version: 0.4.1
+ sha256: cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ name: propcache
+ version: 0.4.1
+ sha256: d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl
+ name: psycopg2-binary
+ version: 2.9.11
+ sha256: 8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ name: psycopg2-binary
+ version: 2.9.11
+ sha256: 5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl
+ name: psycopg2-binary
+ version: 2.9.11
+ sha256: 366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda
+ sha256: 9c88f8c64590e9567c6c80823f0328e58d3b1efb0e1c539c0315ceca764e0973
+ md5: b3c17d95b5a10c6e64a21fa17573e70e
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 8252
+ timestamp: 1726802366959
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/pthread-stubs-0.4-h86ecc28_1002.conda
+ sha256: 977dfb0cb3935d748521dd80262fe7169ab82920afd38ed14b7fee2ea5ec01ba
+ md5: bb5a90c93e3bac3d5690acf76b4a6386
+ depends:
+ - libgcc >=13
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 8342
+ timestamp: 1726803319942
+- pypi: https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl
+ name: ptyprocess
+ version: 0.7.0
+ sha256: 4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35
+- pypi: https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl
+ name: pure-eval
+ version: 0.2.3
+ sha256: 1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0
+ requires_dist:
+ - pytest ; extra == 'tests'
+- pypi: https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl
+ name: pyarrow
+ version: 23.0.1
+ sha256: 6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl
+ name: pyarrow
+ version: 23.0.1
+ sha256: 9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl
+ name: pyarrow
+ version: 23.0.1
+ sha256: 71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl
+ name: pycparser
+ version: '2.23'
+ sha256: e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
+ name: pycryptodome
+ version: 3.23.0
+ sha256: 67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490
+ requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*'
+- pypi: https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ name: pycryptodome
+ version: 3.23.0
+ sha256: c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575
+ requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*'
+- pypi: https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl
+ name: pycryptodome
+ version: 3.23.0
+ sha256: 187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27
+ requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*'
+- pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
+ name: pydantic
+ version: 2.12.5
+ sha256: e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d
+ requires_dist:
+ - annotated-types>=0.6.0
+ - pydantic-core==2.41.5
+ - typing-extensions>=4.14.1
+ - typing-inspection>=0.4.2
+ - email-validator>=2.0.0 ; extra == 'email'
+ - tzdata ; python_full_version >= '3.9' and sys_platform == 'win32' and extra == 'timezone'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
+ name: pydantic-core
+ version: 2.41.5
+ sha256: 0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0
+ requires_dist:
+ - typing-extensions>=4.14.1
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl
+ name: pydantic-core
+ version: 2.41.5
+ sha256: 112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34
+ requires_dist:
+ - typing-extensions>=4.14.1
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ name: pydantic-core
+ version: 2.41.5
+ sha256: 406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586
+ requires_dist:
+ - typing-extensions>=4.14.1
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl
+ name: pydantic-settings
+ version: 2.12.0
+ sha256: fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809
+ requires_dist:
+ - pydantic>=2.7.0
+ - python-dotenv>=0.21.0
+ - typing-inspection>=0.4.0
+ - boto3-stubs[secretsmanager] ; extra == 'aws-secrets-manager'
+ - boto3>=1.35.0 ; extra == 'aws-secrets-manager'
+ - azure-identity>=1.16.0 ; extra == 'azure-key-vault'
+ - azure-keyvault-secrets>=4.8.0 ; extra == 'azure-key-vault'
+ - google-cloud-secret-manager>=2.23.1 ; extra == 'gcp-secret-manager'
+ - tomli>=2.0.1 ; extra == 'toml'
+ - pyyaml>=6.0.1 ; extra == 'yaml'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl
+ name: pydot
+ version: 4.0.1
+ sha256: 869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6
+ requires_dist:
+ - pyparsing>=3.1.0
+ - ruff ; extra == 'lint'
+ - mypy ; extra == 'types'
+ - pydot[lint] ; extra == 'dev'
+ - pydot[types] ; extra == 'dev'
+ - chardet ; extra == 'dev'
+ - parameterized ; extra == 'dev'
+ - pydot[dev] ; extra == 'tests'
+ - tox ; extra == 'tests'
+ - pytest ; extra == 'tests'
+ - pytest-cov ; extra == 'tests'
+ - pytest-xdist[psutil] ; extra == 'tests'
+ - zest-releaser[recommended] ; extra == 'release'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl
+ name: pygments
+ version: 2.19.2
+ sha256: 86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b
+ requires_dist:
+ - colorama>=0.4.6 ; extra == 'windows-terminal'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl
+ name: pymysql
+ version: 1.1.2
+ sha256: e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9
+ requires_dist:
+ - cryptography ; extra == 'rsa'
+ - pynacl>=1.4.0 ; extra == 'ed25519'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/53/b8/fbab973592e23ae313042d450fc26fa24282ebffba21ba373786e1ce63b4/pyparsing-3.2.4-py3-none-any.whl
+ name: pyparsing
+ version: 3.2.4
+ sha256: 91d0fcde680d42cd031daf3a6ba20da3107e08a75de50da58360e7d94ab24d36
+ requires_dist:
+ - railroad-diagrams ; extra == 'diagrams'
+ - jinja2 ; extra == 'diagrams'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl
+ name: pyparsing
+ version: 3.2.5
+ sha256: e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e
+ requires_dist:
+ - railroad-diagrams ; extra == 'diagrams'
+ - jinja2 ; extra == 'diagrams'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl
+ name: pytest
+ version: 8.4.2
+ sha256: 872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79
+ requires_dist:
+ - colorama>=0.4 ; sys_platform == 'win32'
+ - exceptiongroup>=1 ; python_full_version < '3.11'
+ - iniconfig>=1
+ - packaging>=20
+ - pluggy>=1.5,<2
+ - pygments>=2.7.2
+ - tomli>=1 ; python_full_version < '3.11'
+ - argcomplete ; extra == 'dev'
+ - attrs>=19.2 ; extra == 'dev'
+ - hypothesis>=3.56 ; extra == 'dev'
+ - mock ; extra == 'dev'
+ - requests ; extra == 'dev'
+ - setuptools ; extra == 'dev'
+ - xmlschema ; extra == 'dev'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl
+ name: pytest
+ version: 9.0.0
+ sha256: e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96
+ requires_dist:
+ - colorama>=0.4 ; sys_platform == 'win32'
+ - exceptiongroup>=1 ; python_full_version < '3.11'
+ - iniconfig>=1.0.1
+ - packaging>=22
+ - pluggy>=1.5,<2
+ - pygments>=2.7.2
+ - tomli>=1 ; python_full_version < '3.11'
+ - argcomplete ; extra == 'dev'
+ - attrs>=19.2 ; extra == 'dev'
+ - hypothesis>=3.56 ; extra == 'dev'
+ - mock ; extra == 'dev'
+ - requests ; extra == 'dev'
+ - setuptools ; extra == 'dev'
+ - xmlschema ; extra == 'dev'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl
+ name: pytest-cov
+ version: 7.0.0
+ sha256: 3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861
+ requires_dist:
+ - coverage[toml]>=7.10.6
+ - pluggy>=1.2
+ - pytest>=7
+ - process-tests ; extra == 'testing'
+ - pytest-xdist ; extra == 'testing'
+ - virtualenv ; extra == 'testing'
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.7-h2b335a9_100_cp313.conda
+ build_number: 100
+ sha256: 16cc30a5854f31ca6c3688337d34e37a79cdc518a06375fe3482ea8e2d6b34c8
+ md5: 724dcf9960e933838247971da07fe5cf
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - bzip2 >=1.0.8,<2.0a0
+ - ld_impl_linux-64 >=2.36.1
+ - libexpat >=2.7.1,<3.0a0
+ - libffi >=3.4.6,<3.5.0a0
+ - libgcc >=14
+ - liblzma >=5.8.1,<6.0a0
+ - libmpdec >=4.0.0,<5.0a0
+ - libsqlite >=3.50.4,<4.0a0
+ - libuuid >=2.38.1,<3.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - ncurses >=6.5,<7.0a0
+ - openssl >=3.5.2,<4.0a0
+ - python_abi 3.13.* *_cp313
+ - readline >=8.2,<9.0a0
+ - tk >=8.6.13,<8.7.0a0
+ - tzdata
+ license: Python-2.0
+ purls: []
+ size: 33583088
+ timestamp: 1756911465277
+ python_site_packages_path: lib/python3.13/site-packages
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/python-3.13.9-h4c0d347_101_cp313.conda
+ build_number: 101
+ sha256: 95f11d8f8e8007ead0927ff15401a9a48a28df92b284f41a08824955c009e974
+ md5: b62a2e7c210e4bffa9aaa041f7152a25
+ depends:
+ - bzip2 >=1.0.8,<2.0a0
+ - ld_impl_linux-aarch64 >=2.36.1
+ - libexpat >=2.7.1,<3.0a0
+ - libffi >=3.5.2,<3.6.0a0
+ - libgcc >=14
+ - liblzma >=5.8.1,<6.0a0
+ - libmpdec >=4.0.0,<5.0a0
+ - libsqlite >=3.50.4,<4.0a0
+ - libuuid >=2.41.2,<3.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - ncurses >=6.5,<7.0a0
+ - openssl >=3.5.4,<4.0a0
+ - python_abi 3.13.* *_cp313
+ - readline >=8.2,<9.0a0
+ - tk >=8.6.13,<8.7.0a0
+ - tzdata
+ license: Python-2.0
+ purls: []
+ size: 33737136
+ timestamp: 1761175607146
+ python_site_packages_path: lib/python3.13/site-packages
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.9-hfc2f54d_101_cp313.conda
+ build_number: 101
+ sha256: 516229f780b98783a5ef4112a5a4b5e5647d4f0177c4621e98aa60bb9bc32f98
+ md5: a4241bce59eecc74d4d2396e108c93b8
+ depends:
+ - __osx >=11.0
+ - bzip2 >=1.0.8,<2.0a0
+ - libexpat >=2.7.1,<3.0a0
+ - libffi >=3.5.2,<3.6.0a0
+ - liblzma >=5.8.1,<6.0a0
+ - libmpdec >=4.0.0,<5.0a0
+ - libsqlite >=3.50.4,<4.0a0
+ - libzlib >=1.3.1,<2.0a0
+ - ncurses >=6.5,<7.0a0
+ - openssl >=3.5.4,<4.0a0
+ - python_abi 3.13.* *_cp313
+ - readline >=8.2,<9.0a0
+ - tk >=8.6.13,<8.7.0a0
+ - tzdata
+ license: Python-2.0
+ purls: []
+ size: 11915380
+ timestamp: 1761176793936
+ python_site_packages_path: lib/python3.13/site-packages
+- pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
+ name: python-dateutil
+ version: 2.9.0.post0
+ sha256: a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
+ requires_dist:
+ - six>=1.5
+ requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*'
+- pypi: https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl
+ name: python-dotenv
+ version: 1.2.1
+ sha256: b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61
+ requires_dist:
+ - click>=5.0 ; extra == 'cli'
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda
+ build_number: 8
+ sha256: 210bffe7b121e651419cb196a2a63687b087497595c9be9d20ebe97dd06060a7
+ md5: 94305520c52a4aa3f6c2b1ff6008d9f8
+ constrains:
+ - python 3.13.* *_cp313
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 7002
+ timestamp: 1752805902938
+- pypi: https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl
+ name: pytz
+ version: '2025.2'
+ sha256: 5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00
+- pypi: https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ name: pyyaml
+ version: 6.0.2
+ sha256: 70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ name: pyyaml
+ version: 6.0.3
+ sha256: ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl
+ name: pyyaml
+ version: 6.0.3
+ sha256: 2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1
+ requires_python: '>=3.8'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda
+ sha256: 2d6d0c026902561ed77cd646b5021aef2d4db22e57a5b0178dfc669231e06d2c
+ md5: 283b96675859b20a825f8fa30f311446
+ depends:
+ - libgcc >=13
+ - ncurses >=6.5,<7.0a0
+ license: GPL-3.0-only
+ license_family: GPL
+ purls: []
+ size: 282480
+ timestamp: 1740379431762
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/readline-8.2-h8382b9d_2.conda
+ sha256: 54bed3a3041befaa9f5acde4a37b1a02f44705b7796689574bcf9d7beaad2959
+ md5: c0f08fc2737967edde1a272d4bf41ed9
+ depends:
+ - libgcc >=13
+ - ncurses >=6.5,<7.0a0
+ license: GPL-3.0-only
+ license_family: GPL
+ purls: []
+ size: 291806
+ timestamp: 1740380591358
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h1d1bf99_2.conda
+ sha256: 7db04684d3904f6151eff8673270922d31da1eea7fa73254d01c437f49702e34
+ md5: 63ef3f6e6d6d5c589e64f11263dc5676
+ depends:
+ - ncurses >=6.5,<7.0a0
+ license: GPL-3.0-only
+ license_family: GPL
+ purls: []
+ size: 252359
+ timestamp: 1740379663071
+- pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
+ name: requests
+ version: 2.32.5
+ sha256: 2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6
+ requires_dist:
+ - charset-normalizer>=2,<4
+ - idna>=2.5,<4
+ - urllib3>=1.21.1,<3
+ - certifi>=2017.4.17
+ - pysocks>=1.5.6,!=1.5.7 ; extra == 'socks'
+ - chardet>=3.0.2,<6 ; extra == 'use-chardet-on-py3'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/7d/f8/2be49047f929d6965401855461e697ab185e1a6a683d914c5c19c7962d9e/ruff-0.14.9-py3-none-macosx_11_0_arm64.whl
+ name: ruff
+ version: 0.14.9
+ sha256: d5dc3473c3f0e4a1008d0ef1d75cee24a48e254c8bed3a7afdd2b4392657ed2c
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/9e/e9/08840ff5127916bb989c86f18924fd568938b06f58b60e206176f327c0fe/ruff-0.14.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
+ name: ruff
+ version: 0.14.9
+ sha256: 84bf7c698fc8f3cb8278830fb6b5a47f9bcc1ed8cb4f689b9dd02698fa840697
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/f3/51/0489a6a5595b7760b5dbac0dd82852b510326e7d88d51dbffcd2e07e3ff3/ruff-0.14.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ name: ruff
+ version: 0.14.9
+ sha256: 72034534e5b11e8a593f517b2f2f2b273eb68a30978c6a2d40473ad0aaa4cb4a
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/6a/52/5ccdc01f7a8a61357d15a66b5d8a6580aa8529cb33f32e6cbb71c52622c5/s3fs-2026.3.0-py3-none-any.whl
+ name: s3fs
+ version: 2026.3.0
+ sha256: 2fa40a64c03003cfa5ae0e352788d97aa78ae8f9e25ea98b28ce9d21ba10c1b8
+ requires_dist:
+ - aiobotocore>=2.19.0,<4.0.0
+ - fsspec==2026.3.0
+ - aiohttp>=3.9.0,!=4.0.0a0,!=4.0.0a1
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
+ name: six
+ version: 1.17.0
+ sha256: 4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274
+ requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*'
+- pypi: https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ name: sqlalchemy
+ version: 2.0.48
+ sha256: 2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f
+ requires_dist:
+ - importlib-metadata ; python_full_version < '3.8'
+ - greenlet>=1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'
+ - typing-extensions>=4.6.0
+ - greenlet>=1 ; extra == 'asyncio'
+ - mypy>=0.910 ; extra == 'mypy'
+ - pyodbc ; extra == 'mssql'
+ - pymssql ; extra == 'mssql-pymssql'
+ - pyodbc ; extra == 'mssql-pyodbc'
+ - mysqlclient>=1.4.0 ; extra == 'mysql'
+ - mysql-connector-python ; extra == 'mysql-connector'
+ - mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10 ; extra == 'mariadb-connector'
+ - cx-oracle>=8 ; extra == 'oracle'
+ - oracledb>=1.0.1 ; extra == 'oracle-oracledb'
+ - psycopg2>=2.7 ; extra == 'postgresql'
+ - pg8000>=1.29.1 ; extra == 'postgresql-pg8000'
+ - greenlet>=1 ; extra == 'postgresql-asyncpg'
+ - asyncpg ; extra == 'postgresql-asyncpg'
+ - psycopg2-binary ; extra == 'postgresql-psycopg2binary'
+ - psycopg2cffi ; extra == 'postgresql-psycopg2cffi'
+ - psycopg>=3.0.7 ; extra == 'postgresql-psycopg'
+ - psycopg[binary]>=3.0.7 ; extra == 'postgresql-psycopgbinary'
+ - pymysql ; extra == 'pymysql'
+ - greenlet>=1 ; extra == 'aiomysql'
+ - aiomysql>=0.2.0 ; extra == 'aiomysql'
+ - greenlet>=1 ; extra == 'aioodbc'
+ - aioodbc ; extra == 'aioodbc'
+ - greenlet>=1 ; extra == 'asyncmy'
+ - asyncmy>=0.2.3,!=0.2.4,!=0.2.6 ; extra == 'asyncmy'
+ - greenlet>=1 ; extra == 'aiosqlite'
+ - aiosqlite ; extra == 'aiosqlite'
+ - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite'
+ - sqlcipher3-binary ; extra == 'sqlcipher'
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl
+ name: sqlalchemy
+ version: 2.0.48
+ sha256: e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4
+ requires_dist:
+ - importlib-metadata ; python_full_version < '3.8'
+ - greenlet>=1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'
+ - typing-extensions>=4.6.0
+ - greenlet>=1 ; extra == 'asyncio'
+ - mypy>=0.910 ; extra == 'mypy'
+ - pyodbc ; extra == 'mssql'
+ - pymssql ; extra == 'mssql-pymssql'
+ - pyodbc ; extra == 'mssql-pyodbc'
+ - mysqlclient>=1.4.0 ; extra == 'mysql'
+ - mysql-connector-python ; extra == 'mysql-connector'
+ - mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10 ; extra == 'mariadb-connector'
+ - cx-oracle>=8 ; extra == 'oracle'
+ - oracledb>=1.0.1 ; extra == 'oracle-oracledb'
+ - psycopg2>=2.7 ; extra == 'postgresql'
+ - pg8000>=1.29.1 ; extra == 'postgresql-pg8000'
+ - greenlet>=1 ; extra == 'postgresql-asyncpg'
+ - asyncpg ; extra == 'postgresql-asyncpg'
+ - psycopg2-binary ; extra == 'postgresql-psycopg2binary'
+ - psycopg2cffi ; extra == 'postgresql-psycopg2cffi'
+ - psycopg>=3.0.7 ; extra == 'postgresql-psycopg'
+ - psycopg[binary]>=3.0.7 ; extra == 'postgresql-psycopgbinary'
+ - pymysql ; extra == 'pymysql'
+ - greenlet>=1 ; extra == 'aiomysql'
+ - aiomysql>=0.2.0 ; extra == 'aiomysql'
+ - greenlet>=1 ; extra == 'aioodbc'
+ - aioodbc ; extra == 'aioodbc'
+ - greenlet>=1 ; extra == 'asyncmy'
+ - asyncmy>=0.2.3,!=0.2.4,!=0.2.6 ; extra == 'asyncmy'
+ - greenlet>=1 ; extra == 'aiosqlite'
+ - aiosqlite ; extra == 'aiosqlite'
+ - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite'
+ - sqlcipher3-binary ; extra == 'sqlcipher'
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ name: sqlalchemy
+ version: 2.0.48
+ sha256: b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed
+ requires_dist:
+ - importlib-metadata ; python_full_version < '3.8'
+ - greenlet>=1 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'
+ - typing-extensions>=4.6.0
+ - greenlet>=1 ; extra == 'asyncio'
+ - mypy>=0.910 ; extra == 'mypy'
+ - pyodbc ; extra == 'mssql'
+ - pymssql ; extra == 'mssql-pymssql'
+ - pyodbc ; extra == 'mssql-pyodbc'
+ - mysqlclient>=1.4.0 ; extra == 'mysql'
+ - mysql-connector-python ; extra == 'mysql-connector'
+ - mariadb>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10 ; extra == 'mariadb-connector'
+ - cx-oracle>=8 ; extra == 'oracle'
+ - oracledb>=1.0.1 ; extra == 'oracle-oracledb'
+ - psycopg2>=2.7 ; extra == 'postgresql'
+ - pg8000>=1.29.1 ; extra == 'postgresql-pg8000'
+ - greenlet>=1 ; extra == 'postgresql-asyncpg'
+ - asyncpg ; extra == 'postgresql-asyncpg'
+ - psycopg2-binary ; extra == 'postgresql-psycopg2binary'
+ - psycopg2cffi ; extra == 'postgresql-psycopg2cffi'
+ - psycopg>=3.0.7 ; extra == 'postgresql-psycopg'
+ - psycopg[binary]>=3.0.7 ; extra == 'postgresql-psycopgbinary'
+ - pymysql ; extra == 'pymysql'
+ - greenlet>=1 ; extra == 'aiomysql'
+ - aiomysql>=0.2.0 ; extra == 'aiomysql'
+ - greenlet>=1 ; extra == 'aioodbc'
+ - aioodbc ; extra == 'aioodbc'
+ - greenlet>=1 ; extra == 'asyncmy'
+ - asyncmy>=0.2.3,!=0.2.4,!=0.2.6 ; extra == 'asyncmy'
+ - greenlet>=1 ; extra == 'aiosqlite'
+ - aiosqlite ; extra == 'aiosqlite'
+ - typing-extensions!=3.10.0.1 ; extra == 'aiosqlite'
+ - sqlcipher3-binary ; extra == 'sqlcipher'
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl
+ name: stack-data
+ version: 0.6.3
+ sha256: d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695
+ requires_dist:
+ - executing>=1.2.0
+ - asttokens>=2.1.0
+ - pure-eval
+ - pytest ; extra == 'tests'
+ - typeguard ; extra == 'tests'
+ - pygments ; extra == 'tests'
+ - littleutils ; extra == 'tests'
+ - cython ; extra == 'tests'
+- pypi: https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl
+ name: testcontainers
+ version: 4.14.2
+ sha256: 0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68
+ requires_dist:
+ - docker
+ - python-dotenv
+ - typing-extensions
+ - urllib3
+ - wrapt
+ - python-arango>=8 ; extra == 'arangodb'
+ - boto3>=1 ; extra == 'aws'
+ - httpx ; extra == 'aws'
+ - azure-storage-blob>=12 ; extra == 'azurite'
+ - chromadb-client>=1 ; extra == 'chroma'
+ - clickhouse-driver ; extra == 'clickhouse'
+ - azure-cosmos>=4 ; extra == 'cosmosdb'
+ - ibm-db-sa ; platform_machine != 'aarch64' and platform_machine != 'arm64' and extra == 'db2'
+ - sqlalchemy>=2 ; extra == 'db2'
+ - httpx ; extra == 'generic'
+ - redis>=7 ; extra == 'generic'
+ - google-cloud-datastore>=2 ; extra == 'google'
+ - google-cloud-pubsub>=2 ; extra == 'google'
+ - influxdb-client>=1 ; extra == 'influxdb'
+ - influxdb>=5 ; extra == 'influxdb'
+ - kubernetes ; extra == 'k3s'
+ - pyyaml>=6.0.3 ; extra == 'k3s'
+ - python-keycloak>=6 ; python_full_version < '4' and extra == 'keycloak'
+ - boto3>=1 ; extra == 'localstack'
+ - cryptography ; extra == 'mailpit'
+ - minio>=7 ; extra == 'minio'
+ - pymongo>=4 ; extra == 'mongodb'
+ - pymssql>=2 ; extra == 'mssql'
+ - sqlalchemy>=2 ; extra == 'mssql'
+ - pymysql[rsa]>=1 ; extra == 'mysql'
+ - sqlalchemy>=2 ; extra == 'mysql'
+ - nats-py>=2 ; extra == 'nats'
+ - neo4j>=6 ; extra == 'neo4j'
+ - openfga-sdk ; extra == 'openfga'
+ - opensearch-py>=3 ; python_full_version < '4' and extra == 'opensearch'
+ - oracledb>=3 ; extra == 'oracle'
+ - sqlalchemy>=2 ; extra == 'oracle'
+ - oracledb>=3 ; extra == 'oracle-free'
+ - sqlalchemy>=2 ; extra == 'oracle-free'
+ - qdrant-client>=1 ; extra == 'qdrant'
+ - pika>=1 ; extra == 'rabbitmq'
+ - redis>=7 ; extra == 'redis'
+ - bcrypt>=5 ; extra == 'registry'
+ - cassandra-driver>=3 ; extra == 'scylla'
+ - selenium>=4 ; extra == 'selenium'
+ - cryptography ; extra == 'sftp'
+ - httpx ; extra == 'test-module-import'
+ - trino ; extra == 'trino'
+ - weaviate-client>=4 ; extra == 'weaviate'
+ requires_python: '>=3.10'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda
+ sha256: a84ff687119e6d8752346d1d408d5cf360dee0badd487a472aa8ddedfdc219e1
+ md5: a0116df4f4ed05c303811a837d5b39d8
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - libzlib >=1.3.1,<2.0a0
+ license: TCL
+ license_family: BSD
+ purls: []
+ size: 3285204
+ timestamp: 1748387766691
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/tk-8.6.13-noxft_h5688188_102.conda
+ sha256: 46e10488e9254092c655257c18fcec0a9864043bdfbe935a9fbf4fb2028b8514
+ md5: 2562c9bfd1de3f9c590f0fe53858d85c
+ depends:
+ - libgcc >=13
+ - libzlib >=1.3.1,<2.0a0
+ license: TCL
+ license_family: BSD
+ purls: []
+ size: 3342845
+ timestamp: 1748393219221
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_2.conda
+ sha256: cb86c522576fa95c6db4c878849af0bccfd3264daf0cc40dd18e7f4a7bfced0e
+ md5: 7362396c170252e7b7b0c8fb37fe9c78
+ depends:
+ - __osx >=11.0
+ - libzlib >=1.3.1,<2.0a0
+ license: TCL
+ license_family: BSD
+ purls: []
+ size: 3125538
+ timestamp: 1748388189063
+- pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
+ name: tqdm
+ version: 4.67.1
+ sha256: 26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2
+ requires_dist:
+ - colorama ; sys_platform == 'win32'
+ - pytest>=6 ; extra == 'dev'
+ - pytest-cov ; extra == 'dev'
+ - pytest-timeout ; extra == 'dev'
+ - pytest-asyncio>=0.24 ; extra == 'dev'
+ - nbval ; extra == 'dev'
+ - requests ; extra == 'discord'
+ - slack-sdk ; extra == 'slack'
+ - requests ; extra == 'telegram'
+ - ipywidgets>=6 ; extra == 'notebook'
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl
+ name: traitlets
+ version: 5.14.3
+ sha256: b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f
+ requires_dist:
+ - myst-parser ; extra == 'docs'
+ - pydata-sphinx-theme ; extra == 'docs'
+ - sphinx ; extra == 'docs'
+ - argcomplete>=3.0.3 ; extra == 'test'
+ - mypy>=1.7.0 ; extra == 'test'
+ - pre-commit ; extra == 'test'
+ - pytest-mock ; extra == 'test'
+ - pytest-mypy-testing ; extra == 'test'
+ - pytest>=7.0,<8.2 ; extra == 'test'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl
+ name: typing-extensions
+ version: 4.15.0
+ sha256: f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl
+ name: typing-inspection
+ version: 0.4.2
+ sha256: 4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7
+ requires_dist:
+ - typing-extensions>=4.12.0
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl
+ name: tzdata
+ version: '2025.2'
+ sha256: 1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8
+ requires_python: '>=2'
+- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
+ sha256: 5aaa366385d716557e365f0a4e9c3fca43ba196872abbbe3d56bb610d131e192
+ md5: 4222072737ccff51314b5ece9c7d6f5a
+ license: LicenseRef-Public-Domain
+ purls: []
+ size: 122968
+ timestamp: 1742727099393
+- pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl
+ name: urllib3
+ version: 2.5.0
+ sha256: e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc
+ requires_dist:
+ - brotli>=1.0.9 ; platform_python_implementation == 'CPython' and extra == 'brotli'
+ - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'brotli'
+ - h2>=4,<5 ; extra == 'h2'
+ - pysocks>=1.5.6,!=1.5.7,<2.0 ; extra == 'socks'
+ - zstandard>=0.18.0 ; extra == 'zstd'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl
+ name: virtualenv
+ version: 20.34.0
+ sha256: 341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026
+ requires_dist:
+ - distlib>=0.3.7,<1
+ - filelock>=3.12.2,<4
+ - importlib-metadata>=6.6 ; python_full_version < '3.8'
+ - platformdirs>=3.9.1,<5
+ - typing-extensions>=4.13.2 ; python_full_version < '3.11'
+ - furo>=2023.7.26 ; extra == 'docs'
+ - proselint>=0.13 ; extra == 'docs'
+ - sphinx>=7.1.2,!=7.3 ; extra == 'docs'
+ - sphinx-argparse>=0.4 ; extra == 'docs'
+ - sphinxcontrib-towncrier>=0.2.1a0 ; extra == 'docs'
+ - towncrier>=23.6 ; extra == 'docs'
+ - covdefaults>=2.3 ; extra == 'test'
+ - coverage-enable-subprocess>=1 ; extra == 'test'
+ - coverage>=7.2.7 ; extra == 'test'
+ - flaky>=3.7 ; extra == 'test'
+ - packaging>=23.1 ; extra == 'test'
+ - pytest-env>=0.8.2 ; extra == 'test'
+ - pytest-freezer>=0.4.8 ; (python_full_version >= '3.13' and platform_python_implementation == 'CPython' and sys_platform == 'win32' and extra == 'test') or (platform_python_implementation == 'GraalVM' and extra == 'test') or (platform_python_implementation == 'PyPy' and extra == 'test')
+ - pytest-mock>=3.11.1 ; extra == 'test'
+ - pytest-randomly>=3.12 ; extra == 'test'
+ - pytest-timeout>=2.1 ; extra == 'test'
+ - pytest>=7.4 ; extra == 'test'
+ - setuptools>=68 ; extra == 'test'
+ - time-machine>=2.10 ; platform_python_implementation == 'CPython' and extra == 'test'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl
+ name: virtualenv
+ version: 20.35.4
+ sha256: c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b
+ requires_dist:
+ - distlib>=0.3.7,<1
+ - filelock>=3.12.2,<4
+ - importlib-metadata>=6.6 ; python_full_version < '3.8'
+ - platformdirs>=3.9.1,<5
+ - typing-extensions>=4.13.2 ; python_full_version < '3.11'
+ - furo>=2023.7.26 ; extra == 'docs'
+ - proselint>=0.13 ; extra == 'docs'
+ - sphinx>=7.1.2,!=7.3 ; extra == 'docs'
+ - sphinx-argparse>=0.4 ; extra == 'docs'
+ - sphinxcontrib-towncrier>=0.2.1a0 ; extra == 'docs'
+ - towncrier>=23.6 ; extra == 'docs'
+ - covdefaults>=2.3 ; extra == 'test'
+ - coverage-enable-subprocess>=1 ; extra == 'test'
+ - coverage>=7.2.7 ; extra == 'test'
+ - flaky>=3.7 ; extra == 'test'
+ - packaging>=23.1 ; extra == 'test'
+ - pytest-env>=0.8.2 ; extra == 'test'
+ - pytest-freezer>=0.4.8 ; (python_full_version >= '3.13' and platform_python_implementation == 'CPython' and sys_platform == 'win32' and extra == 'test') or (platform_python_implementation == 'GraalVM' and extra == 'test') or (platform_python_implementation == 'PyPy' and extra == 'test')
+ - pytest-mock>=3.11.1 ; extra == 'test'
+ - pytest-randomly>=3.12 ; extra == 'test'
+ - pytest-timeout>=2.1 ; extra == 'test'
+ - pytest>=7.4 ; extra == 'test'
+ - setuptools>=68 ; extra == 'test'
+ - time-machine>=2.10 ; platform_python_implementation == 'CPython' and extra == 'test'
+ requires_python: '>=3.8'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/wayland-1.24.0-h3e06ad9_0.conda
+ sha256: ba673427dcd480cfa9bbc262fd04a9b1ad2ed59a159bd8f7e750d4c52282f34c
+ md5: 0f2ca7906bf166247d1d760c3422cb8a
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libexpat >=2.7.0,<3.0a0
+ - libffi >=3.4.6,<3.5.0a0
+ - libgcc >=13
+ - libstdcxx >=13
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 330474
+ timestamp: 1751817998141
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/wayland-1.24.0-h4f8a99f_1.conda
+ sha256: d94af8f287db764327ac7b48f6c0cd5c40da6ea2606afd34ac30671b7c85d8ee
+ md5: f6966cb1f000c230359ae98c29e37d87
+ depends:
+ - libexpat >=2.7.1,<3.0a0
+ - libffi >=3.5.2,<3.6.0a0
+ - libgcc >=14
+ - libstdcxx >=14
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 331480
+ timestamp: 1761174368396
+- pypi: https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl
+ name: wcwidth
+ version: 0.2.13
+ sha256: 3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859
+ requires_dist:
+ - backports-functools-lru-cache>=1.2.1 ; python_full_version < '3.2'
+- pypi: https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl
+ name: wcwidth
+ version: 0.2.14
+ sha256: a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1
+ requires_python: '>=3.6'
+- pypi: https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ name: wrapt
+ version: 2.1.2
+ sha256: bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb
+ requires_dist:
+ - pytest ; extra == 'dev'
+ - setuptools ; extra == 'dev'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ name: wrapt
+ version: 2.1.2
+ sha256: 16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca
+ requires_dist:
+ - pytest ; extra == 'dev'
+ - setuptools ; extra == 'dev'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl
+ name: wrapt
+ version: 2.1.2
+ sha256: 4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e
+ requires_dist:
+ - pytest ; extra == 'dev'
+ - setuptools ; extra == 'dev'
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xkeyboard-config-2.45-hb9d3cd8_0.conda
+ sha256: a5d4af601f71805ec67403406e147c48d6bad7aaeae92b0622b7e2396842d3fe
+ md5: 397a013c2dc5145a70737871aaa87e98
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - xorg-libx11 >=1.8.12,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 392406
+ timestamp: 1749375847832
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xkeyboard-config-2.46-he30d5cf_0.conda
+ sha256: c440a757d210e84c7f315ac3b034266980a8b4c986600649d296b9198b5b4f5e
+ md5: 9524f30d9dea7dd5d6ead43a8823b6c2
+ depends:
+ - libgcc >=14
+ - xorg-libx11 >=1.8.12,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 396706
+ timestamp: 1759543850920
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libice-1.1.2-hb9d3cd8_0.conda
+ sha256: c12396aabb21244c212e488bbdc4abcdef0b7404b15761d9329f5a4a39113c4b
+ md5: fb901ff28063514abb6046c9ec2c4a45
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 58628
+ timestamp: 1734227592886
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libice-1.1.2-h86ecc28_0.conda
+ sha256: a2ba1864403c7eb4194dacbfe2777acf3d596feae43aada8d1b478617ce45031
+ md5: c8d8ec3e00cd0fd8a231789b91a7c5b7
+ depends:
+ - libgcc >=13
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 60433
+ timestamp: 1734229908988
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libsm-1.2.6-he73a12e_0.conda
+ sha256: 277841c43a39f738927145930ff963c5ce4c4dacf66637a3d95d802a64173250
+ md5: 1c74ff8c35dcadf952a16f752ca5aa49
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - libuuid >=2.38.1,<3.0a0
+ - xorg-libice >=1.1.2,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 27590
+ timestamp: 1741896361728
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libsm-1.2.6-h0808dbd_0.conda
+ sha256: b86a819cd16f90c01d9d81892155126d01555a20dabd5f3091da59d6309afd0a
+ md5: 2d1409c50882819cb1af2de82e2b7208
+ depends:
+ - libgcc >=13
+ - libuuid >=2.38.1,<3.0a0
+ - xorg-libice >=1.1.2,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 28701
+ timestamp: 1741897678254
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.12-h4f16b4b_0.conda
+ sha256: 51909270b1a6c5474ed3978628b341b4d4472cd22610e5f22b506855a5e20f67
+ md5: db038ce880f100acc74dba10302b5630
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - libxcb >=1.17.0,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 835896
+ timestamp: 1741901112627
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libx11-1.8.12-hca56bd8_0.conda
+ sha256: 452977d8ad96f04ec668ba74f46e70a53e00f99c0e0307956aeca75894c8131d
+ md5: 3df132f0048b9639bc091ef22937c111
+ depends:
+ - libgcc >=13
+ - libxcb >=1.17.0,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 864850
+ timestamp: 1741901264068
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb9d3cd8_0.conda
+ sha256: ed10c9283974d311855ae08a16dfd7e56241fac632aec3b92e3cfe73cff31038
+ md5: f6ebe2cb3f82ba6c057dde5d9debe4f7
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 14780
+ timestamp: 1734229004433
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxau-1.0.12-h86ecc28_0.conda
+ sha256: 7829a0019b99ba462aece7592d2d7f42e12d12ccd3b9614e529de6ddba453685
+ md5: d5397424399a66d33c80b1f2345a36a6
+ depends:
+ - libgcc >=13
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 15873
+ timestamp: 1734230458294
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcomposite-0.4.6-hb9d3cd8_2.conda
+ sha256: 753f73e990c33366a91fd42cc17a3d19bb9444b9ca5ff983605fa9e953baf57f
+ md5: d3c295b50f092ab525ffe3c2aa4b7413
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - xorg-libx11 >=1.8.10,<2.0a0
+ - xorg-libxfixes >=6.0.1,<7.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 13603
+ timestamp: 1727884600744
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxcomposite-0.4.6-h86ecc28_2.conda
+ sha256: 0cb82160412adb6d83f03cf50e807a8e944682d556b2215992a6fbe9ced18bc0
+ md5: 86051eee0766c3542be24844a9c3cf36
+ depends:
+ - libgcc >=13
+ - xorg-libx11 >=1.8.9,<2.0a0
+ - xorg-libxfixes >=6.0.1,<7.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 13982
+ timestamp: 1727884626338
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxcursor-1.2.3-hb9d3cd8_0.conda
+ sha256: 832f538ade441b1eee863c8c91af9e69b356cd3e9e1350fff4fe36cc573fc91a
+ md5: 2ccd714aa2242315acaf0a67faea780b
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - xorg-libx11 >=1.8.10,<2.0a0
+ - xorg-libxfixes >=6.0.1,<7.0a0
+ - xorg-libxrender >=0.9.11,<0.10.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 32533
+ timestamp: 1730908305254
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxcursor-1.2.3-h86ecc28_0.conda
+ sha256: c5d3692520762322a9598e7448492309f5ee9d8f3aff72d787cf06e77c42507f
+ md5: f2054759c2203d12d0007005e1f1296d
+ depends:
+ - libgcc >=13
+ - xorg-libx11 >=1.8.9,<2.0a0
+ - xorg-libxfixes >=6.0.1,<7.0a0
+ - xorg-libxrender >=0.9.11,<0.10.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 34596
+ timestamp: 1730908388714
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdamage-1.1.6-hb9d3cd8_0.conda
+ sha256: 43b9772fd6582bf401846642c4635c47a9b0e36ca08116b3ec3df36ab96e0ec0
+ md5: b5fcc7172d22516e1f965490e65e33a4
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - xorg-libx11 >=1.8.10,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ - xorg-libxfixes >=6.0.1,<7.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 13217
+ timestamp: 1727891438799
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxdamage-1.1.6-h86ecc28_0.conda
+ sha256: 3afaa2f43eb4cb679fc0c3d9d7c50f0f2c80cc5d3df01d5d5fd60655d0bfa9be
+ md5: d5773c4e4d64428d7ddaa01f6f845dc7
+ depends:
+ - libgcc >=13
+ - xorg-libx11 >=1.8.9,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ - xorg-libxfixes >=6.0.1,<7.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 13794
+ timestamp: 1727891406431
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb9d3cd8_0.conda
+ sha256: 6b250f3e59db07c2514057944a3ea2044d6a8cdde8a47b6497c254520fade1ee
+ md5: 8035c64cb77ed555e3f150b7b3972480
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 19901
+ timestamp: 1727794976192
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxdmcp-1.1.5-h57736b2_0.conda
+ sha256: efcc150da5926cf244f757b8376d96a4db78bc15b8d90ca9f56ac6e75755971f
+ md5: 25a5a7b797fe6e084e04ffe2db02fc62
+ depends:
+ - libgcc >=13
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 20615
+ timestamp: 1727796660574
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.6-hb9d3cd8_0.conda
+ sha256: da5dc921c017c05f38a38bd75245017463104457b63a1ce633ed41f214159c14
+ md5: febbab7d15033c913d53c7a2c102309d
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - xorg-libx11 >=1.8.10,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 50060
+ timestamp: 1727752228921
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxext-1.3.6-h57736b2_0.conda
+ sha256: 8e216b024f52e367463b4173f237af97cf7053c77d9ce3e958bc62473a053f71
+ md5: bd1e86dd8aa3afd78a4bfdb4ef918165
+ depends:
+ - libgcc >=13
+ - xorg-libx11 >=1.8.9,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 50746
+ timestamp: 1727754268156
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.1-hb9d3cd8_0.conda
+ sha256: 2fef37e660985794617716eb915865ce157004a4d567ed35ec16514960ae9271
+ md5: 4bdb303603e9821baf5fe5fdff1dc8f8
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - xorg-libx11 >=1.8.10,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 19575
+ timestamp: 1727794961233
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxfixes-6.0.2-he30d5cf_0.conda
+ sha256: 8cb9c88e25c57e47419e98f04f9ef3154ad96b9f858c88c570c7b91216a64d0e
+ md5: e8b4056544341daf1d415eaeae7a040c
+ depends:
+ - libgcc >=14
+ - xorg-libx11 >=1.8.12,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 20704
+ timestamp: 1759284028146
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.2-hb9d3cd8_0.conda
+ sha256: 1a724b47d98d7880f26da40e45f01728e7638e6ec69f35a3e11f92acd05f9e7a
+ md5: 17dcc85db3c7886650b8908b183d6876
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - xorg-libx11 >=1.8.10,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ - xorg-libxfixes >=6.0.1,<7.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 47179
+ timestamp: 1727799254088
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxi-1.8.2-h57736b2_0.conda
+ sha256: 7b587407ecb9ccd2bbaf0fb94c5dbdde4d015346df063e9502dc0ce2b682fb5e
+ md5: eeee3bdb31c6acde2b81ad1b8c287087
+ depends:
+ - libgcc >=13
+ - xorg-libx11 >=1.8.9,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ - xorg-libxfixes >=6.0.1,<7.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 48197
+ timestamp: 1727801059062
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxinerama-1.1.5-h5888daf_1.conda
+ sha256: 1b9141c027f9d84a9ee5eb642a0c19457c788182a5a73c5a9083860ac5c20a8c
+ md5: 5e2eb9bf77394fc2e5918beefec9f9ab
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - libstdcxx >=13
+ - xorg-libx11 >=1.8.10,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 13891
+ timestamp: 1727908521531
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxinerama-1.1.5-h5ad3122_1.conda
+ sha256: 5f84f820397db504e187754665d48d385e0a2a49f07ffc2372c7f42fa36dd972
+ md5: a7b99f104e14b99ca773d2fe2d195585
+ depends:
+ - libgcc >=13
+ - libstdcxx >=13
+ - xorg-libx11 >=1.8.9,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 14388
+ timestamp: 1727908606602
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrandr-1.5.4-hb9d3cd8_0.conda
+ sha256: ac0f037e0791a620a69980914a77cb6bb40308e26db11698029d6708f5aa8e0d
+ md5: 2de7f99d6581a4a7adbff607b5c278ca
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - xorg-libx11 >=1.8.10,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ - xorg-libxrender >=0.9.11,<0.10.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 29599
+ timestamp: 1727794874300
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrandr-1.5.4-h86ecc28_0.conda
+ sha256: b2588a2b101d1b0a4e852532c8b9c92c59ef584fc762dd700567bdbf8cd00650
+ md5: dd3e74283a082381aa3860312e3c721e
+ depends:
+ - libgcc >=13
+ - xorg-libx11 >=1.8.9,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ - xorg-libxrender >=0.9.11,<0.10.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 30197
+ timestamp: 1727794957221
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxrender-0.9.12-hb9d3cd8_0.conda
+ sha256: 044c7b3153c224c6cedd4484dd91b389d2d7fd9c776ad0f4a34f099b3389f4a1
+ md5: 96d57aba173e878a2089d5638016dc5e
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - xorg-libx11 >=1.8.10,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 33005
+ timestamp: 1734229037766
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxrender-0.9.12-h86ecc28_0.conda
+ sha256: ffd77ee860c9635a28cfda46163dcfe9224dc6248c62404c544ae6b564a0be1f
+ md5: ae2c2dd0e2d38d249887727db2af960e
+ depends:
+ - libgcc >=13
+ - xorg-libx11 >=1.8.10,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 33649
+ timestamp: 1734229123157
+- conda: https://conda.anaconda.org/conda-forge/linux-64/xorg-libxtst-1.2.5-hb9d3cd8_3.conda
+ sha256: 752fdaac5d58ed863bbf685bb6f98092fe1a488ea8ebb7ed7b606ccfce08637a
+ md5: 7bbe9a0cc0df0ac5f5a8ad6d6a11af2f
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - xorg-libx11 >=1.8.10,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ - xorg-libxi >=1.7.10,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 32808
+ timestamp: 1727964811275
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxtst-1.2.5-h57736b2_3.conda
+ sha256: 6eaffce5a34fc0a16a21ddeaefb597e792a263b1b0c387c1ce46b0a967d558e1
+ md5: c05698071b5c8e0da82a282085845860
+ depends:
+ - libgcc >=13
+ - xorg-libx11 >=1.8.9,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ - xorg-libxi >=1.7.10,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 33786
+ timestamp: 1727964907993
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-libxxf86vm-1.1.6-h86ecc28_0.conda
+ sha256: 012f0d1fd9fb1d949e0dccc0b28d9dd5a8895a1f3e2a7edc1fa2e1b33fc0f233
+ md5: d745faa2d7c15092652e40a22bb261ed
+ depends:
+ - libgcc >=13
+ - xorg-libx11 >=1.8.10,<2.0a0
+ - xorg-libxext >=1.3.6,<2.0a0
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 18185
+ timestamp: 1734214652726
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/xorg-xorgproto-2024.1-h86ecc28_1.conda
+ sha256: 3dbbf4cdb5ad82d3479ab2aa68ae67de486a6d57d67f0402d8e55869f6f13aec
+ md5: 91cef7867bf2b47f614597b59705ff56
+ depends:
+ - libgcc >=13
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 566948
+ timestamp: 1726847598167
+- pypi: https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ name: yarl
+ version: 1.23.0
+ sha256: 34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4
+ requires_dist:
+ - idna>=2.0
+ - multidict>=4.0
+ - propcache>=0.2.1
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl
+ name: yarl
+ version: 1.23.0
+ sha256: 7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b
+ requires_dist:
+ - idna>=2.0
+ - multidict>=4.0
+ - propcache>=0.2.1
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl
+ name: yarl
+ version: 1.23.0
+ sha256: 2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035
+ requires_dist:
+ - idna>=2.0
+ - multidict>=4.0
+ - propcache>=0.2.1
+ requires_python: '>=3.10'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda
+ sha256: a4166e3d8ff4e35932510aaff7aa90772f84b4d07e9f6f83c614cba7ceefe0eb
+ md5: 6432cb5d4ac0046c3ac0a8a0f95842f9
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=13
+ - libstdcxx >=13
+ - libzlib >=1.3.1,<2.0a0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 567578
+ timestamp: 1742433379869
+- conda: https://conda.anaconda.org/conda-forge/linux-aarch64/zstd-1.5.7-hbcf94c1_2.conda
+ sha256: 0812e7b45f087cfdd288690ada718ce5e13e8263312e03b643dd7aa50d08b51b
+ md5: 5be90c5a3e4b43c53e38f50a85e11527
+ depends:
+ - libgcc >=13
+ - libstdcxx >=13
+ - libzlib >=1.3.1,<2.0a0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 551176
+ timestamp: 1742433378347
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-h6491c7d_2.conda
+ sha256: 0d02046f57f7a1a3feae3e9d1aa2113788311f3cf37a3244c71e61a93177ba67
+ md5: e6f69c7bcccdefa417f056fa593b40f0
+ depends:
+ - __osx >=11.0
+ - libzlib >=1.3.1,<2.0a0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ size: 399979
+ timestamp: 1742433432699
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 000000000..d5c361658
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,272 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "datajoint"
+dynamic = ["version"]
+dependencies = [
+ "numpy",
+ "pymysql>=0.7.2",
+ "deepdiff",
+ "pyparsing",
+ "pandas",
+ "tqdm",
+ "networkx",
+ "pydot",
+ "fsspec>=2023.1.0",
+ "pydantic-settings>=2.0.0",
+ "packaging",
+]
+
+requires-python = ">=3.10,<3.14"
+authors = [
+ {name = "Dimitri Yatsenko", email = "dimitri@datajoint.com"},
+ {name = "Thinh Nguyen", email = "thinh@datajoint.com"},
+ {name = "Raphael Guzman"},
+ {name = "Edgar Walker"},
+ {name = "DataJoint Contributors", email = "support@datajoint.com"},
+]
+maintainers = [
+ {name = "Dimitri Yatsenko", email = "dimitri@datajoint.com"},
+ {name = "DataJoint Contributors", email = "support@datajoint.com"},
+]
+# manually sync here: https://docs.datajoint.com/core/datajoint-python/latest/#welcome-to-datajoint-for-python
+description = "DataJoint for Python is a framework for scientific workflow management based on relational principles. DataJoint is built on the foundation of the relational data model and prescribes a consistent method for organizing, populating, computing, and querying data."
+readme = "README.md"
+license = {file = "LICENSE"}
+keywords = [
+ "datajoint",
+ "data-pipelines",
+ "workflow-management",
+ "data-engineering",
+ "scientific-computing",
+ "neuroscience",
+ "research-software",
+ "data-integrity",
+ "reproducibility",
+ "declarative",
+ "etl",
+ "object-storage",
+ "schema-management",
+ "data-lineage",
+ "relational-model",
+ "mysql",
+ "postgresql",
+]
+# https://pypi.org/classifiers/
+classifiers = [
+ "Programming Language :: Python",
+ "Development Status :: 5 - Production/Stable",
+ "Intended Audience :: Science/Research",
+ "Intended Audience :: Healthcare Industry",
+ "License :: OSI Approved :: Apache Software License",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+ "Topic :: Scientific/Engineering",
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
+]
+
+[project.urls]
+Homepage = "https://docs.datajoint.com/"
+Documentation = "https://docs.datajoint.com/"
+Repository = "https://github.com/datajoint/datajoint-python"
+"Bug Tracker" = "https://github.com/datajoint/datajoint-python/issues"
+"Release Notes" = "https://github.com/datajoint/datajoint-python/releases"
+
+[project.scripts]
+dj = "datajoint.cli:cli"
+datajoint = "datajoint.cli:cli"
+
+[dependency-groups]
+test = [
+ "pytest",
+ "pytest-cov",
+ "requests",
+ "faker",
+ "matplotlib",
+ "ipython",
+ "graphviz",
+ "testcontainers[mysql,minio,postgres]>=4.0",
+ "polars>=0.20.0",
+ "pyarrow>=14.0.0",
+]
+
+[project.optional-dependencies]
+s3 = ["s3fs>=2023.1.0"]
+gcs = ["gcsfs>=2023.1.0"]
+azure = ["adlfs>=2023.1.0"]
+postgres = ["psycopg2-binary>=2.9.0"]
+polars = ["polars>=0.20.0"]
+arrow = ["pyarrow>=14.0.0"]
+viz = ["matplotlib", "ipython"]
+test = [
+ "pytest",
+ "pytest-cov",
+ "requests",
+ "faker",
+ "matplotlib",
+ "ipython",
+ "s3fs>=2023.1.0",
+ "testcontainers[mysql,minio,postgres]>=4.0",
+ "psycopg2-binary>=2.9.0",
+ "polars>=0.20.0",
+ "pyarrow>=14.0.0",
+]
+dev = [
+ "pre-commit",
+ "ruff",
+ "codespell",
+ # including test
+ "pytest",
+ "pytest-cov",
+ "polars>=0.20.0",
+ "pyarrow>=14.0.0",
+]
+
+[tool.ruff]
+# Equivalent to flake8 configuration
+line-length = 127
+target-version = "py310"
+
+[tool.ruff.lint]
+# Enable specific rule sets equivalent to flake8 configuration
+select = [
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "F", # pyflakes
+ "C90", # mccabe complexity
+]
+
+# Ignore specific rules (equivalent to flake8 --ignore)
+ignore = [
+ "E203", # whitespace before ':'
+ "E722", # bare except
+]
+
+# Per-file ignores (equivalent to flake8 --per-file-ignores)
+[tool.ruff.lint.per-file-ignores]
+"datajoint/diagram.py" = ["C901"] # function too complex
+"tests/integration/test_blob_matlab.py" = ["E501"] # SQL hex strings cannot be broken across lines
+
+[tool.ruff.lint.mccabe]
+# Maximum complexity (equivalent to flake8 --max-complexity)
+max-complexity = 62
+
+[tool.ruff.format]
+# Use black-compatible formatting
+quote-style = "double"
+indent-style = "space"
+line-ending = "auto"
+
+[tool.mypy]
+python_version = "3.10"
+ignore_missing_imports = true
+# Start with lenient settings, gradually enable stricter checks
+warn_return_any = false
+warn_unused_ignores = false
+disallow_untyped_defs = false
+disallow_incomplete_defs = false
+check_untyped_defs = true
+
+# Modules with complete type coverage - strict checking enabled
+[[tool.mypy.overrides]]
+module = [
+ "datajoint.hash_registry",
+ "datajoint.errors",
+ "datajoint.hash",
+]
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+warn_return_any = true
+
+# Modules excluded from type checking until fully typed
+[[tool.mypy.overrides]]
+module = [
+ "datajoint.admin",
+ "datajoint.autopopulate",
+ "datajoint.blob",
+ "datajoint.builtin_codecs",
+ "datajoint.cli",
+ "datajoint.codecs",
+ "datajoint.condition",
+ "datajoint.connection",
+ "datajoint.declare",
+ "datajoint.dependencies",
+ "datajoint.diagram",
+ "datajoint.expression",
+ "datajoint.gc",
+ "datajoint.heading",
+ "datajoint.jobs",
+ "datajoint.lineage",
+ "datajoint.logging",
+ "datajoint.migrate",
+ "datajoint.objectref",
+ "datajoint.preview",
+ "datajoint.schemas",
+ "datajoint.settings",
+ "datajoint.staged_insert",
+ "datajoint.storage",
+ "datajoint.table",
+ "datajoint.user_tables",
+ "datajoint.utils",
+]
+ignore_errors = true
+
+[tool.hatch.version]
+path = "src/datajoint/version.py"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/datajoint"]
+
+[tool.codespell]
+skip = ".git,*.pdf,*.svg,*.csv,*.ipynb,*.drawio"
+# Rever -- nobody knows
+# numer -- numerator variable
+# astroid -- Python library name (not "asteroid")
+ignore-words-list = "rever,numer,astroid"
+
+[tool.pytest.ini_options]
+markers = [
+ "requires_mysql: marks tests as requiring MySQL database (deselect with '-m \"not requires_mysql\"')",
+ "requires_minio: marks tests as requiring MinIO object storage (deselect with '-m \"not requires_minio\"')",
+ "mysql: marks tests that run on MySQL backend (select with '-m mysql')",
+ "postgresql: marks tests that run on PostgreSQL backend (select with '-m postgresql')",
+ "backend_agnostic: marks tests that should pass on all backends (auto-marked for parameterized tests)",
+]
+
+
+
+[tool.pixi.workspace]
+channels = ["conda-forge"]
+platforms = ["linux-64", "osx-arm64", "linux-aarch64"]
+
+[tool.pixi.pypi-dependencies]
+datajoint = { path = ".", editable = true }
+
+[tool.pixi.feature.test.pypi-dependencies]
+datajoint = { path = ".", editable = true, extras = ["test"] }
+
+[tool.pixi.feature.dev.pypi-dependencies]
+datajoint = { path = ".", editable = true, extras = ["dev", "test"] }
+
+[tool.pixi.environments]
+default = { solve-group = "default" }
+dev = { features = ["dev"], solve-group = "default" }
+test = { features = ["test"], solve-group = "default" }
+
+[tool.pixi.tasks]
+# Tests use testcontainers - no manual setup required
+test = "pytest tests/"
+test-cov = "pytest --cov-report term-missing --cov=datajoint tests/"
+# Optional: use external containers (docker-compose) instead of testcontainers
+services-up = "docker compose up -d db minio"
+services-down = "docker compose down"
+test-external = { cmd = "DJ_USE_EXTERNAL_CONTAINERS=1 pytest tests/", depends-on = ["services-up"] }
+
+[tool.pixi.dependencies]
+python = ">=3.10,<3.14"
+graphviz = ">=13.1.2,<14"
+
+[tool.pixi.activation]
+scripts=["activate.sh"]
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 65c0c8b6f..000000000
--- a/requirements.txt
+++ /dev/null
@@ -1,13 +0,0 @@
-numpy
-pymysql>=0.7.2
-pyparsing
-ipython
-pandas
-tqdm
-networkx
-pydot
-minio>=7.0.0
-matplotlib
-cryptography
-otumat
-urllib3
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 1d3d5f8f9..000000000
--- a/setup.py
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/usr/bin/env python
-from setuptools import setup, find_packages
-from os import path
-import sys
-
-min_py_version = (3, 6)
-
-if sys.version_info < min_py_version:
- sys.exit('DataJoint is only supported for Python {}.{} or higher'.format(*min_py_version))
-
-here = path.abspath(path.dirname(__file__))
-
-long_description = "A relational data framework for scientific data pipelines with MySQL backend."
-
-# read in version number into __version__
-with open(path.join(here, 'datajoint', 'version.py')) as f:
- exec(f.read())
-
-with open(path.join(here, 'requirements.txt')) as f:
- requirements = f.read().split()
-
-setup(
- name='datajoint',
- version=__version__,
- description="A relational data pipeline framework.",
- long_description=long_description,
- author='Dimitri Yatsenko',
- author_email='info@datajoint.io',
- license="GNU LGPL",
- url='https://datajoint.io',
- keywords='database organization',
- packages=find_packages(exclude=['contrib', 'docs', 'tests*']),
- install_requires=requirements,
- python_requires='~={}.{}'.format(*min_py_version),
- setup_requires=['otumat'], # maybe remove due to conflicts?
- pubkey_path='./datajoint.pub'
-)
diff --git a/src/datajoint/__init__.py b/src/datajoint/__init__.py
new file mode 100644
index 000000000..2e242ce84
--- /dev/null
+++ b/src/datajoint/__init__.py
@@ -0,0 +1,306 @@
+"""
+DataJoint for Python — a framework for scientific data pipelines.
+
+DataJoint introduces the Relational Workflow Model, where your database schema
+is an executable specification of your workflow. Tables represent workflow steps,
+foreign keys encode dependencies, and computations are declarative.
+
+Documentation: https://docs.datajoint.com
+Source: https://github.com/datajoint/datajoint-python
+
+Copyright 2014-2026 DataJoint Inc. and contributors.
+Licensed under the Apache License, Version 2.0.
+
+If DataJoint contributes to a publication, please cite:
+https://doi.org/10.1101/031658
+"""
+
+__author__ = "DataJoint Contributors"
+__date__ = "November 7, 2020"
+__all__ = [
+ "__author__",
+ "__version__",
+ "config",
+ "conn",
+ "Connection",
+ "Instance",
+ "Schema",
+ "VirtualModule",
+ "virtual_schema",
+ "list_schemas",
+ "Table",
+ "FreeTable",
+ "AutoPopulate",
+ "Job",
+ "Manual",
+ "Lookup",
+ "Imported",
+ "Computed",
+ "Part",
+ "Not",
+ "AndList",
+ "Top",
+ "U",
+ "Diagram",
+ "MatCell",
+ "MatStruct",
+ # Codec API
+ "Codec",
+ "SchemaCodec",
+ "list_codecs",
+ "get_codec",
+ "ObjectRef",
+ "NpyRef",
+ # SparkAdapter Codec Protocol
+ "SparkAdapter",
+ # Storage Adapter API
+ "StorageAdapter",
+ "get_storage_adapter",
+ # Other
+ "errors",
+ "migrate",
+ "deploy",
+ "DataJointError",
+ "ThreadSafetyError",
+ "logger",
+ "cli",
+ "ValidationResult",
+]
+
+# =============================================================================
+# Eager imports — core functionality needed immediately
+# =============================================================================
+from . import errors
+from . import migrate
+from . import deploy
+from .codecs import (
+ Codec,
+ get_codec,
+ list_codecs,
+)
+from .builtin_codecs import (
+ SchemaCodec,
+ NpyRef,
+)
+from .blob import MatCell, MatStruct
+from .connection import Connection
+from .errors import DataJointError, ThreadSafetyError
+from .expression import AndList, Not, Top, U
+from .instance import Instance, _ConfigProxy, _get_singleton_connection, _global_config, _check_thread_safe
+from .logging import logger
+from .objectref import ObjectRef
+from .spark import SparkAdapter
+from .storage_adapter import StorageAdapter, get_storage_adapter
+from .schemas import _Schema, VirtualModule, list_schemas, virtual_schema
+from .autopopulate import AutoPopulate
+from .jobs import Job
+from .table import FreeTable as _FreeTable, Table, ValidationResult
+from .user_tables import Computed, Imported, Lookup, Manual, Part
+from .version import __version__
+
+# =============================================================================
+# Singleton-aware API
+# =============================================================================
+# config is a proxy that delegates to the singleton instance's config
+config = _ConfigProxy()
+
+
+def conn(
+ host: str | None = None,
+ user: str | None = None,
+ password: str | None = None,
+ *,
+ reset: bool = False,
+ use_tls: bool | dict | None = None,
+) -> Connection:
+ """
+ Return a persistent connection object.
+
+ When called without arguments, returns the singleton connection using
+ credentials from dj.config. When connection parameters are provided,
+ updates the singleton connection with the new credentials.
+
+ Parameters
+ ----------
+ host : str, optional
+ Database hostname. If provided, updates singleton.
+ user : str, optional
+ Database username. If provided, updates singleton.
+ password : str, optional
+ Database password. If provided, updates singleton.
+ reset : bool, optional
+ If True, reset existing connection. Default False.
+ use_tls : bool or dict, optional
+ TLS encryption option.
+
+ Returns
+ -------
+ Connection
+ Database connection.
+
+ Raises
+ ------
+ ThreadSafetyError
+ If thread_safe mode is enabled.
+ """
+ import datajoint.instance as instance_module
+ from pydantic import SecretStr
+
+ _check_thread_safe()
+
+ # If reset requested, always recreate
+ # If credentials provided and no singleton exists, create one
+ # If credentials provided and singleton exists, return existing singleton
+ if reset or (
+ instance_module._singleton_connection is None and (host is not None or user is not None or password is not None)
+ ):
+ # Use provided values or fall back to config
+ host = host if host is not None else _global_config.database.host
+ user = user if user is not None else _global_config.database.user
+ raw_password = password if password is not None else _global_config.database.password
+ password = raw_password.get_secret_value() if isinstance(raw_password, SecretStr) else raw_password
+ port = _global_config.database.port
+ use_tls = use_tls if use_tls is not None else _global_config.database.use_tls
+
+ if user is None:
+ from .errors import DataJointError
+
+ raise DataJointError("Database user not configured. Set dj.config['database.user'] or pass user= argument.")
+ if password is None:
+ from .errors import DataJointError
+
+ raise DataJointError(
+ "Database password not configured. Set dj.config['database.password'] or pass password= argument."
+ )
+
+ instance_module._singleton_connection = Connection(host, user, password, port, use_tls, config_override=_global_config)
+
+ return _get_singleton_connection()
+
+
+class Schema(_Schema):
+ """
+ Decorator that binds table classes to a database schema.
+
+ When connection is not provided, uses the singleton connection.
+ In thread-safe mode (``DJ_THREAD_SAFE=true``), a connection must be
+ provided explicitly or use ``dj.Instance().Schema()`` instead.
+
+ Parameters
+ ----------
+ schema_name : str, optional
+ Database schema name. If omitted, call ``activate()`` later.
+ context : dict, optional
+ Namespace for foreign key lookup. None uses caller's context.
+ connection : Connection, optional
+ Database connection. Defaults to singleton connection.
+ create_schema : bool, optional
+ If False, raise error if schema doesn't exist. Default True.
+ create_tables : bool, optional
+ If False, raise error when accessing missing tables.
+ add_objects : dict, optional
+ Additional objects for declaration context.
+
+ Raises
+ ------
+ ThreadSafetyError
+ If thread_safe mode is enabled and no connection is provided.
+
+ Examples
+ --------
+ >>> schema = dj.Schema('my_schema')
+ >>> @schema
+ ... class Session(dj.Manual):
+ ... definition = '''
+ ... session_id : int
+ ... '''
+ """
+
+ def __init__(
+ self,
+ schema_name: str | None = None,
+ context: dict | None = None,
+ *,
+ connection: Connection | None = None,
+ create_schema: bool = True,
+ create_tables: bool | None = None,
+ add_objects: dict | None = None,
+ ) -> None:
+ if connection is None:
+ _check_thread_safe()
+ super().__init__(
+ schema_name,
+ context=context,
+ connection=connection,
+ create_schema=create_schema,
+ create_tables=create_tables,
+ add_objects=add_objects,
+ )
+
+
+def FreeTable(conn_or_name, full_table_name: str | None = None) -> _FreeTable:
+ """
+ Create a FreeTable for accessing a table without a dedicated class.
+
+ Can be called in two ways:
+ - ``FreeTable("schema.table")`` - uses singleton connection
+ - ``FreeTable(connection, "schema.table")`` - uses provided connection
+
+ Parameters
+ ----------
+ conn_or_name : Connection or str
+ Either a Connection object, or the full table name if using singleton.
+ full_table_name : str, optional
+ Full table name when first argument is a connection.
+
+ Returns
+ -------
+ FreeTable
+ A FreeTable instance for the specified table.
+
+ Raises
+ ------
+ ThreadSafetyError
+ If thread_safe mode is enabled and using singleton.
+ """
+ if full_table_name is None:
+ # Called as FreeTable("db.table") - use singleton connection
+ _check_thread_safe()
+ return _FreeTable(_get_singleton_connection(), conn_or_name)
+ else:
+ # Called as FreeTable(conn, "db.table") - use provided connection
+ return _FreeTable(conn_or_name, full_table_name)
+
+
+# =============================================================================
+# Lazy imports — heavy dependencies loaded on first access
+# =============================================================================
+# These modules import heavy dependencies (networkx, matplotlib, click, pymysql)
+# that slow down `import datajoint`. They are loaded on demand.
+
+_lazy_modules = {
+ # Diagram imports networkx and matplotlib
+ "Diagram": (".diagram", "Diagram"),
+ "diagram": (".diagram", None), # Return the module itself
+ # cli imports click
+ "cli": (".cli", "cli"),
+ # gc — exposed lazily so `dj.gc.scan(...)` works as documented in gc.py
+ # and in the user docs (how-to/garbage-collection.md).
+ "gc": (".gc", None), # Return the module itself
+}
+
+
+def __getattr__(name: str):
+ """Lazy import for heavy dependencies."""
+ if name in _lazy_modules:
+ module_path, attr_name = _lazy_modules[name]
+ import importlib
+
+ module = importlib.import_module(module_path, __package__)
+ # If attr_name is None, return the module itself
+ attr = module if attr_name is None else getattr(module, attr_name)
+ # Cache in module __dict__ to avoid repeated __getattr__ calls
+ # and to override the submodule that importlib adds automatically
+ globals()[name] = attr
+ return attr
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/src/datajoint/adapters/__init__.py b/src/datajoint/adapters/__init__.py
new file mode 100644
index 000000000..5115a982a
--- /dev/null
+++ b/src/datajoint/adapters/__init__.py
@@ -0,0 +1,54 @@
+"""
+Database adapter registry for DataJoint.
+
+This module provides the adapter factory function and exports all adapters.
+"""
+
+from __future__ import annotations
+
+from .base import DatabaseAdapter
+from .mysql import MySQLAdapter
+from .postgres import PostgreSQLAdapter
+
+__all__ = ["DatabaseAdapter", "MySQLAdapter", "PostgreSQLAdapter", "get_adapter"]
+
+# Adapter registry mapping backend names to adapter classes
+ADAPTERS: dict[str, type[DatabaseAdapter]] = {
+ "mysql": MySQLAdapter,
+ "postgresql": PostgreSQLAdapter,
+ "postgres": PostgreSQLAdapter, # Alias for postgresql
+}
+
+
+def get_adapter(backend: str) -> DatabaseAdapter:
+ """
+ Get adapter instance for the specified database backend.
+
+ Parameters
+ ----------
+ backend : str
+ Backend name: 'mysql', 'postgresql', or 'postgres'.
+
+ Returns
+ -------
+ DatabaseAdapter
+ Adapter instance for the specified backend.
+
+ Raises
+ ------
+ ValueError
+ If the backend is not supported.
+
+ Examples
+ --------
+ >>> from datajoint.adapters import get_adapter
+ >>> mysql_adapter = get_adapter('mysql')
+ >>> postgres_adapter = get_adapter('postgresql')
+ """
+ backend_lower = backend.lower()
+
+ if backend_lower not in ADAPTERS:
+ supported = sorted(set(ADAPTERS.keys()))
+ raise ValueError(f"Unknown database backend: {backend}. " f"Supported backends: {', '.join(supported)}")
+
+ return ADAPTERS[backend_lower]()
diff --git a/src/datajoint/adapters/base.py b/src/datajoint/adapters/base.py
new file mode 100644
index 000000000..da4779543
--- /dev/null
+++ b/src/datajoint/adapters/base.py
@@ -0,0 +1,1309 @@
+"""
+Abstract base class for database backend adapters.
+
+This module defines the interface that all database adapters must implement
+to support multiple database backends (MySQL, PostgreSQL, etc.) in DataJoint.
+"""
+
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from typing import Any
+
+
+class DatabaseAdapter(ABC):
+ """
+ Abstract base class for database backend adapters.
+
+ Adapters provide database-specific implementations for SQL generation,
+ type mapping, error translation, and connection management.
+ """
+
+ # =========================================================================
+ # Connection Management
+ # =========================================================================
+
+ @abstractmethod
+ def connect(
+ self,
+ host: str,
+ port: int,
+ user: str,
+ password: str,
+ **kwargs: Any,
+ ) -> Any:
+ """
+ Establish database connection.
+
+ Parameters
+ ----------
+ host : str
+ Database server hostname.
+ port : int
+ Database server port.
+ user : str
+ Username for authentication.
+ password : str
+ Password for authentication.
+ **kwargs : Any
+ Additional backend-specific connection parameters.
+
+ Returns
+ -------
+ Any
+ Database connection object (backend-specific).
+ """
+ ...
+
+ @abstractmethod
+ def close(self, connection: Any) -> None:
+ """
+ Close the database connection.
+
+ Parameters
+ ----------
+ connection : Any
+ Database connection object to close.
+ """
+ ...
+
+ @abstractmethod
+ def ping(self, connection: Any) -> bool:
+ """
+ Check if connection is alive.
+
+ Parameters
+ ----------
+ connection : Any
+ Database connection object to check.
+
+ Returns
+ -------
+ bool
+ True if connection is alive, False otherwise.
+ """
+ ...
+
+ @abstractmethod
+ def get_connection_id(self, connection: Any) -> int:
+ """
+ Get the current connection/backend process ID.
+
+ Parameters
+ ----------
+ connection : Any
+ Database connection object.
+
+ Returns
+ -------
+ int
+ Connection or process ID.
+ """
+ ...
+
+ @property
+ @abstractmethod
+ def default_port(self) -> int:
+ """
+ Default port for this database backend.
+
+ Returns
+ -------
+ int
+ Default port number (3306 for MySQL, 5432 for PostgreSQL).
+ """
+ ...
+
+ @property
+ @abstractmethod
+ def backend(self) -> str:
+ """
+ Backend identifier string.
+
+ Returns
+ -------
+ str
+ Backend name: 'mysql' or 'postgresql'.
+ """
+ ...
+
+ @abstractmethod
+ def get_cursor(self, connection: Any, as_dict: bool = False) -> Any:
+ """
+ Get a cursor from the database connection.
+
+ Parameters
+ ----------
+ connection : Any
+ Database connection object.
+ as_dict : bool, optional
+ If True, return cursor that yields rows as dictionaries.
+ If False, return cursor that yields rows as tuples.
+ Default False.
+
+ Returns
+ -------
+ Any
+ Database cursor object (backend-specific).
+ """
+ ...
+
+ # =========================================================================
+ # SQL Syntax
+ # =========================================================================
+
+ @abstractmethod
+ def quote_identifier(self, name: str) -> str:
+ """
+ Quote an identifier (table/column name) for this backend.
+
+ Parameters
+ ----------
+ name : str
+ Identifier to quote.
+
+ Returns
+ -------
+ str
+ Quoted identifier (e.g., `name` for MySQL, "name" for PostgreSQL).
+ """
+ ...
+
+ @abstractmethod
+ def split_full_table_name(self, full_table_name: str) -> tuple[str, str]:
+ """
+ Split a fully-qualified table name into schema and table components.
+
+ Inverse of quoting: strips backend-specific identifier quotes
+ and splits into (schema, table).
+
+ Parameters
+ ----------
+ full_table_name : str
+ Quoted full table name (e.g., ```\\`schema\\`.\\`table\\` ``` or
+ ``"schema"."table"``).
+
+ Returns
+ -------
+ tuple[str, str]
+ (schema_name, table_name) with quotes stripped.
+ """
+ ...
+
+ @abstractmethod
+ def quote_string(self, value: str) -> str:
+ """
+ Quote a string literal for this backend.
+
+ Parameters
+ ----------
+ value : str
+ String value to quote.
+
+ Returns
+ -------
+ str
+ Quoted string literal with proper escaping.
+ """
+ ...
+
+ @abstractmethod
+ def get_master_table_name(self, part_table: str) -> str | None:
+ """
+ Extract master table name from a part table name.
+
+ Parameters
+ ----------
+ part_table : str
+ Full table name (e.g., `schema`.`master__part` for MySQL,
+ "schema"."master__part" for PostgreSQL).
+
+ Returns
+ -------
+ str or None
+ Master table name if part_table is a part table, None otherwise.
+ """
+ ...
+
+ @property
+ @abstractmethod
+ def parameter_placeholder(self) -> str:
+ """
+ Parameter placeholder style for this backend.
+
+ Returns
+ -------
+ str
+ Placeholder string (e.g., '%s' for MySQL/psycopg2, '?' for SQLite).
+ """
+ ...
+
+ def make_full_table_name(self, database: str, table_name: str) -> str:
+ """
+ Construct a fully-qualified table name for this backend.
+
+ Default implementation produces a two-part name (``schema.table``).
+ Backends that require additional namespace levels can override.
+
+ Parameters
+ ----------
+ database : str
+ Schema/database name.
+ table_name : str
+ Table name (including tier prefix).
+
+ Returns
+ -------
+ str
+ Fully-qualified, quoted table name.
+ """
+ return f"{self.quote_identifier(database)}.{self.quote_identifier(table_name)}"
+
+ @property
+ def max_table_name_length(self) -> int:
+ """
+ Maximum length of a table name for this backend.
+
+ Returns
+ -------
+ int
+ Maximum allowed characters in a table identifier.
+ """
+ return 64 # safe default (MySQL limit)
+
+ # =========================================================================
+ # Type Mapping
+ # =========================================================================
+
+ @abstractmethod
+ def core_type_to_sql(self, core_type: str) -> str:
+ """
+ Convert a DataJoint core type to backend SQL type.
+
+ Parameters
+ ----------
+ core_type : str
+ DataJoint core type (e.g., 'int64', 'float32', 'uuid').
+
+ Returns
+ -------
+ str
+ Backend SQL type (e.g., 'bigint', 'float', 'binary(16)').
+
+ Raises
+ ------
+ ValueError
+ If core_type is not a valid DataJoint core type.
+ """
+ ...
+
+ @abstractmethod
+ def sql_type_to_core(self, sql_type: str) -> str | None:
+ """
+ Convert a backend SQL type to DataJoint core type (if mappable).
+
+ Parameters
+ ----------
+ sql_type : str
+ Backend SQL type.
+
+ Returns
+ -------
+ str or None
+ DataJoint core type if mappable, None otherwise.
+ """
+ ...
+
+ # =========================================================================
+ # DDL Generation
+ # =========================================================================
+
+ @abstractmethod
+ def create_schema_sql(self, schema_name: str) -> str:
+ """
+ Generate CREATE SCHEMA/DATABASE statement.
+
+ Parameters
+ ----------
+ schema_name : str
+ Name of schema/database to create.
+
+ Returns
+ -------
+ str
+ CREATE SCHEMA/DATABASE SQL statement.
+ """
+ ...
+
+ @abstractmethod
+ def drop_schema_sql(self, schema_name: str, if_exists: bool = True) -> str:
+ """
+ Generate DROP SCHEMA/DATABASE statement.
+
+ Parameters
+ ----------
+ schema_name : str
+ Name of schema/database to drop.
+ if_exists : bool, optional
+ Include IF EXISTS clause. Default True.
+
+ Returns
+ -------
+ str
+ DROP SCHEMA/DATABASE SQL statement.
+ """
+ ...
+
+ @abstractmethod
+ def create_table_sql(
+ self,
+ table_name: str,
+ columns: list[dict[str, Any]],
+ primary_key: list[str],
+ foreign_keys: list[dict[str, Any]],
+ indexes: list[dict[str, Any]],
+ comment: str | None = None,
+ ) -> str:
+ """
+ Generate CREATE TABLE statement.
+
+ Parameters
+ ----------
+ table_name : str
+ Name of table to create.
+ columns : list[dict]
+ Column definitions with keys: name, type, nullable, default, comment.
+ primary_key : list[str]
+ List of primary key column names.
+ foreign_keys : list[dict]
+ Foreign key definitions with keys: columns, ref_table, ref_columns.
+ indexes : list[dict]
+ Index definitions with keys: columns, unique.
+ comment : str, optional
+ Table comment.
+
+ Returns
+ -------
+ str
+ CREATE TABLE SQL statement.
+ """
+ ...
+
+ @abstractmethod
+ def drop_table_sql(self, table_name: str, if_exists: bool = True) -> str:
+ """
+ Generate DROP TABLE statement.
+
+ Parameters
+ ----------
+ table_name : str
+ Name of table to drop.
+ if_exists : bool, optional
+ Include IF EXISTS clause. Default True.
+
+ Returns
+ -------
+ str
+ DROP TABLE SQL statement.
+ """
+ ...
+
+ @abstractmethod
+ def alter_table_sql(
+ self,
+ table_name: str,
+ add_columns: list[dict[str, Any]] | None = None,
+ drop_columns: list[str] | None = None,
+ modify_columns: list[dict[str, Any]] | None = None,
+ ) -> str:
+ """
+ Generate ALTER TABLE statement.
+
+ Parameters
+ ----------
+ table_name : str
+ Name of table to alter.
+ add_columns : list[dict], optional
+ Columns to add with keys: name, type, nullable, default, comment.
+ drop_columns : list[str], optional
+ Column names to drop.
+ modify_columns : list[dict], optional
+ Columns to modify with keys: name, type, nullable, default, comment.
+
+ Returns
+ -------
+ str
+ ALTER TABLE SQL statement.
+ """
+ ...
+
+ @abstractmethod
+ def add_comment_sql(
+ self,
+ object_type: str,
+ object_name: str,
+ comment: str,
+ ) -> str | None:
+ """
+ Generate comment statement (may be None if embedded in CREATE).
+
+ Parameters
+ ----------
+ object_type : str
+ Type of object ('table', 'column').
+ object_name : str
+ Fully qualified object name.
+ comment : str
+ Comment text.
+
+ Returns
+ -------
+ str or None
+ COMMENT statement, or None if comments are inline in CREATE.
+ """
+ ...
+
+ # =========================================================================
+ # DML Generation
+ # =========================================================================
+
+ @abstractmethod
+ def insert_sql(
+ self,
+ table_name: str,
+ columns: list[str],
+ on_duplicate: str | None = None,
+ ) -> str:
+ """
+ Generate INSERT statement.
+
+ Parameters
+ ----------
+ table_name : str
+ Name of table to insert into.
+ columns : list[str]
+ Column names to insert.
+ on_duplicate : str, optional
+ Duplicate handling: 'ignore', 'replace', 'update', or None.
+
+ Returns
+ -------
+ str
+ INSERT SQL statement with parameter placeholders.
+ """
+ ...
+
+ @abstractmethod
+ def update_sql(
+ self,
+ table_name: str,
+ set_columns: list[str],
+ where_columns: list[str],
+ ) -> str:
+ """
+ Generate UPDATE statement.
+
+ Parameters
+ ----------
+ table_name : str
+ Name of table to update.
+ set_columns : list[str]
+ Column names to set.
+ where_columns : list[str]
+ Column names for WHERE clause.
+
+ Returns
+ -------
+ str
+ UPDATE SQL statement with parameter placeholders.
+ """
+ ...
+
+ @abstractmethod
+ def delete_sql(self, table_name: str) -> str:
+ """
+ Generate DELETE statement (WHERE clause added separately).
+
+ Parameters
+ ----------
+ table_name : str
+ Name of table to delete from.
+
+ Returns
+ -------
+ str
+ DELETE SQL statement without WHERE clause.
+ """
+ ...
+
+ @abstractmethod
+ def upsert_on_duplicate_sql(
+ self,
+ table_name: str,
+ columns: list[str],
+ primary_key: list[str],
+ num_rows: int,
+ ) -> str:
+ """
+ Generate INSERT ... ON DUPLICATE KEY UPDATE (MySQL) or
+ INSERT ... ON CONFLICT ... DO UPDATE (PostgreSQL) statement.
+
+ Parameters
+ ----------
+ table_name : str
+ Fully qualified table name (with quotes).
+ columns : list[str]
+ Column names to insert (unquoted).
+ primary_key : list[str]
+ Primary key column names (unquoted) for conflict detection.
+ num_rows : int
+ Number of rows to insert (for generating placeholders).
+
+ Returns
+ -------
+ str
+ Upsert SQL statement with placeholders.
+
+ Examples
+ --------
+ MySQL:
+ INSERT INTO `table` (a, b, c) VALUES (%s, %s, %s), (%s, %s, %s)
+ ON DUPLICATE KEY UPDATE a = VALUES(a), b = VALUES(b), c = VALUES(c)
+
+ PostgreSQL:
+ INSERT INTO "table" (a, b, c) VALUES (%s, %s, %s), (%s, %s, %s)
+ ON CONFLICT (a) DO UPDATE SET b = EXCLUDED.b, c = EXCLUDED.c
+ """
+ ...
+
+ @abstractmethod
+ def skip_duplicates_clause(
+ self,
+ full_table_name: str,
+ primary_key: list[str],
+ ) -> str:
+ """
+ Generate clause to skip duplicate key insertions.
+
+ For MySQL: ON DUPLICATE KEY UPDATE pk=table.pk (no-op update)
+ For PostgreSQL: ON CONFLICT (pk_cols) DO NOTHING
+
+ Parameters
+ ----------
+ full_table_name : str
+ Fully qualified table name (with quotes).
+ primary_key : list[str]
+ Primary key column names (unquoted).
+
+ Returns
+ -------
+ str
+ SQL clause to append to INSERT statement.
+ """
+ ...
+
+ @property
+ def supports_inline_indexes(self) -> bool:
+ """
+ Whether this backend supports inline INDEX in CREATE TABLE.
+
+ MySQL supports inline index definitions in CREATE TABLE.
+ PostgreSQL requires separate CREATE INDEX statements.
+
+ Returns
+ -------
+ bool
+ True for MySQL, False for PostgreSQL.
+ """
+ return True # Default for MySQL, override in PostgreSQL
+
+ def create_index_ddl(
+ self,
+ full_table_name: str,
+ columns: list[str],
+ unique: bool = False,
+ index_name: str | None = None,
+ ) -> str:
+ """
+ Generate CREATE INDEX statement.
+
+ Parameters
+ ----------
+ full_table_name : str
+ Fully qualified table name (with quotes).
+ columns : list[str]
+ Column names to index (unquoted).
+ unique : bool, optional
+ If True, create a unique index.
+ index_name : str, optional
+ Custom index name. If None, auto-generate from table/columns.
+
+ Returns
+ -------
+ str
+ CREATE INDEX SQL statement.
+ """
+ quoted_cols = ", ".join(self.quote_identifier(col) for col in columns)
+ # Generate index name from table and columns if not provided
+ if index_name is None:
+ # Extract table name from full_table_name for index naming
+ _, table_part = self.split_full_table_name(full_table_name)
+ col_part = "_".join(columns)[:30] # Truncate for long column lists
+ index_name = f"idx_{table_part}_{col_part}"
+ unique_clause = "UNIQUE " if unique else ""
+ return f"CREATE {unique_clause}INDEX {self.quote_identifier(index_name)} ON {full_table_name} ({quoted_cols})"
+
+ # =========================================================================
+ # Introspection
+ # =========================================================================
+
+ @abstractmethod
+ def list_schemas_sql(self) -> str:
+ """
+ Generate query to list all schemas/databases.
+
+ Returns
+ -------
+ str
+ SQL query to list schemas.
+ """
+ ...
+
+ @abstractmethod
+ def schema_exists_sql(self, schema_name: str) -> str:
+ """
+ Generate query to check if a schema exists.
+
+ Parameters
+ ----------
+ schema_name : str
+ Name of schema to check.
+
+ Returns
+ -------
+ str
+ SQL query that returns a row if the schema exists.
+ """
+ ...
+
+ @abstractmethod
+ def list_tables_sql(self, schema_name: str, pattern: str | None = None) -> str:
+ """
+ Generate query to list tables in a schema.
+
+ Parameters
+ ----------
+ schema_name : str
+ Name of schema to list tables from.
+ pattern : str, optional
+ LIKE pattern to filter table names. Use %% for % in SQL.
+
+ Returns
+ -------
+ str
+ SQL query to list tables.
+ """
+ ...
+
+ @abstractmethod
+ def get_table_info_sql(self, schema_name: str, table_name: str) -> str:
+ """
+ Generate query to get table metadata (comment, engine, etc.).
+
+ Parameters
+ ----------
+ schema_name : str
+ Schema name.
+ table_name : str
+ Table name.
+
+ Returns
+ -------
+ str
+ SQL query to get table info.
+ """
+ ...
+
+ @abstractmethod
+ def get_columns_sql(self, schema_name: str, table_name: str) -> str:
+ """
+ Generate query to get column definitions.
+
+ Parameters
+ ----------
+ schema_name : str
+ Schema name.
+ table_name : str
+ Table name.
+
+ Returns
+ -------
+ str
+ SQL query to get column definitions.
+ """
+ ...
+
+ @abstractmethod
+ def get_primary_key_sql(self, schema_name: str, table_name: str) -> str:
+ """
+ Generate query to get primary key columns.
+
+ Parameters
+ ----------
+ schema_name : str
+ Schema name.
+ table_name : str
+ Table name.
+
+ Returns
+ -------
+ str
+ SQL query to get primary key columns.
+ """
+ ...
+
+ @abstractmethod
+ def get_foreign_keys_sql(self, schema_name: str, table_name: str) -> str:
+ """
+ Generate query to get foreign key constraints.
+
+ Parameters
+ ----------
+ schema_name : str
+ Schema name.
+ table_name : str
+ Table name.
+
+ Returns
+ -------
+ str
+ SQL query to get foreign key constraints.
+ """
+ ...
+
+ @abstractmethod
+ def load_primary_keys_sql(self, schemas_list: str, like_pattern: str) -> str:
+ """
+ Generate query to load primary key columns for all tables across schemas.
+
+ Used by the dependency graph to build the schema graph.
+
+ Parameters
+ ----------
+ schemas_list : str
+ Comma-separated, quoted schema names for an IN clause.
+ like_pattern : str
+ SQL LIKE pattern to exclude (e.g., "'~%%'" for internal tables).
+
+ Returns
+ -------
+ str
+ SQL query returning rows with columns:
+ - tab: fully qualified table name (quoted)
+ - column_name: primary key column name
+ """
+ ...
+
+ @abstractmethod
+ def load_foreign_keys_sql(self, schemas_list: str, like_pattern: str) -> str:
+ """
+ Generate query to load foreign key relationships across schemas.
+
+ Used by the dependency graph to build the schema graph.
+
+ Parameters
+ ----------
+ schemas_list : str
+ Comma-separated, quoted schema names for an IN clause.
+ like_pattern : str
+ SQL LIKE pattern to exclude (e.g., "'~%%'" for internal tables).
+
+ Returns
+ -------
+ str
+ SQL query returning rows (as dicts) with columns:
+ - constraint_name: FK constraint name
+ - referencing_table: fully qualified child table name (quoted)
+ - referenced_table: fully qualified parent table name (quoted)
+ - column_name: FK column in child table
+ - referenced_column_name: referenced column in parent table
+ """
+ ...
+
+ def find_downstream_schemas_sql(self, schemas_list: str) -> str:
+ """
+ Generate query to find schemas with FK references to the given schemas.
+
+ Used to discover unloaded schemas that depend on loaded ones.
+
+ Parameters
+ ----------
+ schemas_list : str
+ Comma-separated, quoted schema names for an IN clause.
+
+ Returns
+ -------
+ str
+ SQL query returning rows with a single column ``schema_name``
+ containing distinct schema names that reference the given schemas.
+ """
+ raise NotImplementedError
+ ...
+
+ @abstractmethod
+ def get_constraint_info_sql(self, constraint_name: str, schema_name: str, table_name: str) -> str:
+ """
+ Generate query to get foreign key constraint details from information_schema.
+
+ Used during cascade delete to determine FK columns when error message
+ doesn't provide full details.
+
+ Parameters
+ ----------
+ constraint_name : str
+ Name of the foreign key constraint.
+ schema_name : str
+ Schema/database name of the child table.
+ table_name : str
+ Name of the child table.
+
+ Returns
+ -------
+ str
+ SQL query that returns rows with columns:
+ - fk_attrs: foreign key column name in child table
+ - parent: parent table name (quoted, with schema)
+ - pk_attrs: referenced column name in parent table
+ """
+ ...
+
+ @abstractmethod
+ def parse_foreign_key_error(self, error_message: str) -> dict[str, str | list[str] | None] | None:
+ """
+ Parse a foreign key violation error message to extract constraint details.
+
+ Used during cascade delete to identify which child table is preventing
+ deletion and what columns are involved.
+
+ Parameters
+ ----------
+ error_message : str
+ The error message from a foreign key constraint violation.
+
+ Returns
+ -------
+ dict or None
+ Dictionary with keys if successfully parsed:
+ - child: child table name (quoted with schema if available)
+ - name: constraint name (quoted)
+ - fk_attrs: list of foreign key column names (may be None if not in message)
+ - parent: parent table name (quoted, may be None if not in message)
+ - pk_attrs: list of parent key column names (may be None if not in message)
+
+ Returns None if error message doesn't match FK violation pattern.
+
+ Examples
+ --------
+ MySQL error:
+ "Cannot delete or update a parent row: a foreign key constraint fails
+ (`schema`.`child`, CONSTRAINT `fk_name` FOREIGN KEY (`child_col`)
+ REFERENCES `parent` (`parent_col`))"
+
+ PostgreSQL error:
+ "update or delete on table \"parent\" violates foreign key constraint
+ \"child_parent_id_fkey\" on table \"child\"
+ DETAIL: Key (parent_id)=(1) is still referenced from table \"child\"."
+ """
+ ...
+
+ @abstractmethod
+ def get_indexes_sql(self, schema_name: str, table_name: str) -> str:
+ """
+ Generate query to get index definitions.
+
+ Parameters
+ ----------
+ schema_name : str
+ Schema name.
+ table_name : str
+ Table name.
+
+ Returns
+ -------
+ str
+ SQL query to get index definitions.
+ """
+ ...
+
+ @abstractmethod
+ def parse_column_info(self, row: dict[str, Any]) -> dict[str, Any]:
+ """
+ Parse a column info row into standardized format.
+
+ Parameters
+ ----------
+ row : dict
+ Raw column info row from database introspection query.
+
+ Returns
+ -------
+ dict
+ Standardized column info with keys: name, type, nullable,
+ default, comment, etc.
+ """
+ ...
+
+ # =========================================================================
+ # Transactions
+ # =========================================================================
+
+ @abstractmethod
+ def start_transaction_sql(self, isolation_level: str | None = None) -> str:
+ """
+ Generate START TRANSACTION statement.
+
+ Parameters
+ ----------
+ isolation_level : str, optional
+ Transaction isolation level.
+
+ Returns
+ -------
+ str
+ START TRANSACTION SQL statement.
+ """
+ ...
+
+ @abstractmethod
+ def commit_sql(self) -> str:
+ """
+ Generate COMMIT statement.
+
+ Returns
+ -------
+ str
+ COMMIT SQL statement.
+ """
+ ...
+
+ @abstractmethod
+ def rollback_sql(self) -> str:
+ """
+ Generate ROLLBACK statement.
+
+ Returns
+ -------
+ str
+ ROLLBACK SQL statement.
+ """
+ ...
+
+ # =========================================================================
+ # Functions and Expressions
+ # =========================================================================
+
+ @abstractmethod
+ def current_timestamp_expr(self, precision: int | None = None) -> str:
+ """
+ Expression for current timestamp.
+
+ Parameters
+ ----------
+ precision : int, optional
+ Fractional seconds precision (0-6).
+
+ Returns
+ -------
+ str
+ SQL expression for current timestamp.
+ """
+ ...
+
+ @abstractmethod
+ def interval_expr(self, value: int, unit: str) -> str:
+ """
+ Expression for time interval.
+
+ Parameters
+ ----------
+ value : int
+ Interval value.
+ unit : str
+ Time unit ('second', 'minute', 'hour', 'day', etc.).
+
+ Returns
+ -------
+ str
+ SQL expression for interval (e.g., 'INTERVAL 5 SECOND' for MySQL,
+ "INTERVAL '5 seconds'" for PostgreSQL).
+ """
+ ...
+
+ @abstractmethod
+ def current_user_expr(self) -> str:
+ """
+ SQL expression to get the current user.
+
+ Returns
+ -------
+ str
+ SQL expression for current user (e.g., 'user()' for MySQL,
+ 'current_user' for PostgreSQL).
+ """
+ ...
+
+ @abstractmethod
+ def json_path_expr(self, column: str, path: str, return_type: str | None = None) -> str:
+ """
+ Generate JSON path extraction expression.
+
+ Parameters
+ ----------
+ column : str
+ Column name containing JSON data.
+ path : str
+ JSON path (e.g., 'field' or 'nested.field').
+ return_type : str, optional
+ Return type specification (MySQL-specific).
+
+ Returns
+ -------
+ str
+ Database-specific JSON extraction SQL expression.
+
+ Examples
+ --------
+ MySQL: json_value(`column`, _utf8mb4'$.path' returning type)
+ PostgreSQL: jsonb_extract_path_text("column", 'path_part1', 'path_part2')
+ """
+ ...
+
+ def translate_expression(self, expr: str) -> str:
+ """
+ Translate SQL expression for backend compatibility.
+
+ Converts database-specific function calls to the equivalent syntax
+ for the current backend. This enables portable DataJoint code that
+ uses common aggregate functions.
+
+ Translations performed:
+ - GROUP_CONCAT(col) ↔ STRING_AGG(col, ',')
+
+ Parameters
+ ----------
+ expr : str
+ SQL expression that may contain function calls.
+
+ Returns
+ -------
+ str
+ Translated expression for the current backend.
+
+ Notes
+ -----
+ The base implementation returns the expression unchanged.
+ Subclasses override to provide backend-specific translations.
+ """
+ return expr
+
+ # =========================================================================
+ # DDL Generation
+ # =========================================================================
+
+ @abstractmethod
+ def format_column_definition(
+ self,
+ name: str,
+ sql_type: str,
+ nullable: bool = False,
+ default: str | None = None,
+ comment: str | None = None,
+ ) -> str:
+ """
+ Format a column definition for DDL.
+
+ Parameters
+ ----------
+ name : str
+ Column name.
+ sql_type : str
+ SQL type (already backend-specific, e.g., 'bigint', 'varchar(255)').
+ nullable : bool, optional
+ Whether column is nullable. Default False.
+ default : str | None, optional
+ Default value expression (e.g., 'NULL', '"value"', 'CURRENT_TIMESTAMP').
+ comment : str | None, optional
+ Column comment.
+
+ Returns
+ -------
+ str
+ Formatted column definition (without trailing comma).
+
+ Examples
+ --------
+ MySQL: `name` bigint NOT NULL COMMENT "user ID"
+ PostgreSQL: "name" bigint NOT NULL
+ """
+ ...
+
+ @abstractmethod
+ def table_options_clause(self, comment: str | None = None) -> str:
+ """
+ Generate table options clause (ENGINE, etc.) for CREATE TABLE.
+
+ Parameters
+ ----------
+ comment : str | None, optional
+ Table-level comment.
+
+ Returns
+ -------
+ str
+ Table options clause (e.g., 'ENGINE=InnoDB, COMMENT "..."' for MySQL).
+
+ Examples
+ --------
+ MySQL: ENGINE=InnoDB, COMMENT "experiment sessions"
+ PostgreSQL: (empty string, comments handled separately)
+ """
+ ...
+
+ @abstractmethod
+ def table_comment_ddl(self, full_table_name: str, comment: str) -> str | None:
+ """
+ Generate DDL for table-level comment (if separate from CREATE TABLE).
+
+ Parameters
+ ----------
+ full_table_name : str
+ Fully qualified table name (quoted).
+ comment : str
+ Table comment.
+
+ Returns
+ -------
+ str or None
+ DDL statement for table comment, or None if handled inline.
+
+ Examples
+ --------
+ MySQL: None (inline)
+ PostgreSQL: COMMENT ON TABLE "schema"."table" IS 'comment text'
+ """
+ ...
+
+ @abstractmethod
+ def column_comment_ddl(self, full_table_name: str, column_name: str, comment: str) -> str | None:
+ """
+ Generate DDL for column-level comment (if separate from CREATE TABLE).
+
+ Parameters
+ ----------
+ full_table_name : str
+ Fully qualified table name (quoted).
+ column_name : str
+ Column name (unquoted).
+ comment : str
+ Column comment.
+
+ Returns
+ -------
+ str or None
+ DDL statement for column comment, or None if handled inline.
+
+ Examples
+ --------
+ MySQL: None (inline)
+ PostgreSQL: COMMENT ON COLUMN "schema"."table"."column" IS 'comment text'
+ """
+ ...
+
+ @abstractmethod
+ def enum_type_ddl(self, type_name: str, values: list[str]) -> str | None:
+ """
+ Generate DDL for enum type definition (if needed before CREATE TABLE).
+
+ Parameters
+ ----------
+ type_name : str
+ Enum type name.
+ values : list[str]
+ Enum values.
+
+ Returns
+ -------
+ str or None
+ DDL statement for enum type, or None if handled inline.
+
+ Examples
+ --------
+ MySQL: None (inline enum('val1', 'val2'))
+ PostgreSQL: CREATE TYPE "type_name" AS ENUM ('val1', 'val2')
+ """
+ ...
+
+ @abstractmethod
+ def job_metadata_columns(self) -> list[str]:
+ """
+ Return job metadata column definitions for Computed/Imported tables.
+
+ Returns
+ -------
+ list[str]
+ List of column definition strings (fully formatted with quotes).
+
+ Examples
+ --------
+ MySQL:
+ ["`_job_start_time` datetime(3) DEFAULT NULL",
+ "`_job_duration` float DEFAULT NULL",
+ "`_job_version` varchar(64) DEFAULT ''"]
+ PostgreSQL:
+ ['"_job_start_time" timestamp DEFAULT NULL',
+ '"_job_duration" real DEFAULT NULL',
+ '"_job_version" varchar(64) DEFAULT \'\'']
+ """
+ ...
+
+ # =========================================================================
+ # Error Translation
+ # =========================================================================
+
+ @abstractmethod
+ def translate_error(self, error: Exception, query: str = "") -> Exception:
+ """
+ Translate backend-specific error to DataJoint error.
+
+ Parameters
+ ----------
+ error : Exception
+ Backend-specific exception.
+
+ Returns
+ -------
+ Exception
+ DataJoint exception or original error if no mapping exists.
+ """
+ ...
+
+ # =========================================================================
+ # Native Type Validation
+ # =========================================================================
+
+ @abstractmethod
+ def validate_native_type(self, type_str: str) -> bool:
+ """
+ Check if a native type string is valid for this backend.
+
+ Parameters
+ ----------
+ type_str : str
+ Native type string to validate.
+
+ Returns
+ -------
+ bool
+ True if valid for this backend, False otherwise.
+ """
+ ...
diff --git a/src/datajoint/adapters/mysql.py b/src/datajoint/adapters/mysql.py
new file mode 100644
index 000000000..f035ba87f
--- /dev/null
+++ b/src/datajoint/adapters/mysql.py
@@ -0,0 +1,1131 @@
+"""
+MySQL database adapter for DataJoint.
+
+This module provides MySQL-specific implementations for SQL generation,
+type mapping, error translation, and connection management.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pymysql as client
+
+from .. import errors
+from .base import DatabaseAdapter
+
+# Core type mapping: DataJoint core types → MySQL types
+CORE_TYPE_MAP = {
+ "int64": "bigint",
+ "int32": "int",
+ "int16": "smallint",
+ "int8": "tinyint",
+ "float32": "float",
+ "float64": "double",
+ "bool": "tinyint",
+ "uuid": "binary(16)",
+ "bytes": "longblob",
+ "json": "json",
+ "date": "date",
+ # datetime, char, varchar, decimal, enum require parameters - handled in method
+}
+
+# Reverse mapping: MySQL types → DataJoint core types (for introspection)
+SQL_TO_CORE_MAP = {
+ "bigint": "int64",
+ "int": "int32",
+ "smallint": "int16",
+ "tinyint": "int8", # Could be bool, need context
+ "float": "float32",
+ "double": "float64",
+ "binary(16)": "uuid",
+ "longblob": "bytes",
+ "json": "json",
+ "date": "date",
+}
+
+
+class MySQLAdapter(DatabaseAdapter):
+ """MySQL database adapter implementation."""
+
+ # =========================================================================
+ # Connection Management
+ # =========================================================================
+
+ def connect(
+ self,
+ host: str,
+ port: int,
+ user: str,
+ password: str,
+ **kwargs: Any,
+ ) -> Any:
+ """
+ Establish MySQL connection.
+
+ Parameters
+ ----------
+ host : str
+ MySQL server hostname.
+ port : int
+ MySQL server port.
+ user : str
+ Username for authentication.
+ password : str
+ Password for authentication.
+ **kwargs : Any
+ Additional MySQL-specific parameters:
+ - ssl: TLS/SSL configuration dict (deprecated, use use_tls)
+ - use_tls: bool or dict - DataJoint's SSL parameter (preferred)
+ - charset: Character set (default from kwargs)
+
+ Returns
+ -------
+ pymysql.Connection
+ MySQL connection object.
+ """
+ # Handle both ssl (old) and use_tls (new) parameter names
+ ssl_config = kwargs.get("use_tls", kwargs.get("ssl"))
+ # Convert boolean True to dict for PyMySQL (PyMySQL expects dict or SSLContext)
+ if ssl_config is True:
+ ssl_config = {} # Enable SSL with default settings
+ charset = kwargs.get("charset", "")
+
+ # Prepare connection parameters
+ conn_params = {
+ "host": host,
+ "port": port,
+ "user": user,
+ "passwd": password,
+ "sql_mode": "NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,"
+ "STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION,ONLY_FULL_GROUP_BY",
+ "charset": charset,
+ "autocommit": True, # DataJoint manages transactions explicitly
+ }
+
+ # Handle SSL configuration
+ if ssl_config is False:
+ # Explicitly disable SSL
+ conn_params["ssl_disabled"] = True
+ elif ssl_config is not None:
+ # Enable SSL with config dict (can be empty for defaults)
+ conn_params["ssl"] = ssl_config
+ # Explicitly enable SSL by setting ssl_disabled=False
+ conn_params["ssl_disabled"] = False
+
+ return client.connect(**conn_params)
+
+ def close(self, connection: Any) -> None:
+ """Close the MySQL connection."""
+ connection.close()
+
+ def ping(self, connection: Any) -> bool:
+ """
+ Check if MySQL connection is alive.
+
+ Returns
+ -------
+ bool
+ True if connection is alive.
+ """
+ try:
+ connection.ping(reconnect=False)
+ return True
+ except Exception:
+ return False
+
+ def get_connection_id(self, connection: Any) -> int:
+ """
+ Get MySQL connection ID.
+
+ Returns
+ -------
+ int
+ MySQL connection_id().
+ """
+ cursor = connection.cursor()
+ cursor.execute("SELECT connection_id()")
+ return cursor.fetchone()[0]
+
+ @property
+ def default_port(self) -> int:
+ """MySQL default port 3306."""
+ return 3306
+
+ @property
+ def backend(self) -> str:
+ """Backend identifier: 'mysql'."""
+ return "mysql"
+
+ def get_cursor(self, connection: Any, as_dict: bool = False) -> Any:
+ """
+ Get a cursor from MySQL connection.
+
+ Parameters
+ ----------
+ connection : Any
+ pymysql connection object.
+ as_dict : bool, optional
+ If True, return DictCursor that yields rows as dictionaries.
+ If False, return standard Cursor that yields rows as tuples.
+ Default False.
+
+ Returns
+ -------
+ Any
+ pymysql cursor object.
+ """
+ import pymysql
+
+ cursor_class = pymysql.cursors.DictCursor if as_dict else pymysql.cursors.Cursor
+ return connection.cursor(cursor=cursor_class)
+
+ # =========================================================================
+ # SQL Syntax
+ # =========================================================================
+
+ def quote_identifier(self, name: str) -> str:
+ """
+ Quote identifier with backticks for MySQL.
+
+ Parameters
+ ----------
+ name : str
+ Identifier to quote.
+
+ Returns
+ -------
+ str
+ Backtick-quoted identifier: `name`
+ """
+ return f"`{name}`"
+
+ def split_full_table_name(self, full_table_name: str) -> tuple[str, str]:
+ """Split ```\\`schema\\`.\\`table\\` ``` into ``('schema', 'table')``."""
+ schema, table = full_table_name.replace("`", "").split(".")
+ return schema, table
+
+ def quote_string(self, value: str) -> str:
+ """
+ Quote string literal for MySQL with escaping.
+
+ Parameters
+ ----------
+ value : str
+ String value to quote.
+
+ Returns
+ -------
+ str
+ Quoted and escaped string literal.
+ """
+ # Use pymysql's escape_string for proper escaping
+ escaped = client.converters.escape_string(value)
+ return f"'{escaped}'"
+
+ def get_master_table_name(self, part_table: str) -> str | None:
+ """Extract master table name from part table (MySQL backtick format)."""
+ import re
+
+ # MySQL format: `schema`.`master__part`
+ match = re.match(r"(?P`\w+`.`#?\w+)__\w+`", part_table)
+ return match["master"] + "`" if match else None
+
+ @property
+ def parameter_placeholder(self) -> str:
+ """MySQL/pymysql uses %s placeholders."""
+ return "%s"
+
+ # =========================================================================
+ # Type Mapping
+ # =========================================================================
+
+ def core_type_to_sql(self, core_type: str) -> str:
+ """
+ Convert DataJoint core type to MySQL type.
+
+ Parameters
+ ----------
+ core_type : str
+ DataJoint core type, possibly with parameters:
+ - int64, float32, bool, uuid, bytes, json, date
+ - datetime or datetime(n)
+ - char(n), varchar(n)
+ - decimal(p,s)
+ - enum('a','b','c')
+
+ Returns
+ -------
+ str
+ MySQL SQL type.
+
+ Raises
+ ------
+ ValueError
+ If core_type is not recognized.
+ """
+ # Handle simple types without parameters
+ if core_type in CORE_TYPE_MAP:
+ return CORE_TYPE_MAP[core_type]
+
+ # Handle parametrized types
+ if core_type.startswith("datetime"):
+ # datetime or datetime(precision)
+ return core_type # MySQL supports datetime(n) directly
+
+ if core_type.startswith("char("):
+ # char(n)
+ return core_type
+
+ if core_type.startswith("varchar("):
+ # varchar(n)
+ return core_type
+
+ if core_type.startswith("decimal("):
+ # decimal(precision, scale)
+ return core_type
+
+ if core_type.startswith("enum("):
+ # enum('value1', 'value2', ...)
+ return core_type
+
+ raise ValueError(f"Unknown core type: {core_type}")
+
+ def sql_type_to_core(self, sql_type: str) -> str | None:
+ """
+ Convert MySQL type to DataJoint core type (if mappable).
+
+ Parameters
+ ----------
+ sql_type : str
+ MySQL SQL type.
+
+ Returns
+ -------
+ str or None
+ DataJoint core type if mappable, None otherwise.
+ """
+ # Normalize type string (lowercase, strip spaces)
+ sql_type_lower = sql_type.lower().strip()
+
+ # Direct mapping
+ if sql_type_lower in SQL_TO_CORE_MAP:
+ return SQL_TO_CORE_MAP[sql_type_lower]
+
+ # Handle parametrized types
+ if sql_type_lower.startswith("datetime"):
+ return sql_type # Keep precision
+
+ if sql_type_lower.startswith("char("):
+ return sql_type # Keep size
+
+ if sql_type_lower.startswith("varchar("):
+ return sql_type # Keep size
+
+ if sql_type_lower.startswith("decimal("):
+ return sql_type # Keep precision/scale
+
+ if sql_type_lower.startswith("enum("):
+ return sql_type # Keep values
+
+ # Not a mappable core type
+ return None
+
+ # =========================================================================
+ # DDL Generation
+ # =========================================================================
+
+ def create_schema_sql(self, schema_name: str) -> str:
+ """
+ Generate CREATE DATABASE statement for MySQL.
+
+ Parameters
+ ----------
+ schema_name : str
+ Database name.
+
+ Returns
+ -------
+ str
+ CREATE DATABASE SQL.
+ """
+ return f"CREATE DATABASE {self.quote_identifier(schema_name)}"
+
+ def drop_schema_sql(self, schema_name: str, if_exists: bool = True) -> str:
+ """
+ Generate DROP DATABASE statement for MySQL.
+
+ Parameters
+ ----------
+ schema_name : str
+ Database name.
+ if_exists : bool
+ Include IF EXISTS clause.
+
+ Returns
+ -------
+ str
+ DROP DATABASE SQL.
+ """
+ if_exists_clause = "IF EXISTS " if if_exists else ""
+ return f"DROP DATABASE {if_exists_clause}{self.quote_identifier(schema_name)}"
+
+ def create_table_sql(
+ self,
+ table_name: str,
+ columns: list[dict[str, Any]],
+ primary_key: list[str],
+ foreign_keys: list[dict[str, Any]],
+ indexes: list[dict[str, Any]],
+ comment: str | None = None,
+ ) -> str:
+ """
+ Generate CREATE TABLE statement for MySQL.
+
+ Parameters
+ ----------
+ table_name : str
+ Fully qualified table name (schema.table).
+ columns : list[dict]
+ Column defs: [{name, type, nullable, default, comment}, ...]
+ primary_key : list[str]
+ Primary key column names.
+ foreign_keys : list[dict]
+ FK defs: [{columns, ref_table, ref_columns}, ...]
+ indexes : list[dict]
+ Index defs: [{columns, unique}, ...]
+ comment : str, optional
+ Table comment.
+
+ Returns
+ -------
+ str
+ CREATE TABLE SQL statement.
+ """
+ lines = []
+
+ # Column definitions
+ for col in columns:
+ col_name = self.quote_identifier(col["name"])
+ col_type = col["type"]
+ nullable = "NULL" if col.get("nullable", False) else "NOT NULL"
+ default = f" DEFAULT {col['default']}" if "default" in col else ""
+ col_comment = f" COMMENT {self.quote_string(col['comment'])}" if "comment" in col else ""
+ lines.append(f"{col_name} {col_type} {nullable}{default}{col_comment}")
+
+ # Primary key
+ if primary_key:
+ pk_cols = ", ".join(self.quote_identifier(col) for col in primary_key)
+ lines.append(f"PRIMARY KEY ({pk_cols})")
+
+ # Foreign keys
+ for fk in foreign_keys:
+ fk_cols = ", ".join(self.quote_identifier(col) for col in fk["columns"])
+ ref_cols = ", ".join(self.quote_identifier(col) for col in fk["ref_columns"])
+ lines.append(
+ f"FOREIGN KEY ({fk_cols}) REFERENCES {fk['ref_table']} ({ref_cols}) ON UPDATE CASCADE ON DELETE RESTRICT"
+ )
+
+ # Indexes
+ for idx in indexes:
+ unique = "UNIQUE " if idx.get("unique", False) else ""
+ idx_cols = ", ".join(self.quote_identifier(col) for col in idx["columns"])
+ lines.append(f"{unique}INDEX ({idx_cols})")
+
+ # Assemble CREATE TABLE
+ table_def = ",\n ".join(lines)
+ comment_clause = f" COMMENT={self.quote_string(comment)}" if comment else ""
+ return f"CREATE TABLE IF NOT EXISTS {table_name} (\n {table_def}\n) ENGINE=InnoDB{comment_clause}"
+
+ def drop_table_sql(self, table_name: str, if_exists: bool = True) -> str:
+ """Generate DROP TABLE statement for MySQL."""
+ if_exists_clause = "IF EXISTS " if if_exists else ""
+ return f"DROP TABLE {if_exists_clause}{table_name}"
+
+ def alter_table_sql(
+ self,
+ table_name: str,
+ add_columns: list[dict[str, Any]] | None = None,
+ drop_columns: list[str] | None = None,
+ modify_columns: list[dict[str, Any]] | None = None,
+ ) -> str:
+ """
+ Generate ALTER TABLE statement for MySQL.
+
+ Parameters
+ ----------
+ table_name : str
+ Table name.
+ add_columns : list[dict], optional
+ Columns to add.
+ drop_columns : list[str], optional
+ Column names to drop.
+ modify_columns : list[dict], optional
+ Columns to modify.
+
+ Returns
+ -------
+ str
+ ALTER TABLE SQL statement.
+ """
+ clauses = []
+
+ if add_columns:
+ for col in add_columns:
+ col_name = self.quote_identifier(col["name"])
+ col_type = col["type"]
+ nullable = "NULL" if col.get("nullable", False) else "NOT NULL"
+ clauses.append(f"ADD {col_name} {col_type} {nullable}")
+
+ if drop_columns:
+ for col_name in drop_columns:
+ clauses.append(f"DROP {self.quote_identifier(col_name)}")
+
+ if modify_columns:
+ for col in modify_columns:
+ col_name = self.quote_identifier(col["name"])
+ col_type = col["type"]
+ nullable = "NULL" if col.get("nullable", False) else "NOT NULL"
+ clauses.append(f"MODIFY {col_name} {col_type} {nullable}")
+
+ return f"ALTER TABLE {table_name} {', '.join(clauses)}"
+
+ def add_comment_sql(
+ self,
+ object_type: str,
+ object_name: str,
+ comment: str,
+ ) -> str | None:
+ """
+ MySQL embeds comments in CREATE/ALTER, not separate statements.
+
+ Returns None since comments are inline.
+ """
+ return None
+
+ # =========================================================================
+ # DML Generation
+ # =========================================================================
+
+ def insert_sql(
+ self,
+ table_name: str,
+ columns: list[str],
+ on_duplicate: str | None = None,
+ ) -> str:
+ """
+ Generate INSERT statement for MySQL.
+
+ Parameters
+ ----------
+ table_name : str
+ Table name.
+ columns : list[str]
+ Column names.
+ on_duplicate : str, optional
+ 'ignore', 'replace', or 'update'.
+
+ Returns
+ -------
+ str
+ INSERT SQL with placeholders.
+ """
+ cols = ", ".join(self.quote_identifier(col) for col in columns)
+ placeholders = ", ".join([self.parameter_placeholder] * len(columns))
+
+ if on_duplicate == "ignore":
+ return f"INSERT IGNORE INTO {table_name} ({cols}) VALUES ({placeholders})"
+ elif on_duplicate == "replace":
+ return f"REPLACE INTO {table_name} ({cols}) VALUES ({placeholders})"
+ elif on_duplicate == "update":
+ # ON DUPLICATE KEY UPDATE col=VALUES(col)
+ updates = ", ".join(f"{self.quote_identifier(col)}=VALUES({self.quote_identifier(col)})" for col in columns)
+ return f"INSERT INTO {table_name} ({cols}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates}"
+ else:
+ return f"INSERT INTO {table_name} ({cols}) VALUES ({placeholders})"
+
+ def update_sql(
+ self,
+ table_name: str,
+ set_columns: list[str],
+ where_columns: list[str],
+ ) -> str:
+ """Generate UPDATE statement for MySQL."""
+ set_clause = ", ".join(f"{self.quote_identifier(col)} = {self.parameter_placeholder}" for col in set_columns)
+ where_clause = " AND ".join(f"{self.quote_identifier(col)} = {self.parameter_placeholder}" for col in where_columns)
+ return f"UPDATE {table_name} SET {set_clause} WHERE {where_clause}"
+
+ def delete_sql(self, table_name: str) -> str:
+ """Generate DELETE statement for MySQL (WHERE added separately)."""
+ return f"DELETE FROM {table_name}"
+
+ def upsert_on_duplicate_sql(
+ self,
+ table_name: str,
+ columns: list[str],
+ primary_key: list[str],
+ num_rows: int,
+ ) -> str:
+ """Generate INSERT ... ON DUPLICATE KEY UPDATE statement for MySQL."""
+ # Build column list
+ col_list = ", ".join(columns)
+
+ # Build placeholders for VALUES
+ placeholders = ", ".join(["(%s)" % ", ".join(["%s"] * len(columns))] * num_rows)
+
+ # Build UPDATE clause (all columns)
+ update_clauses = ", ".join(f"{col} = VALUES({col})" for col in columns)
+
+ return f"""
+ INSERT INTO {table_name} ({col_list})
+ VALUES {placeholders}
+ ON DUPLICATE KEY UPDATE {update_clauses}
+ """
+
+ def skip_duplicates_clause(
+ self,
+ full_table_name: str,
+ primary_key: list[str],
+ ) -> str:
+ """
+ Generate clause to skip duplicate key insertions for MySQL.
+
+ Uses ON DUPLICATE KEY UPDATE with a no-op update (pk=pk) to effectively
+ skip duplicates without raising an error.
+
+ Parameters
+ ----------
+ full_table_name : str
+ Fully qualified table name (with quotes).
+ primary_key : list[str]
+ Primary key column names (unquoted).
+
+ Returns
+ -------
+ str
+ MySQL ON DUPLICATE KEY UPDATE clause.
+ """
+ quoted_pk = self.quote_identifier(primary_key[0])
+ return f" ON DUPLICATE KEY UPDATE {quoted_pk}={full_table_name}.{quoted_pk}"
+
+ # =========================================================================
+ # Introspection
+ # =========================================================================
+
+ def list_schemas_sql(self) -> str:
+ """Query to list all databases in MySQL."""
+ return "SELECT schema_name FROM information_schema.schemata"
+
+ def schema_exists_sql(self, schema_name: str) -> str:
+ """Query to check if a database exists in MySQL."""
+ return f"SELECT schema_name FROM information_schema.schemata WHERE schema_name = {self.quote_string(schema_name)}"
+
+ def list_tables_sql(self, schema_name: str, pattern: str | None = None) -> str:
+ """Query to list tables in a database."""
+ sql = f"SHOW TABLES IN {self.quote_identifier(schema_name)}"
+ if pattern:
+ sql += f" LIKE '{pattern}'"
+ return sql
+
+ def get_table_info_sql(self, schema_name: str, table_name: str) -> str:
+ """Query to get table metadata (comment, engine, etc.)."""
+ return (
+ f"SELECT * FROM information_schema.tables "
+ f"WHERE table_schema = {self.quote_string(schema_name)} "
+ f"AND table_name = {self.quote_string(table_name)}"
+ )
+
+ def get_columns_sql(self, schema_name: str, table_name: str) -> str:
+ """Query to get column definitions."""
+ return f"SHOW FULL COLUMNS FROM {self.quote_identifier(table_name)} IN {self.quote_identifier(schema_name)}"
+
+ def get_primary_key_sql(self, schema_name: str, table_name: str) -> str:
+ """Query to get primary key columns."""
+ return (
+ f"SELECT COLUMN_NAME as column_name FROM information_schema.key_column_usage "
+ f"WHERE table_schema = {self.quote_string(schema_name)} "
+ f"AND table_name = {self.quote_string(table_name)} "
+ f"AND constraint_name = 'PRIMARY' "
+ f"ORDER BY ordinal_position"
+ )
+
+ def get_foreign_keys_sql(self, schema_name: str, table_name: str) -> str:
+ """Query to get foreign key constraints."""
+ return (
+ f"SELECT CONSTRAINT_NAME as constraint_name, COLUMN_NAME as column_name, "
+ f"REFERENCED_TABLE_NAME as referenced_table_name, REFERENCED_COLUMN_NAME as referenced_column_name "
+ f"FROM information_schema.key_column_usage "
+ f"WHERE table_schema = {self.quote_string(schema_name)} "
+ f"AND table_name = {self.quote_string(table_name)} "
+ f"AND referenced_table_name IS NOT NULL "
+ f"ORDER BY constraint_name, ordinal_position"
+ )
+
+ def load_primary_keys_sql(self, schemas_list: str, like_pattern: str) -> str:
+ """Query to load all primary key columns across schemas."""
+ tab_expr = "concat('`', table_schema, '`.`', table_name, '`')"
+ return (
+ f"SELECT {tab_expr} as tab, column_name "
+ f"FROM information_schema.key_column_usage "
+ f"WHERE table_name NOT LIKE {like_pattern} "
+ f"AND table_schema in ({schemas_list}) "
+ f"AND constraint_name='PRIMARY'"
+ )
+
+ def load_foreign_keys_sql(self, schemas_list: str, like_pattern: str) -> str:
+ """Query to load all foreign key relationships across schemas."""
+ tab_expr = "concat('`', table_schema, '`.`', table_name, '`')"
+ ref_tab_expr = "concat('`', referenced_table_schema, '`.`', referenced_table_name, '`')"
+ return (
+ f"SELECT constraint_name, "
+ f"{tab_expr} as referencing_table, "
+ f"{ref_tab_expr} as referenced_table, "
+ f"column_name, referenced_column_name "
+ f"FROM information_schema.key_column_usage "
+ f"WHERE referenced_table_name NOT LIKE {like_pattern} "
+ f"AND (referenced_table_schema in ({schemas_list}) "
+ f"OR referenced_table_schema is not NULL AND table_schema in ({schemas_list}))"
+ )
+
+ def find_downstream_schemas_sql(self, schemas_list: str) -> str:
+ """Find schemas with FK references to the given schemas."""
+ return (
+ f"SELECT DISTINCT table_schema as schema_name "
+ f"FROM information_schema.key_column_usage "
+ f"WHERE referenced_table_schema IN ({schemas_list}) "
+ f"AND table_schema NOT IN ({schemas_list})"
+ )
+
+ def get_constraint_info_sql(self, constraint_name: str, schema_name: str, table_name: str) -> str:
+ """Query to get FK constraint details from information_schema."""
+ return (
+ "SELECT "
+ " COLUMN_NAME as fk_attrs, "
+ " CONCAT('`', REFERENCED_TABLE_SCHEMA, '`.`', REFERENCED_TABLE_NAME, '`') as parent, "
+ " REFERENCED_COLUMN_NAME as pk_attrs "
+ "FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE "
+ "WHERE CONSTRAINT_NAME = %s AND TABLE_SCHEMA = %s AND TABLE_NAME = %s"
+ )
+
+ def parse_foreign_key_error(self, error_message: str) -> dict[str, str | list[str] | None] | None:
+ """Parse MySQL foreign key violation error message."""
+ import re
+
+ # MySQL FK error pattern with backticks
+ pattern = re.compile(
+ r"[\w\s:]*\((?P`[^`]+`.`[^`]+`), "
+ r"CONSTRAINT (?P`[^`]+`) "
+ r"(FOREIGN KEY \((?P[^)]+)\) "
+ r"REFERENCES (?P`[^`]+`(\.`[^`]+`)?) \((?P[^)]+)\)[\s\w]+\))?"
+ )
+
+ match = pattern.match(error_message)
+ if not match:
+ return None
+
+ result = match.groupdict()
+
+ # Parse comma-separated FK attrs if present
+ if result.get("fk_attrs"):
+ result["fk_attrs"] = [col.strip("`") for col in result["fk_attrs"].split(",")]
+ # Parse comma-separated PK attrs if present
+ if result.get("pk_attrs"):
+ result["pk_attrs"] = [col.strip("`") for col in result["pk_attrs"].split(",")]
+
+ return result
+
+ def get_indexes_sql(self, schema_name: str, table_name: str) -> str:
+ """Query to get index definitions. Functional indexes (NULL COLUMN_NAME) are skipped downstream."""
+ return (
+ f"SELECT INDEX_NAME as index_name, "
+ f"COLUMN_NAME as column_name, "
+ f"NON_UNIQUE as non_unique, SEQ_IN_INDEX as seq_in_index "
+ f"FROM information_schema.statistics "
+ f"WHERE table_schema = {self.quote_string(schema_name)} "
+ f"AND table_name = {self.quote_string(table_name)} "
+ f"AND index_name != 'PRIMARY' "
+ f"ORDER BY index_name, seq_in_index"
+ )
+
+ def parse_column_info(self, row: dict[str, Any]) -> dict[str, Any]:
+ """
+ Parse MySQL SHOW FULL COLUMNS output into standardized format.
+
+ Parameters
+ ----------
+ row : dict
+ Row from SHOW FULL COLUMNS query.
+
+ Returns
+ -------
+ dict
+ Standardized column info with keys:
+ name, type, nullable, default, comment, key, extra
+ """
+ return {
+ "name": row["Field"],
+ "type": row["Type"],
+ "nullable": row["Null"] == "YES",
+ "default": row["Default"],
+ "comment": row["Comment"],
+ "key": row["Key"], # PRI, UNI, MUL
+ "extra": row["Extra"], # auto_increment, etc.
+ }
+
+ # =========================================================================
+ # Transactions
+ # =========================================================================
+
+ def start_transaction_sql(self, isolation_level: str | None = None) -> str:
+ """Generate START TRANSACTION statement."""
+ if isolation_level:
+ return f"START TRANSACTION WITH CONSISTENT SNAPSHOT, {isolation_level}"
+ return "START TRANSACTION WITH CONSISTENT SNAPSHOT"
+
+ def commit_sql(self) -> str:
+ """Generate COMMIT statement."""
+ return "COMMIT"
+
+ def rollback_sql(self) -> str:
+ """Generate ROLLBACK statement."""
+ return "ROLLBACK"
+
+ # =========================================================================
+ # Functions and Expressions
+ # =========================================================================
+
+ def current_timestamp_expr(self, precision: int | None = None) -> str:
+ """
+ CURRENT_TIMESTAMP expression for MySQL.
+
+ Parameters
+ ----------
+ precision : int, optional
+ Fractional seconds precision (0-6).
+
+ Returns
+ -------
+ str
+ CURRENT_TIMESTAMP or CURRENT_TIMESTAMP(n).
+ """
+ if precision is not None:
+ return f"CURRENT_TIMESTAMP({precision})"
+ return "CURRENT_TIMESTAMP"
+
+ def interval_expr(self, value: int, unit: str) -> str:
+ """
+ INTERVAL expression for MySQL.
+
+ Parameters
+ ----------
+ value : int
+ Interval value.
+ unit : str
+ Time unit (singular: 'second', 'minute', 'hour', 'day').
+
+ Returns
+ -------
+ str
+ INTERVAL n UNIT (e.g., 'INTERVAL 5 SECOND').
+ """
+ # MySQL uses singular unit names
+ return f"INTERVAL {value} {unit.upper()}"
+
+ def current_user_expr(self) -> str:
+ """MySQL current user expression."""
+ return "user()"
+
+ def json_path_expr(self, column: str, path: str, return_type: str | None = None) -> str:
+ """
+ Generate MySQL json_value() expression.
+
+ Parameters
+ ----------
+ column : str
+ Column name containing JSON data.
+ path : str
+ JSON path (e.g., 'field' or 'nested.field').
+ return_type : str, optional
+ Return type specification (e.g., 'decimal(10,2)').
+
+ Returns
+ -------
+ str
+ MySQL json_value() expression.
+
+ Examples
+ --------
+ >>> adapter.json_path_expr('data', 'field')
+ "json_value(`data`, _utf8mb4'$.field')"
+ >>> adapter.json_path_expr('data', 'value', 'decimal(10,2)')
+ "json_value(`data`, _utf8mb4'$.value' returning decimal(10,2))"
+ """
+ quoted_col = self.quote_identifier(column)
+ return_clause = f" returning {return_type}" if return_type else ""
+ return f"json_value({quoted_col}, _utf8mb4'$.{path}'{return_clause})"
+
+ def translate_expression(self, expr: str) -> str:
+ """
+ Translate SQL expression for MySQL compatibility.
+
+ Converts PostgreSQL-specific functions to MySQL equivalents:
+ - STRING_AGG(col, 'sep') → GROUP_CONCAT(col SEPARATOR 'sep')
+ - STRING_AGG(col, ',') → GROUP_CONCAT(col)
+
+ Parameters
+ ----------
+ expr : str
+ SQL expression that may contain function calls.
+
+ Returns
+ -------
+ str
+ Translated expression for MySQL.
+ """
+ import re
+
+ # STRING_AGG(col, 'sep') → GROUP_CONCAT(col SEPARATOR 'sep')
+ def replace_string_agg(match):
+ inner = match.group(1).strip()
+ # Parse arguments: col, 'separator'
+ # Handle both single and double quoted separators
+ arg_match = re.match(r"(.+?)\s*,\s*(['\"])(.+?)\2", inner)
+ if arg_match:
+ col = arg_match.group(1).strip()
+ sep = arg_match.group(3)
+ # Remove ::text cast if present (PostgreSQL-specific)
+ col = re.sub(r"::text$", "", col)
+ if sep == ",":
+ return f"GROUP_CONCAT({col})"
+ else:
+ return f"GROUP_CONCAT({col} SEPARATOR '{sep}')"
+ else:
+ # No separator found, just use the expression
+ col = re.sub(r"::text$", "", inner)
+ return f"GROUP_CONCAT({col})"
+
+ expr = re.sub(r"STRING_AGG\s*\((.+?)\)", replace_string_agg, expr, flags=re.IGNORECASE)
+
+ return expr
+
+ # =========================================================================
+ # DDL Generation
+ # =========================================================================
+
+ def format_column_definition(
+ self,
+ name: str,
+ sql_type: str,
+ nullable: bool = False,
+ default: str | None = None,
+ comment: str | None = None,
+ ) -> str:
+ """
+ Format a column definition for MySQL DDL.
+
+ Examples
+ --------
+ >>> adapter.format_column_definition('user_id', 'bigint', nullable=False, comment='user ID')
+ "`user_id` bigint NOT NULL COMMENT \\"user ID\\""
+ """
+ parts = [self.quote_identifier(name), sql_type]
+ if default:
+ parts.append(default) # e.g., "DEFAULT NULL" or "NOT NULL DEFAULT 5"
+ elif not nullable:
+ parts.append("NOT NULL")
+ if comment:
+ parts.append(f'COMMENT "{comment}"')
+ return " ".join(parts)
+
+ def table_options_clause(self, comment: str | None = None) -> str:
+ """
+ Generate MySQL table options clause.
+
+ Examples
+ --------
+ >>> adapter.table_options_clause('test table')
+ 'ENGINE=InnoDB, COMMENT "test table"'
+ >>> adapter.table_options_clause()
+ 'ENGINE=InnoDB'
+ """
+ clause = "ENGINE=InnoDB"
+ if comment:
+ clause += f', COMMENT "{comment}"'
+ return clause
+
+ def table_comment_ddl(self, full_table_name: str, comment: str) -> str | None:
+ """
+ MySQL uses inline COMMENT in CREATE TABLE, so no separate DDL needed.
+
+ Examples
+ --------
+ >>> adapter.table_comment_ddl('`schema`.`table`', 'test comment')
+ None
+ """
+ return None # MySQL uses inline COMMENT
+
+ def column_comment_ddl(self, full_table_name: str, column_name: str, comment: str) -> str | None:
+ """
+ MySQL uses inline COMMENT in column definitions, so no separate DDL needed.
+
+ Examples
+ --------
+ >>> adapter.column_comment_ddl('`schema`.`table`', 'column', 'test comment')
+ None
+ """
+ return None # MySQL uses inline COMMENT
+
+ def enum_type_ddl(self, type_name: str, values: list[str]) -> str | None:
+ """
+ MySQL uses inline enum type in column definition, so no separate DDL needed.
+
+ Examples
+ --------
+ >>> adapter.enum_type_ddl('status_type', ['active', 'inactive'])
+ None
+ """
+ return None # MySQL uses inline enum
+
+ def job_metadata_columns(self) -> list[str]:
+ """
+ Return MySQL-specific job metadata column definitions.
+
+ Examples
+ --------
+ >>> adapter.job_metadata_columns()
+ ["`_job_start_time` datetime(3) DEFAULT NULL",
+ "`_job_duration` float DEFAULT NULL",
+ "`_job_version` varchar(64) DEFAULT ''"]
+ """
+ return [
+ "`_job_start_time` datetime(3) DEFAULT NULL",
+ "`_job_duration` float DEFAULT NULL",
+ "`_job_version` varchar(64) DEFAULT ''",
+ ]
+
+ # =========================================================================
+ # Error Translation
+ # =========================================================================
+
+ def translate_error(self, error: Exception, query: str = "") -> Exception:
+ """
+ Translate MySQL error to DataJoint exception.
+
+ Parameters
+ ----------
+ error : Exception
+ MySQL exception (typically pymysql error).
+
+ Returns
+ -------
+ Exception
+ DataJoint exception or original error.
+ """
+ if not hasattr(error, "args") or len(error.args) == 0:
+ return error
+
+ err, *args = error.args
+
+ match err:
+ # Loss of connection errors
+ case 0 | "(0, '')":
+ return errors.LostConnectionError("Server connection lost due to an interface error.", *args)
+ case 2006:
+ return errors.LostConnectionError("Connection timed out", *args)
+ case 2013:
+ return errors.LostConnectionError("Server connection lost", *args)
+
+ # Access errors
+ case 1044 | 1142:
+ query = args[0] if args else ""
+ return errors.AccessError("Insufficient privileges.", args[0] if args else "", query)
+
+ # Integrity errors
+ case 1062:
+ return errors.DuplicateError(*args)
+ case 1217 | 1451 | 1452 | 3730:
+ return errors.IntegrityError(*args)
+
+ # Syntax errors
+ case 1064:
+ query = args[0] if args else ""
+ return errors.QuerySyntaxError(args[0] if args else "", query)
+
+ # Existence errors
+ case 1146:
+ query = args[0] if args else ""
+ return errors.MissingTableError(args[0] if args else "", query)
+ case 1364:
+ return errors.MissingAttributeError(*args)
+ case 1054:
+ return errors.UnknownAttributeError(*args)
+
+ # All other errors pass through unchanged
+ case _:
+ return error
+
+ # =========================================================================
+ # Native Type Validation
+ # =========================================================================
+
+ def validate_native_type(self, type_str: str) -> bool:
+ """
+ Check if a native MySQL type string is valid.
+
+ Parameters
+ ----------
+ type_str : str
+ Type string to validate.
+
+ Returns
+ -------
+ bool
+ True if valid MySQL type.
+ """
+ type_lower = type_str.lower().strip()
+
+ # MySQL native types (simplified validation)
+ valid_types = {
+ # Integer types
+ "tinyint",
+ "smallint",
+ "mediumint",
+ "int",
+ "integer",
+ "bigint",
+ # Floating point
+ "float",
+ "double",
+ "real",
+ "decimal",
+ "numeric",
+ # String types
+ "char",
+ "varchar",
+ "binary",
+ "varbinary",
+ "tinyblob",
+ "blob",
+ "mediumblob",
+ "longblob",
+ "tinytext",
+ "text",
+ "mediumtext",
+ "longtext",
+ # Temporal types
+ "date",
+ "time",
+ "datetime",
+ "timestamp",
+ "year",
+ # Other
+ "enum",
+ "set",
+ "json",
+ "geometry",
+ }
+
+ # Extract base type (before parentheses)
+ base_type = type_lower.split("(")[0].strip()
+
+ return base_type in valid_types
diff --git a/src/datajoint/adapters/postgres.py b/src/datajoint/adapters/postgres.py
new file mode 100644
index 000000000..55ea189dc
--- /dev/null
+++ b/src/datajoint/adapters/postgres.py
@@ -0,0 +1,1601 @@
+"""
+PostgreSQL database adapter for DataJoint.
+
+This module provides PostgreSQL-specific implementations for SQL generation,
+type mapping, error translation, and connection management.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+try:
+ import psycopg2 as client
+ from psycopg2 import sql
+except ImportError:
+ client = None # type: ignore
+ sql = None # type: ignore
+
+from .. import errors
+from .base import DatabaseAdapter
+
+# Core type mapping: DataJoint core types → PostgreSQL types
+CORE_TYPE_MAP = {
+ "int64": "bigint",
+ "int32": "integer",
+ "int16": "smallint",
+ "int8": "smallint", # PostgreSQL lacks tinyint; semantically equivalent
+ "float32": "real",
+ "float64": "double precision",
+ "bool": "boolean",
+ "uuid": "uuid", # Native UUID support
+ "bytes": "bytea",
+ "json": "jsonb", # Using jsonb for better performance
+ "date": "date",
+ # datetime, char, varchar, decimal, enum require parameters - handled in method
+}
+
+# Reverse mapping: PostgreSQL types → DataJoint core types (for introspection)
+SQL_TO_CORE_MAP = {
+ "bigint": "int64",
+ "integer": "int32",
+ "smallint": "int16",
+ "real": "float32",
+ "double precision": "float64",
+ "boolean": "bool",
+ "uuid": "uuid",
+ "bytea": "bytes",
+ "jsonb": "json",
+ "json": "json",
+ "date": "date",
+}
+
+
+class PostgreSQLAdapter(DatabaseAdapter):
+ """PostgreSQL database adapter implementation."""
+
+ def __init__(self) -> None:
+ """Initialize PostgreSQL adapter."""
+ if client is None:
+ raise ImportError(
+ "psycopg2 is required for PostgreSQL support. " "Install it with: pip install 'datajoint[postgres]'"
+ )
+
+ # =========================================================================
+ # Connection Management
+ # =========================================================================
+
+ def connect(
+ self,
+ host: str,
+ port: int,
+ user: str,
+ password: str,
+ **kwargs: Any,
+ ) -> Any:
+ """
+ Establish PostgreSQL connection.
+
+ Parameters
+ ----------
+ host : str
+ PostgreSQL server hostname.
+ port : int
+ PostgreSQL server port.
+ user : str
+ Username for authentication.
+ password : str
+ Password for authentication.
+ **kwargs : Any
+ Additional PostgreSQL-specific parameters:
+ - dbname: Database name
+ - sslmode: SSL mode ('disable', 'allow', 'prefer', 'require')
+ - use_tls: bool or dict - DataJoint's SSL parameter (converted to sslmode)
+ - connect_timeout: Connection timeout in seconds
+
+ Returns
+ -------
+ psycopg2.connection
+ PostgreSQL connection object.
+ """
+ dbname = kwargs.get("dbname", "postgres") # Default to postgres database
+ connect_timeout = kwargs.get("connect_timeout", 10)
+
+ # Handle use_tls parameter (from DataJoint Connection)
+ # Convert to PostgreSQL's sslmode
+ use_tls = kwargs.get("use_tls")
+ if "sslmode" in kwargs:
+ # Explicit sslmode takes precedence
+ sslmode = kwargs["sslmode"]
+ elif use_tls is False:
+ # use_tls=False → disable SSL
+ sslmode = "disable"
+ elif use_tls is True or isinstance(use_tls, dict):
+ # use_tls=True or dict → require SSL
+ sslmode = "require"
+ else:
+ # use_tls=None (default) → prefer SSL but allow fallback
+ sslmode = "prefer"
+
+ conn = client.connect(
+ host=host,
+ port=port,
+ user=user,
+ password=password,
+ dbname=dbname,
+ sslmode=sslmode,
+ connect_timeout=connect_timeout,
+ )
+ # DataJoint manages transactions explicitly via start_transaction()
+ # Set autocommit=True to avoid implicit transactions
+ conn.autocommit = True
+
+ # Register numpy type adapters so numpy types can be used directly in queries
+ self._register_numpy_adapters()
+
+ return conn
+
+ def _register_numpy_adapters(self) -> None:
+ """
+ Register psycopg2 adapters for numpy types.
+
+ This allows numpy scalar types (bool_, int64, float64, etc.) to be used
+ directly in queries without explicit conversion to Python native types.
+ """
+ try:
+ import numpy as np
+ from psycopg2.extensions import register_adapter, AsIs
+
+ # Numpy bool type
+ register_adapter(np.bool_, lambda x: AsIs(str(bool(x)).upper()))
+
+ # Numpy integer types
+ for np_type in (np.int8, np.int16, np.int32, np.int64, np.uint8, np.uint16, np.uint32, np.uint64):
+ register_adapter(np_type, lambda x: AsIs(int(x)))
+
+ # Numpy float types
+ for np_ftype in (np.float16, np.float32, np.float64):
+ register_adapter(np_ftype, lambda x: AsIs(repr(float(x))))
+
+ except ImportError:
+ pass # numpy not available
+
+ def close(self, connection: Any) -> None:
+ """Close the PostgreSQL connection."""
+ connection.close()
+
+ def ping(self, connection: Any) -> bool:
+ """
+ Check if PostgreSQL connection is alive.
+
+ Returns
+ -------
+ bool
+ True if connection is alive.
+ """
+ try:
+ cursor = connection.cursor()
+ cursor.execute("SELECT 1")
+ cursor.close()
+ return True
+ except Exception:
+ return False
+
+ def get_connection_id(self, connection: Any) -> int:
+ """
+ Get PostgreSQL backend process ID.
+
+ Returns
+ -------
+ int
+ PostgreSQL pg_backend_pid().
+ """
+ cursor = connection.cursor()
+ cursor.execute("SELECT pg_backend_pid()")
+ return cursor.fetchone()[0]
+
+ @property
+ def default_port(self) -> int:
+ """PostgreSQL default port 5432."""
+ return 5432
+
+ @property
+ def backend(self) -> str:
+ """Backend identifier: 'postgresql'."""
+ return "postgresql"
+
+ def get_cursor(self, connection: Any, as_dict: bool = False) -> Any:
+ """
+ Get a cursor from PostgreSQL connection.
+
+ Parameters
+ ----------
+ connection : Any
+ psycopg2 connection object.
+ as_dict : bool, optional
+ If True, return Real DictCursor that yields rows as dictionaries.
+ If False, return standard cursor that yields rows as tuples.
+ Default False.
+
+ Returns
+ -------
+ Any
+ psycopg2 cursor object.
+ """
+ import psycopg2.extras
+
+ if as_dict:
+ return connection.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
+ return connection.cursor()
+
+ # =========================================================================
+ # SQL Syntax
+ # =========================================================================
+
+ def quote_identifier(self, name: str) -> str:
+ """
+ Quote identifier with double quotes for PostgreSQL.
+
+ Parameters
+ ----------
+ name : str
+ Identifier to quote.
+
+ Returns
+ -------
+ str
+ Double-quoted identifier: "name"
+ """
+ return f'"{name}"'
+
+ @property
+ def max_table_name_length(self) -> int:
+ """PostgreSQL NAMEDATALEN-1 = 63."""
+ return 63
+
+ def split_full_table_name(self, full_table_name: str) -> tuple[str, str]:
+ """Split ``"schema"."table"`` into ``('schema', 'table')``."""
+ schema, table = full_table_name.replace('"', "").split(".")
+ return schema, table
+
+ def quote_string(self, value: str) -> str:
+ """
+ Quote string literal for PostgreSQL with escaping.
+
+ Parameters
+ ----------
+ value : str
+ String value to quote.
+
+ Returns
+ -------
+ str
+ Quoted and escaped string literal.
+ """
+ # Escape single quotes by doubling them (PostgreSQL standard)
+ escaped = value.replace("'", "''")
+ return f"'{escaped}'"
+
+ def get_master_table_name(self, part_table: str) -> str | None:
+ """Extract master table name from part table (PostgreSQL double-quote format)."""
+ import re
+
+ # PostgreSQL format: "schema"."master__part"
+ match = re.match(r'(?P"\w+"."#?\w+)__\w+"', part_table)
+ return match["master"] + '"' if match else None
+
+ @property
+ def parameter_placeholder(self) -> str:
+ """PostgreSQL/psycopg2 uses %s placeholders."""
+ return "%s"
+
+ # =========================================================================
+ # Type Mapping
+ # =========================================================================
+
+ def core_type_to_sql(self, core_type: str) -> str:
+ """
+ Convert DataJoint core type to PostgreSQL type.
+
+ Parameters
+ ----------
+ core_type : str
+ DataJoint core type, possibly with parameters:
+ - int64, float32, bool, uuid, bytes, json, date
+ - datetime or datetime(n) → timestamp(n)
+ - char(n), varchar(n)
+ - decimal(p,s) → numeric(p,s)
+ - enum('a','b','c') → requires CREATE TYPE
+
+ Returns
+ -------
+ str
+ PostgreSQL SQL type.
+
+ Raises
+ ------
+ ValueError
+ If core_type is not recognized.
+ """
+ # Handle simple types without parameters
+ if core_type in CORE_TYPE_MAP:
+ return CORE_TYPE_MAP[core_type]
+
+ # Handle parametrized types
+ if core_type.startswith("datetime"):
+ # datetime or datetime(precision) → timestamp or timestamp(precision)
+ if "(" in core_type:
+ # Extract precision: datetime(3) → timestamp(3)
+ precision = core_type[core_type.index("(") : core_type.index(")") + 1]
+ return f"timestamp{precision}"
+ return "timestamp"
+
+ if core_type.startswith("char("):
+ # char(n)
+ return core_type
+
+ if core_type.startswith("varchar("):
+ # varchar(n)
+ return core_type
+
+ if core_type.startswith("decimal("):
+ # decimal(precision, scale) → numeric(precision, scale)
+ params = core_type[7:] # Remove "decimal"
+ return f"numeric{params}"
+
+ if core_type.startswith("enum("):
+ # PostgreSQL requires CREATE TYPE for enums
+ # Extract enum values and generate a deterministic type name
+ enum_match = re.match(r"enum\s*\((.+)\)", core_type, re.I)
+ if enum_match:
+ # Parse enum values: enum('M','F') -> ['M', 'F']
+ values_str = enum_match.group(1)
+ # Split by comma, handling quoted values
+ values = [v.strip().strip("'\"") for v in values_str.split(",")]
+ # Generate a deterministic type name based on values
+ # Use a hash to keep name reasonable length
+ import hashlib
+
+ value_hash = hashlib.md5("_".join(sorted(values)).encode()).hexdigest()[:8]
+ type_name = f"enum_{value_hash}"
+ # Track this enum type for CREATE TYPE DDL
+ if not hasattr(self, "_pending_enum_types"):
+ self._pending_enum_types = {}
+ self._pending_enum_types[type_name] = values
+ # Return schema-qualified type reference using placeholder
+ # {database} will be replaced with actual schema name in table.py
+ return '"{database}".' + self.quote_identifier(type_name)
+ return "text" # Fallback if parsing fails
+
+ raise ValueError(f"Unknown core type: {core_type}")
+
+ def sql_type_to_core(self, sql_type: str) -> str | None:
+ """
+ Convert PostgreSQL type to DataJoint core type (if mappable).
+
+ Parameters
+ ----------
+ sql_type : str
+ PostgreSQL SQL type.
+
+ Returns
+ -------
+ str or None
+ DataJoint core type if mappable, None otherwise.
+ """
+ # Normalize type string (lowercase, strip spaces)
+ sql_type_lower = sql_type.lower().strip()
+
+ # Direct mapping
+ if sql_type_lower in SQL_TO_CORE_MAP:
+ return SQL_TO_CORE_MAP[sql_type_lower]
+
+ # Handle parametrized types
+ if sql_type_lower.startswith("timestamp"):
+ # timestamp(n) → datetime(n)
+ if "(" in sql_type_lower:
+ precision = sql_type_lower[sql_type_lower.index("(") : sql_type_lower.index(")") + 1]
+ return f"datetime{precision}"
+ return "datetime"
+
+ if sql_type_lower.startswith("char("):
+ return sql_type # Keep size
+
+ if sql_type_lower.startswith("varchar("):
+ return sql_type # Keep size
+
+ if sql_type_lower.startswith("numeric("):
+ # numeric(p,s) → decimal(p,s)
+ params = sql_type_lower[7:] # Remove "numeric"
+ return f"decimal{params}"
+
+ # Not a mappable core type
+ return None
+
+ # =========================================================================
+ # DDL Generation
+ # =========================================================================
+
+ def create_schema_sql(self, schema_name: str) -> str:
+ """
+ Generate CREATE SCHEMA statement for PostgreSQL.
+
+ Parameters
+ ----------
+ schema_name : str
+ Schema name.
+
+ Returns
+ -------
+ str
+ CREATE SCHEMA SQL.
+ """
+ return f"CREATE SCHEMA {self.quote_identifier(schema_name)}"
+
+ def drop_schema_sql(self, schema_name: str, if_exists: bool = True) -> str:
+ """
+ Generate DROP SCHEMA statement for PostgreSQL.
+
+ Parameters
+ ----------
+ schema_name : str
+ Schema name.
+ if_exists : bool
+ Include IF EXISTS clause.
+
+ Returns
+ -------
+ str
+ DROP SCHEMA SQL.
+ """
+ if_exists_clause = "IF EXISTS " if if_exists else ""
+ return f"DROP SCHEMA {if_exists_clause}{self.quote_identifier(schema_name)} CASCADE"
+
+ def create_table_sql(
+ self,
+ table_name: str,
+ columns: list[dict[str, Any]],
+ primary_key: list[str],
+ foreign_keys: list[dict[str, Any]],
+ indexes: list[dict[str, Any]],
+ comment: str | None = None,
+ ) -> str:
+ """
+ Generate CREATE TABLE statement for PostgreSQL.
+
+ Parameters
+ ----------
+ table_name : str
+ Fully qualified table name (schema.table).
+ columns : list[dict]
+ Column defs: [{name, type, nullable, default, comment}, ...]
+ primary_key : list[str]
+ Primary key column names.
+ foreign_keys : list[dict]
+ FK defs: [{columns, ref_table, ref_columns}, ...]
+ indexes : list[dict]
+ Index defs: [{columns, unique}, ...]
+ comment : str, optional
+ Table comment (added via separate COMMENT ON statement).
+
+ Returns
+ -------
+ str
+ CREATE TABLE SQL statement (comments via separate COMMENT ON).
+ """
+ lines = []
+
+ # Column definitions
+ for col in columns:
+ col_name = self.quote_identifier(col["name"])
+ col_type = col["type"]
+ nullable = "NULL" if col.get("nullable", False) else "NOT NULL"
+ default = f" DEFAULT {col['default']}" if "default" in col else ""
+ # PostgreSQL comments are via COMMENT ON, not inline
+ lines.append(f"{col_name} {col_type} {nullable}{default}")
+
+ # Primary key
+ if primary_key:
+ pk_cols = ", ".join(self.quote_identifier(col) for col in primary_key)
+ lines.append(f"PRIMARY KEY ({pk_cols})")
+
+ # Foreign keys
+ for fk in foreign_keys:
+ fk_cols = ", ".join(self.quote_identifier(col) for col in fk["columns"])
+ ref_cols = ", ".join(self.quote_identifier(col) for col in fk["ref_columns"])
+ lines.append(
+ f"FOREIGN KEY ({fk_cols}) REFERENCES {fk['ref_table']} ({ref_cols}) " f"ON UPDATE CASCADE ON DELETE RESTRICT"
+ )
+
+ # Indexes - PostgreSQL creates indexes separately via CREATE INDEX
+ # (handled by caller after table creation)
+
+ # Assemble CREATE TABLE (no ENGINE in PostgreSQL)
+ table_def = ",\n ".join(lines)
+ return f"CREATE TABLE IF NOT EXISTS {table_name} (\n {table_def}\n)"
+
+ def drop_table_sql(self, table_name: str, if_exists: bool = True) -> str:
+ """Generate DROP TABLE statement for PostgreSQL."""
+ if_exists_clause = "IF EXISTS " if if_exists else ""
+ return f"DROP TABLE {if_exists_clause}{table_name} CASCADE"
+
+ def alter_table_sql(
+ self,
+ table_name: str,
+ add_columns: list[dict[str, Any]] | None = None,
+ drop_columns: list[str] | None = None,
+ modify_columns: list[dict[str, Any]] | None = None,
+ ) -> str:
+ """
+ Generate ALTER TABLE statement for PostgreSQL.
+
+ Parameters
+ ----------
+ table_name : str
+ Table name.
+ add_columns : list[dict], optional
+ Columns to add.
+ drop_columns : list[str], optional
+ Column names to drop.
+ modify_columns : list[dict], optional
+ Columns to modify.
+
+ Returns
+ -------
+ str
+ ALTER TABLE SQL statement.
+ """
+ clauses = []
+
+ if add_columns:
+ for col in add_columns:
+ col_name = self.quote_identifier(col["name"])
+ col_type = col["type"]
+ nullable = "NULL" if col.get("nullable", False) else "NOT NULL"
+ clauses.append(f"ADD COLUMN {col_name} {col_type} {nullable}")
+
+ if drop_columns:
+ for col_name in drop_columns:
+ clauses.append(f"DROP COLUMN {self.quote_identifier(col_name)}")
+
+ if modify_columns:
+ # PostgreSQL requires ALTER COLUMN ... TYPE ... for type changes
+ for col in modify_columns:
+ col_name = self.quote_identifier(col["name"])
+ col_type = col["type"]
+ nullable = col.get("nullable", False)
+ clauses.append(f"ALTER COLUMN {col_name} TYPE {col_type}")
+ if nullable:
+ clauses.append(f"ALTER COLUMN {col_name} DROP NOT NULL")
+ else:
+ clauses.append(f"ALTER COLUMN {col_name} SET NOT NULL")
+
+ return f"ALTER TABLE {table_name} {', '.join(clauses)}"
+
+ def add_comment_sql(
+ self,
+ object_type: str,
+ object_name: str,
+ comment: str,
+ ) -> str | None:
+ """
+ Generate COMMENT ON statement for PostgreSQL.
+
+ Parameters
+ ----------
+ object_type : str
+ 'table' or 'column'.
+ object_name : str
+ Fully qualified object name.
+ comment : str
+ Comment text.
+
+ Returns
+ -------
+ str
+ COMMENT ON statement.
+ """
+ comment_type = object_type.upper()
+ return f"COMMENT ON {comment_type} {object_name} IS {self.quote_string(comment)}"
+
+ # =========================================================================
+ # DML Generation
+ # =========================================================================
+
+ def insert_sql(
+ self,
+ table_name: str,
+ columns: list[str],
+ on_duplicate: str | None = None,
+ ) -> str:
+ """
+ Generate INSERT statement for PostgreSQL.
+
+ Parameters
+ ----------
+ table_name : str
+ Table name.
+ columns : list[str]
+ Column names.
+ on_duplicate : str, optional
+ 'ignore' or 'update' (PostgreSQL uses ON CONFLICT).
+
+ Returns
+ -------
+ str
+ INSERT SQL with placeholders.
+ """
+ cols = ", ".join(self.quote_identifier(col) for col in columns)
+ placeholders = ", ".join([self.parameter_placeholder] * len(columns))
+
+ base_insert = f"INSERT INTO {table_name} ({cols}) VALUES ({placeholders})"
+
+ if on_duplicate == "ignore":
+ return f"{base_insert} ON CONFLICT DO NOTHING"
+ elif on_duplicate == "update":
+ # ON CONFLICT (pk_cols) DO UPDATE SET col=EXCLUDED.col
+ # Caller must provide constraint name or columns
+ updates = ", ".join(f"{self.quote_identifier(col)}=EXCLUDED.{self.quote_identifier(col)}" for col in columns)
+ return f"{base_insert} ON CONFLICT DO UPDATE SET {updates}"
+ else:
+ return base_insert
+
+ def update_sql(
+ self,
+ table_name: str,
+ set_columns: list[str],
+ where_columns: list[str],
+ ) -> str:
+ """Generate UPDATE statement for PostgreSQL."""
+ set_clause = ", ".join(f"{self.quote_identifier(col)} = {self.parameter_placeholder}" for col in set_columns)
+ where_clause = " AND ".join(f"{self.quote_identifier(col)} = {self.parameter_placeholder}" for col in where_columns)
+ return f"UPDATE {table_name} SET {set_clause} WHERE {where_clause}"
+
+ def delete_sql(self, table_name: str) -> str:
+ """Generate DELETE statement for PostgreSQL (WHERE added separately)."""
+ return f"DELETE FROM {table_name}"
+
+ def upsert_on_duplicate_sql(
+ self,
+ table_name: str,
+ columns: list[str],
+ primary_key: list[str],
+ num_rows: int,
+ ) -> str:
+ """Generate INSERT ... ON CONFLICT ... DO UPDATE statement for PostgreSQL."""
+ # Build column list
+ col_list = ", ".join(columns)
+
+ # Build placeholders for VALUES
+ placeholders = ", ".join(["(%s)" % ", ".join(["%s"] * len(columns))] * num_rows)
+
+ # Build conflict target (primary key columns)
+ conflict_cols = ", ".join(primary_key)
+
+ # Build UPDATE clause (non-PK columns only)
+ non_pk_columns = [col for col in columns if col not in primary_key]
+ update_clauses = ", ".join(f"{col} = EXCLUDED.{col}" for col in non_pk_columns)
+
+ return f"""
+ INSERT INTO {table_name} ({col_list})
+ VALUES {placeholders}
+ ON CONFLICT ({conflict_cols}) DO UPDATE SET {update_clauses}
+ """
+
+ def skip_duplicates_clause(
+ self,
+ full_table_name: str,
+ primary_key: list[str],
+ ) -> str:
+ """
+ Generate clause to skip duplicate key insertions for PostgreSQL.
+
+ Uses ON CONFLICT (pk_cols) DO NOTHING to skip duplicates without
+ raising an error.
+
+ Parameters
+ ----------
+ full_table_name : str
+ Fully qualified table name (with quotes). Unused but kept for
+ API compatibility with MySQL adapter.
+ primary_key : list[str]
+ Primary key column names (unquoted).
+
+ Returns
+ -------
+ str
+ PostgreSQL ON CONFLICT DO NOTHING clause.
+ """
+ pk_cols = ", ".join(self.quote_identifier(pk) for pk in primary_key)
+ return f" ON CONFLICT ({pk_cols}) DO NOTHING"
+
+ @property
+ def supports_inline_indexes(self) -> bool:
+ """
+ PostgreSQL does not support inline INDEX in CREATE TABLE.
+
+ Returns False to indicate indexes must be created separately
+ with CREATE INDEX statements.
+ """
+ return False
+
+ # =========================================================================
+ # Introspection
+ # =========================================================================
+
+ def list_schemas_sql(self) -> str:
+ """Query to list all schemas in PostgreSQL."""
+ return (
+ "SELECT schema_name FROM information_schema.schemata "
+ "WHERE schema_name NOT IN ('pg_catalog', 'information_schema')"
+ )
+
+ def schema_exists_sql(self, schema_name: str) -> str:
+ """Query to check if a schema exists in PostgreSQL."""
+ return f"SELECT schema_name FROM information_schema.schemata WHERE schema_name = {self.quote_string(schema_name)}"
+
+ def list_tables_sql(self, schema_name: str, pattern: str | None = None) -> str:
+ """Query to list tables in a schema."""
+ sql = (
+ f"SELECT table_name FROM information_schema.tables "
+ f"WHERE table_schema = {self.quote_string(schema_name)} "
+ f"AND table_type = 'BASE TABLE'"
+ )
+ if pattern:
+ sql += f" AND table_name LIKE '{pattern}'"
+ return sql
+
+ def get_table_info_sql(self, schema_name: str, table_name: str) -> str:
+ """Query to get table metadata including table comment."""
+ schema_str = self.quote_string(schema_name)
+ table_str = self.quote_string(table_name)
+ regclass_expr = f"({schema_str} || '.' || {table_str})::regclass"
+ return (
+ f"SELECT t.*, obj_description({regclass_expr}, 'pg_class') as table_comment "
+ f"FROM information_schema.tables t "
+ f"WHERE t.table_schema = {schema_str} "
+ f"AND t.table_name = {table_str}"
+ )
+
+ def get_columns_sql(self, schema_name: str, table_name: str) -> str:
+ """Query to get column definitions including comments."""
+ # Use col_description() to retrieve column comments stored via COMMENT ON COLUMN
+ # The regclass cast allows using schema.table notation to get the OID
+ schema_str = self.quote_string(schema_name)
+ table_str = self.quote_string(table_name)
+ regclass_expr = f"({schema_str} || '.' || {table_str})::regclass"
+ return (
+ f"SELECT c.column_name, c.data_type, c.udt_name, c.is_nullable, c.column_default, "
+ f"c.character_maximum_length, c.numeric_precision, c.numeric_scale, "
+ f"col_description({regclass_expr}, c.ordinal_position) as column_comment "
+ f"FROM information_schema.columns c "
+ f"WHERE c.table_schema = {schema_str} "
+ f"AND c.table_name = {table_str} "
+ f"ORDER BY c.ordinal_position"
+ )
+
+ def get_primary_key_sql(self, schema_name: str, table_name: str) -> str:
+ """Query to get primary key columns."""
+ return (
+ f"SELECT column_name FROM information_schema.key_column_usage "
+ f"WHERE table_schema = {self.quote_string(schema_name)} "
+ f"AND table_name = {self.quote_string(table_name)} "
+ f"AND constraint_name IN ("
+ f" SELECT constraint_name FROM information_schema.table_constraints "
+ f" WHERE table_schema = {self.quote_string(schema_name)} "
+ f" AND table_name = {self.quote_string(table_name)} "
+ f" AND constraint_type = 'PRIMARY KEY'"
+ f") "
+ f"ORDER BY ordinal_position"
+ )
+
+ def get_foreign_keys_sql(self, schema_name: str, table_name: str) -> str:
+ """Query to get foreign key constraints."""
+ return (
+ f"SELECT kcu.constraint_name, kcu.column_name, "
+ f"ccu.table_name AS foreign_table_name, ccu.column_name AS foreign_column_name "
+ f"FROM information_schema.key_column_usage AS kcu "
+ f"JOIN information_schema.constraint_column_usage AS ccu "
+ f" ON kcu.constraint_name = ccu.constraint_name "
+ f"WHERE kcu.table_schema = {self.quote_string(schema_name)} "
+ f"AND kcu.table_name = {self.quote_string(table_name)} "
+ f"AND kcu.constraint_name IN ("
+ f" SELECT constraint_name FROM information_schema.table_constraints "
+ f" WHERE table_schema = {self.quote_string(schema_name)} "
+ f" AND table_name = {self.quote_string(table_name)} "
+ f" AND constraint_type = 'FOREIGN KEY'"
+ f") "
+ f"ORDER BY kcu.constraint_name, kcu.ordinal_position"
+ )
+
+ def load_primary_keys_sql(self, schemas_list: str, like_pattern: str) -> str:
+ """Query to load all primary key columns across schemas."""
+ tab_expr = "'\"' || kcu.table_schema || '\".\"' || kcu.table_name || '\"'"
+ return (
+ f"SELECT {tab_expr} as tab, kcu.column_name "
+ f"FROM information_schema.key_column_usage kcu "
+ f"JOIN information_schema.table_constraints tc "
+ f"ON kcu.constraint_name = tc.constraint_name "
+ f"AND kcu.table_schema = tc.table_schema "
+ f"WHERE kcu.table_name NOT LIKE {like_pattern} "
+ f"AND kcu.table_schema in ({schemas_list}) "
+ f"AND tc.constraint_type = 'PRIMARY KEY'"
+ )
+
+ def load_foreign_keys_sql(self, schemas_list: str, like_pattern: str) -> str:
+ """Query to load all foreign key relationships across schemas."""
+ return (
+ f"SELECT "
+ f"c.conname as constraint_name, "
+ f"'\"' || ns1.nspname || '\".\"' || cl1.relname || '\"' as referencing_table, "
+ f"'\"' || ns2.nspname || '\".\"' || cl2.relname || '\"' as referenced_table, "
+ f"a1.attname as column_name, "
+ f"a2.attname as referenced_column_name "
+ f"FROM pg_constraint c "
+ f"JOIN pg_class cl1 ON c.conrelid = cl1.oid "
+ f"JOIN pg_namespace ns1 ON cl1.relnamespace = ns1.oid "
+ f"JOIN pg_class cl2 ON c.confrelid = cl2.oid "
+ f"JOIN pg_namespace ns2 ON cl2.relnamespace = ns2.oid "
+ f"CROSS JOIN LATERAL unnest(c.conkey, c.confkey) WITH ORDINALITY AS cols(conkey, confkey, ord) "
+ f"JOIN pg_attribute a1 ON a1.attrelid = cl1.oid AND a1.attnum = cols.conkey "
+ f"JOIN pg_attribute a2 ON a2.attrelid = cl2.oid AND a2.attnum = cols.confkey "
+ f"WHERE c.contype = 'f' "
+ f"AND cl1.relname NOT LIKE {like_pattern} "
+ f"AND (ns2.nspname in ({schemas_list}) "
+ f"OR ns1.nspname in ({schemas_list})) "
+ f"ORDER BY c.conname, cols.ord"
+ )
+
+ def find_downstream_schemas_sql(self, schemas_list: str) -> str:
+ """Find schemas with FK references to the given schemas."""
+ return (
+ f"SELECT DISTINCT ns1.nspname as schema_name "
+ f"FROM pg_constraint c "
+ f"JOIN pg_class cl1 ON c.conrelid = cl1.oid "
+ f"JOIN pg_namespace ns1 ON cl1.relnamespace = ns1.oid "
+ f"JOIN pg_class cl2 ON c.confrelid = cl2.oid "
+ f"JOIN pg_namespace ns2 ON cl2.relnamespace = ns2.oid "
+ f"WHERE c.contype = 'f' "
+ f"AND ns2.nspname IN ({schemas_list}) "
+ f"AND ns1.nspname NOT IN ({schemas_list})"
+ )
+
+ def get_constraint_info_sql(self, constraint_name: str, schema_name: str, table_name: str) -> str:
+ """
+ Query to get FK constraint details from information_schema.
+
+ Returns matched pairs of (fk_column, parent_table, pk_column) for each
+ column in the foreign key constraint, ordered by position.
+ """
+ return (
+ "SELECT "
+ " kcu.column_name as fk_attrs, "
+ " '\"' || ccu.table_schema || '\".\"' || ccu.table_name || '\"' as parent, "
+ " ccu.column_name as pk_attrs "
+ "FROM information_schema.key_column_usage AS kcu "
+ "JOIN information_schema.referential_constraints AS rc "
+ " ON kcu.constraint_name = rc.constraint_name "
+ " AND kcu.constraint_schema = rc.constraint_schema "
+ "JOIN information_schema.key_column_usage AS ccu "
+ " ON rc.unique_constraint_name = ccu.constraint_name "
+ " AND rc.unique_constraint_schema = ccu.constraint_schema "
+ " AND kcu.ordinal_position = ccu.ordinal_position "
+ "WHERE kcu.constraint_name = %s "
+ " AND kcu.table_schema = %s "
+ " AND kcu.table_name = %s "
+ "ORDER BY kcu.ordinal_position"
+ )
+
+ def parse_foreign_key_error(self, error_message: str) -> dict[str, str | list[str] | None] | None:
+ """
+ Parse PostgreSQL foreign key violation error message.
+
+ PostgreSQL FK error format:
+ 'update or delete on table "X" violates foreign key constraint "Y" on table "Z"'
+ Where:
+ - "X" is the referenced table (being deleted/updated)
+ - "Z" is the referencing table (has the FK, needs cascade delete)
+ """
+ import re
+
+ pattern = re.compile(
+ r'.*table "(?P[^"]+)" violates foreign key constraint '
+ r'"(?P[^"]+)" on table "(?P[^"]+)"'
+ )
+
+ match = pattern.match(error_message)
+ if not match:
+ return None
+
+ result = match.groupdict()
+
+ # The child is the referencing table (the one with the FK that needs cascade delete)
+ # The parent is the referenced table (the one being deleted)
+ # The error doesn't include schema, so we return unqualified names
+ child = f'"{result["referencing_table"]}"'
+ parent = f'"{result["referenced_table"]}"'
+
+ return {
+ "child": child,
+ "name": f'"{result["name"]}"',
+ "fk_attrs": None, # Not in error message, will need constraint query
+ "parent": parent,
+ "pk_attrs": None, # Not in error message, will need constraint query
+ }
+
+ def get_indexes_sql(self, schema_name: str, table_name: str) -> str:
+ """Query to get index definitions."""
+ return (
+ f"SELECT indexname, indexdef FROM pg_indexes "
+ f"WHERE schemaname = {self.quote_string(schema_name)} "
+ f"AND tablename = {self.quote_string(table_name)}"
+ )
+
+ def parse_column_info(self, row: dict[str, Any]) -> dict[str, Any]:
+ """
+ Parse PostgreSQL column info into standardized format.
+
+ Parameters
+ ----------
+ row : dict
+ Row from information_schema.columns query with col_description() join.
+
+ Returns
+ -------
+ dict
+ Standardized column info with keys:
+ name, type, nullable, default, comment, key, extra
+ """
+ # For user-defined types (enums), use udt_name instead of data_type
+ # PostgreSQL reports enums as "USER-DEFINED" in data_type
+ data_type = row["data_type"]
+ if data_type == "USER-DEFINED":
+ data_type = row["udt_name"]
+
+ # Reconstruct parametrized types that PostgreSQL splits into separate fields
+ char_max_len = row.get("character_maximum_length")
+ num_precision = row.get("numeric_precision")
+ num_scale = row.get("numeric_scale")
+
+ if data_type == "character" and char_max_len is not None:
+ # char(n) - PostgreSQL reports as "character" with length in separate field
+ data_type = f"char({char_max_len})"
+ elif data_type == "character varying" and char_max_len is not None:
+ # varchar(n)
+ data_type = f"varchar({char_max_len})"
+ elif data_type == "numeric" and num_precision is not None:
+ # numeric(p,s) - reconstruct decimal type
+ if num_scale is not None and num_scale > 0:
+ data_type = f"decimal({num_precision},{num_scale})"
+ else:
+ data_type = f"decimal({num_precision})"
+
+ return {
+ "name": row["column_name"],
+ "type": data_type,
+ "nullable": row["is_nullable"] == "YES",
+ "default": row["column_default"],
+ "comment": row.get("column_comment"), # Retrieved via col_description()
+ "key": "", # PostgreSQL key info retrieved separately
+ "extra": "", # PostgreSQL doesn't have auto_increment in same way
+ }
+
+ # =========================================================================
+ # Transactions
+ # =========================================================================
+
+ def start_transaction_sql(self, isolation_level: str | None = None) -> str:
+ """Generate BEGIN statement for PostgreSQL."""
+ if isolation_level:
+ return f"BEGIN ISOLATION LEVEL {isolation_level}"
+ return "BEGIN"
+
+ def commit_sql(self) -> str:
+ """Generate COMMIT statement."""
+ return "COMMIT"
+
+ def rollback_sql(self) -> str:
+ """Generate ROLLBACK statement."""
+ return "ROLLBACK"
+
+ # =========================================================================
+ # Functions and Expressions
+ # =========================================================================
+
+ def current_timestamp_expr(self, precision: int | None = None) -> str:
+ """
+ CURRENT_TIMESTAMP expression for PostgreSQL.
+
+ Parameters
+ ----------
+ precision : int, optional
+ Fractional seconds precision (0-6).
+
+ Returns
+ -------
+ str
+ CURRENT_TIMESTAMP or CURRENT_TIMESTAMP(n).
+ """
+ if precision is not None:
+ return f"CURRENT_TIMESTAMP({precision})"
+ return "CURRENT_TIMESTAMP"
+
+ def interval_expr(self, value: int, unit: str) -> str:
+ """
+ INTERVAL expression for PostgreSQL.
+
+ Parameters
+ ----------
+ value : int
+ Interval value.
+ unit : str
+ Time unit (singular: 'second', 'minute', 'hour', 'day').
+
+ Returns
+ -------
+ str
+ INTERVAL 'n units' (e.g., "INTERVAL '5 seconds'").
+ """
+ # PostgreSQL uses plural unit names and quotes
+ unit_plural = unit.lower() + "s" if not unit.endswith("s") else unit.lower()
+ return f"INTERVAL '{value} {unit_plural}'"
+
+ def current_user_expr(self) -> str:
+ """PostgreSQL current user expression."""
+ return "current_user"
+
+ def json_path_expr(self, column: str, path: str, return_type: str | None = None) -> str:
+ """
+ Generate PostgreSQL jsonb_extract_path_text() expression.
+
+ Parameters
+ ----------
+ column : str
+ Column name containing JSON data.
+ path : str
+ JSON path (e.g., 'field' or 'nested.field').
+ return_type : str, optional
+ Return type specification for casting (e.g., 'float', 'decimal(10,2)').
+
+ Returns
+ -------
+ str
+ PostgreSQL jsonb_extract_path_text() expression, with optional cast.
+
+ Examples
+ --------
+ >>> adapter.json_path_expr('data', 'field')
+ 'jsonb_extract_path_text("data", \\'field\\')'
+ >>> adapter.json_path_expr('data', 'nested.field')
+ 'jsonb_extract_path_text("data", \\'nested\\', \\'field\\')'
+ >>> adapter.json_path_expr('data', 'value', 'float')
+ 'jsonb_extract_path_text("data", \\'value\\')::float'
+ """
+ quoted_col = self.quote_identifier(column)
+ # Split path by '.' for nested access, handling array notation
+ path_parts = []
+ for part in path.split("."):
+ # Handle array access like field[0]
+ if "[" in part:
+ base, rest = part.split("[", 1)
+ path_parts.append(base)
+ # Extract array indices
+ indices = rest.rstrip("]").split("][")
+ path_parts.extend(indices)
+ else:
+ path_parts.append(part)
+ path_args = ", ".join(f"'{part}'" for part in path_parts)
+ expr = f"jsonb_extract_path_text({quoted_col}, {path_args})"
+ # Add cast if return type specified
+ if return_type:
+ # Map DataJoint types to PostgreSQL types
+ pg_type = return_type.lower()
+ if pg_type in ("unsigned", "signed"):
+ pg_type = "integer"
+ elif pg_type == "double":
+ pg_type = "double precision"
+ expr = f"({expr})::{pg_type}"
+ return expr
+
+ def translate_expression(self, expr: str) -> str:
+ """
+ Translate SQL expression for PostgreSQL compatibility.
+
+ Converts MySQL-specific functions to PostgreSQL equivalents:
+ - GROUP_CONCAT(col) → STRING_AGG(col::text, ',')
+ - GROUP_CONCAT(col SEPARATOR 'sep') → STRING_AGG(col::text, 'sep')
+
+ Parameters
+ ----------
+ expr : str
+ SQL expression that may contain function calls.
+
+ Returns
+ -------
+ str
+ Translated expression for PostgreSQL.
+ """
+ import re
+
+ # GROUP_CONCAT(col) → STRING_AGG(col::text, ',')
+ # GROUP_CONCAT(col SEPARATOR 'sep') → STRING_AGG(col::text, 'sep')
+ def replace_group_concat(match):
+ inner = match.group(1).strip()
+ # Check for SEPARATOR clause
+ sep_match = re.match(r"(.+?)\s+SEPARATOR\s+(['\"])(.+?)\2", inner, re.IGNORECASE)
+ if sep_match:
+ col = sep_match.group(1).strip()
+ sep = sep_match.group(3)
+ return f"STRING_AGG({col}::text, '{sep}')"
+ else:
+ return f"STRING_AGG({inner}::text, ',')"
+
+ expr = re.sub(r"GROUP_CONCAT\s*\((.+?)\)", replace_group_concat, expr, flags=re.IGNORECASE)
+
+ # Replace simple functions FIRST before complex patterns
+ # CURDATE() → CURRENT_DATE
+ expr = re.sub(r"CURDATE\s*\(\s*\)", "CURRENT_DATE", expr, flags=re.IGNORECASE)
+
+ # NOW() → CURRENT_TIMESTAMP
+ expr = re.sub(r"\bNOW\s*\(\s*\)", "CURRENT_TIMESTAMP", expr, flags=re.IGNORECASE)
+
+ # YEAR(date) → EXTRACT(YEAR FROM date)::int
+ expr = re.sub(r"\bYEAR\s*\(\s*([^)]+)\s*\)", r"EXTRACT(YEAR FROM \1)::int", expr, flags=re.IGNORECASE)
+
+ # MONTH(date) → EXTRACT(MONTH FROM date)::int
+ expr = re.sub(r"\bMONTH\s*\(\s*([^)]+)\s*\)", r"EXTRACT(MONTH FROM \1)::int", expr, flags=re.IGNORECASE)
+
+ # DAY(date) → EXTRACT(DAY FROM date)::int
+ expr = re.sub(r"\bDAY\s*\(\s*([^)]+)\s*\)", r"EXTRACT(DAY FROM \1)::int", expr, flags=re.IGNORECASE)
+
+ # TIMESTAMPDIFF(YEAR, d1, d2) → EXTRACT(YEAR FROM AGE(d2, d1))::int
+ # Use a more robust regex that handles the comma-separated arguments
+ def replace_timestampdiff(match):
+ unit = match.group(1).upper()
+ date1 = match.group(2).strip()
+ date2 = match.group(3).strip()
+ if unit == "YEAR":
+ return f"EXTRACT(YEAR FROM AGE({date2}, {date1}))::int"
+ elif unit == "MONTH":
+ return f"(EXTRACT(YEAR FROM AGE({date2}, {date1})) * 12 + EXTRACT(MONTH FROM AGE({date2}, {date1})))::int"
+ elif unit == "DAY":
+ return f"({date2}::date - {date1}::date)"
+ else:
+ return f"EXTRACT({unit} FROM AGE({date2}, {date1}))::int"
+
+ # Match TIMESTAMPDIFF with proper argument parsing
+ # The arguments are: unit, date1, date2 - we need to handle identifiers and CURRENT_DATE
+ expr = re.sub(
+ r"TIMESTAMPDIFF\s*\(\s*(\w+)\s*,\s*([^,]+)\s*,\s*([^)]+)\s*\)",
+ replace_timestampdiff,
+ expr,
+ flags=re.IGNORECASE,
+ )
+
+ # SUM(expr='value') → SUM((expr='value')::int) for PostgreSQL boolean handling
+ # This handles patterns like SUM(sex='F') which produce boolean in PostgreSQL
+ def replace_sum_comparison(match):
+ inner = match.group(1).strip()
+ # Check if inner contains a comparison operator
+ if re.search(r"[=<>!]", inner) and not inner.startswith("("):
+ return f"SUM(({inner})::int)"
+ return match.group(0) # Return unchanged if no comparison
+
+ expr = re.sub(r"\bSUM\s*\(\s*([^)]+)\s*\)", replace_sum_comparison, expr, flags=re.IGNORECASE)
+
+ return expr
+
+ # =========================================================================
+ # DDL Generation
+ # =========================================================================
+
+ def format_column_definition(
+ self,
+ name: str,
+ sql_type: str,
+ nullable: bool = False,
+ default: str | None = None,
+ comment: str | None = None,
+ ) -> str:
+ """
+ Format a column definition for PostgreSQL DDL.
+
+ Examples
+ --------
+ >>> adapter.format_column_definition('user_id', 'bigint', nullable=False, comment='user ID')
+ '"user_id" bigint NOT NULL'
+ """
+ parts = [self.quote_identifier(name), sql_type]
+ if default:
+ parts.append(default)
+ elif not nullable:
+ parts.append("NOT NULL")
+ # Note: PostgreSQL comments handled separately via COMMENT ON
+ return " ".join(parts)
+
+ def table_options_clause(self, comment: str | None = None) -> str:
+ """
+ Generate PostgreSQL table options clause (empty - no ENGINE in PostgreSQL).
+
+ Examples
+ --------
+ >>> adapter.table_options_clause('test table')
+ ''
+ >>> adapter.table_options_clause()
+ ''
+ """
+ return "" # PostgreSQL uses COMMENT ON TABLE separately
+
+ def table_comment_ddl(self, full_table_name: str, comment: str) -> str | None:
+ """
+ Generate COMMENT ON TABLE statement for PostgreSQL.
+
+ Examples
+ --------
+ >>> adapter.table_comment_ddl('"schema"."table"', 'test comment')
+ 'COMMENT ON TABLE "schema"."table" IS \\'test comment\\''
+ """
+ # Escape single quotes by doubling them
+ escaped_comment = comment.replace("'", "''")
+ return f"COMMENT ON TABLE {full_table_name} IS '{escaped_comment}'"
+
+ def column_comment_ddl(self, full_table_name: str, column_name: str, comment: str) -> str | None:
+ """
+ Generate COMMENT ON COLUMN statement for PostgreSQL.
+
+ Examples
+ --------
+ >>> adapter.column_comment_ddl('"schema"."table"', 'column', 'test comment')
+ 'COMMENT ON COLUMN "schema"."table"."column" IS \\'test comment\\''
+ """
+ quoted_col = self.quote_identifier(column_name)
+ # Escape single quotes by doubling them (PostgreSQL string literal syntax)
+ escaped_comment = comment.replace("'", "''")
+ return f"COMMENT ON COLUMN {full_table_name}.{quoted_col} IS '{escaped_comment}'"
+
+ def enum_type_ddl(self, type_name: str, values: list[str]) -> str | None:
+ """
+ Generate CREATE TYPE statement for PostgreSQL enum.
+
+ Examples
+ --------
+ >>> adapter.enum_type_ddl('status_type', ['active', 'inactive'])
+ 'CREATE TYPE "status_type" AS ENUM (\\'active\\', \\'inactive\\')'
+ """
+ quoted_values = ", ".join(f"'{v}'" for v in values)
+ return f"CREATE TYPE {self.quote_identifier(type_name)} AS ENUM ({quoted_values})"
+
+ def replica_identity_ddl(self, full_table_name: str, mode: str) -> str:
+ """
+ Generate ALTER TABLE ... REPLICA IDENTITY statement.
+
+ Controls how much of the old row PostgreSQL writes to WAL on UPDATE/DELETE.
+ ``"default"`` logs only primary-key columns; ``"full"`` logs the entire row.
+ Required by some CDC tools (e.g. Databricks Lakehouse Sync) that need the
+ full pre-image to drive Slowly-Changing-Dimension history.
+
+ The ALTER is metadata-only, instant, and idempotent — re-applying the same
+ mode is a no-op at the storage layer.
+
+ Examples
+ --------
+ >>> adapter.replica_identity_ddl('"schema"."table"', 'full')
+ 'ALTER TABLE "schema"."table" REPLICA IDENTITY FULL'
+ >>> adapter.replica_identity_ddl('"schema"."table"', 'default')
+ 'ALTER TABLE "schema"."table" REPLICA IDENTITY DEFAULT'
+ """
+ if mode not in ("default", "full"):
+ from ..errors import DataJointError
+
+ raise DataJointError(f"Unsupported replica_identity mode: {mode!r}. Expected 'default' or 'full'.")
+ return f"ALTER TABLE {full_table_name} REPLICA IDENTITY {mode.upper()}"
+
+ def get_pending_enum_ddl(self, schema_name: str) -> list[str]:
+ """
+ Get DDL statements for pending enum types and clear the pending list.
+
+ PostgreSQL requires CREATE TYPE statements before using enum types in
+ column definitions. This method returns DDL for enum types accumulated
+ during type conversion and clears the pending list.
+
+ Parameters
+ ----------
+ schema_name : str
+ Schema name to qualify enum type names.
+
+ Returns
+ -------
+ list[str]
+ List of CREATE TYPE statements (if any pending).
+ """
+ ddl_statements = []
+ if hasattr(self, "_pending_enum_types") and self._pending_enum_types:
+ for type_name, values in self._pending_enum_types.items():
+ # Generate CREATE TYPE with schema qualification
+ quoted_type = f"{self.quote_identifier(schema_name)}.{self.quote_identifier(type_name)}"
+ quoted_values = ", ".join(f"'{v}'" for v in values)
+ ddl_statements.append(f"CREATE TYPE {quoted_type} AS ENUM ({quoted_values})")
+ self._pending_enum_types = {}
+ return ddl_statements
+
+ def job_metadata_columns(self) -> list[str]:
+ """
+ Return PostgreSQL-specific job metadata column definitions.
+
+ Examples
+ --------
+ >>> adapter.job_metadata_columns()
+ ['"_job_start_time" timestamp DEFAULT NULL',
+ '"_job_duration" real DEFAULT NULL',
+ '"_job_version" varchar(64) DEFAULT \\'\\'']
+ """
+ return [
+ '"_job_start_time" timestamp DEFAULT NULL',
+ '"_job_duration" real DEFAULT NULL',
+ "\"_job_version\" varchar(64) DEFAULT ''",
+ ]
+
+ # =========================================================================
+ # Error Translation
+ # =========================================================================
+
+ def translate_error(self, error: Exception, query: str = "") -> Exception:
+ """
+ Translate PostgreSQL error to DataJoint exception.
+
+ Parameters
+ ----------
+ error : Exception
+ PostgreSQL exception (typically psycopg2 error).
+ query : str, optional
+ SQL query that caused the error (for context).
+
+ Returns
+ -------
+ Exception
+ DataJoint exception or original error.
+ """
+ if not hasattr(error, "pgcode"):
+ return error
+
+ pgcode = error.pgcode
+
+ # PostgreSQL error code mapping
+ # Reference: https://www.postgresql.org/docs/current/errcodes-appendix.html
+ match pgcode:
+ # Integrity constraint violations
+ case "23505": # unique_violation
+ return errors.DuplicateError(str(error))
+ case "23503": # foreign_key_violation
+ return errors.IntegrityError(str(error))
+ case "23502": # not_null_violation
+ return errors.MissingAttributeError(str(error))
+
+ # Syntax errors
+ case "42601": # syntax_error
+ return errors.QuerySyntaxError(str(error), "")
+
+ # Undefined errors
+ case "42P01": # undefined_table
+ return errors.MissingTableError(str(error), "")
+ case "42703": # undefined_column
+ return errors.UnknownAttributeError(str(error))
+
+ # Connection errors
+ case "08006" | "08003" | "08000": # connection_failure
+ return errors.LostConnectionError(str(error))
+ case "57P01": # admin_shutdown
+ return errors.LostConnectionError(str(error))
+
+ # Access errors
+ case "42501": # insufficient_privilege
+ return errors.AccessError("Insufficient privileges.", str(error), "")
+
+ # All other errors pass through unchanged
+ case _:
+ return error
+
+ # =========================================================================
+ # Native Type Validation
+ # =========================================================================
+
+ def validate_native_type(self, type_str: str) -> bool:
+ """
+ Check if a native PostgreSQL type string is valid.
+
+ Parameters
+ ----------
+ type_str : str
+ Type string to validate.
+
+ Returns
+ -------
+ bool
+ True if valid PostgreSQL type.
+ """
+ type_lower = type_str.lower().strip()
+
+ # PostgreSQL native types (simplified validation)
+ valid_types = {
+ # Integer types
+ "smallint",
+ "integer",
+ "int",
+ "bigint",
+ "smallserial",
+ "serial",
+ "bigserial",
+ # Floating point
+ "real",
+ "double precision",
+ "numeric",
+ "decimal",
+ # String types
+ "char",
+ "varchar",
+ "text",
+ # Binary
+ "bytea",
+ # Boolean
+ "boolean",
+ "bool",
+ # Temporal types
+ "date",
+ "time",
+ "timetz",
+ "timestamp",
+ "timestamptz",
+ "interval",
+ # UUID
+ "uuid",
+ # JSON
+ "json",
+ "jsonb",
+ # Network types
+ "inet",
+ "cidr",
+ "macaddr",
+ # Geometric types
+ "point",
+ "line",
+ "lseg",
+ "box",
+ "path",
+ "polygon",
+ "circle",
+ # Other
+ "money",
+ "xml",
+ }
+
+ # Extract base type (before parentheses or brackets)
+ base_type = type_lower.split("(")[0].split("[")[0].strip()
+
+ return base_type in valid_types
+
+ # =========================================================================
+ # PostgreSQL-Specific Enum Handling
+ # =========================================================================
+
+ def create_enum_type_sql(
+ self,
+ schema: str,
+ table: str,
+ column: str,
+ values: list[str],
+ ) -> str:
+ """
+ Generate CREATE TYPE statement for PostgreSQL enum.
+
+ Parameters
+ ----------
+ schema : str
+ Schema name.
+ table : str
+ Table name.
+ column : str
+ Column name.
+ values : list[str]
+ Enum values.
+
+ Returns
+ -------
+ str
+ CREATE TYPE ... AS ENUM statement.
+ """
+ type_name = f"{schema}_{table}_{column}_enum"
+ quoted_values = ", ".join(self.quote_string(v) for v in values)
+ return f"CREATE TYPE {self.quote_identifier(type_name)} AS ENUM ({quoted_values})"
+
+ def drop_enum_type_sql(self, schema: str, table: str, column: str) -> str:
+ """
+ Generate DROP TYPE statement for PostgreSQL enum.
+
+ Parameters
+ ----------
+ schema : str
+ Schema name.
+ table : str
+ Table name.
+ column : str
+ Column name.
+
+ Returns
+ -------
+ str
+ DROP TYPE statement.
+ """
+ type_name = f"{schema}_{table}_{column}_enum"
+ return f"DROP TYPE IF EXISTS {self.quote_identifier(type_name)} CASCADE"
+
+ def get_table_enum_types_sql(self, schema_name: str, table_name: str) -> str:
+ """
+ Query to get enum types used by a table's columns.
+
+ Parameters
+ ----------
+ schema_name : str
+ Schema name.
+ table_name : str
+ Table name.
+
+ Returns
+ -------
+ str
+ SQL query that returns enum type names (schema-qualified).
+ """
+ return f"""
+ SELECT DISTINCT
+ n.nspname || '.' || t.typname as enum_type
+ FROM pg_catalog.pg_type t
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
+ JOIN pg_catalog.pg_attribute a ON a.atttypid = t.oid
+ JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
+ JOIN pg_catalog.pg_namespace cn ON cn.oid = c.relnamespace
+ WHERE t.typtype = 'e'
+ AND cn.nspname = {self.quote_string(schema_name)}
+ AND c.relname = {self.quote_string(table_name)}
+ """
+
+ def drop_enum_types_for_table(self, schema_name: str, table_name: str) -> list[str]:
+ """
+ Generate DROP TYPE statements for all enum types used by a table.
+
+ Parameters
+ ----------
+ schema_name : str
+ Schema name.
+ table_name : str
+ Table name.
+
+ Returns
+ -------
+ list[str]
+ List of DROP TYPE IF EXISTS statements.
+ """
+ # Returns list of DDL statements - caller should execute query first
+ # to get actual enum types, then call this with results
+ return [] # Placeholder - actual implementation requires query execution
+
+ def drop_enum_type_ddl(self, enum_type_name: str) -> str:
+ """
+ Generate DROP TYPE IF EXISTS statement for a PostgreSQL enum.
+
+ Parameters
+ ----------
+ enum_type_name : str
+ Fully qualified enum type name (schema.typename).
+
+ Returns
+ -------
+ str
+ DROP TYPE IF EXISTS statement with CASCADE.
+ """
+ # Split schema.typename and quote each part
+ parts = enum_type_name.split(".")
+ if len(parts) == 2:
+ qualified_name = f"{self.quote_identifier(parts[0])}.{self.quote_identifier(parts[1])}"
+ else:
+ qualified_name = self.quote_identifier(enum_type_name)
+ return f"DROP TYPE IF EXISTS {qualified_name} CASCADE"
diff --git a/src/datajoint/autopopulate.py b/src/datajoint/autopopulate.py
new file mode 100644
index 000000000..24d6b17aa
--- /dev/null
+++ b/src/datajoint/autopopulate.py
@@ -0,0 +1,789 @@
+"""This module defines class dj.AutoPopulate"""
+
+from __future__ import annotations
+
+import contextlib
+import datetime
+import inspect
+import logging
+import multiprocessing as mp
+import signal
+import traceback
+from typing import TYPE_CHECKING, Any, Generator
+
+from .errors import DataJointError, LostConnectionError
+from .expression import AndList, QueryExpression
+
+if TYPE_CHECKING:
+ from .jobs import Job
+ from .table import Table
+
+# noinspection PyExceptionInherit,PyCallingNonCallable
+
+logger = logging.getLogger(__name__.split(".")[0])
+
+
+# --- helper functions for multiprocessing --
+
+
+def _initialize_populate(table: Table, jobs: Job | None, populate_kwargs: dict[str, Any]) -> None:
+ """
+ Initialize a worker process for multiprocessing.
+
+ Saves the unpickled table to the current process and reconnects to database.
+
+ Parameters
+ ----------
+ table : Table
+ Table instance to populate.
+ jobs : Job or None
+ Job management object or None for direct mode.
+ populate_kwargs : dict
+ Arguments for _populate1().
+ """
+ process = mp.current_process()
+ process.table = table
+ process.jobs = jobs
+ process.populate_kwargs = populate_kwargs
+ table.connection.connect() # reconnect
+
+
+def _call_populate1(key: dict[str, Any]) -> bool | tuple[dict[str, Any], Any]:
+ """
+ Call _populate1() for a single key in the worker process.
+
+ Parameters
+ ----------
+ key : dict
+ Primary key specifying job to compute.
+
+ Returns
+ -------
+ bool or tuple
+ Result from _populate1().
+ """
+ process = mp.current_process()
+ return process.table._populate1(key, process.jobs, **process.populate_kwargs)
+
+
+class AutoPopulate:
+ """
+ Mixin class that adds automated population to Table classes.
+
+ Auto-populated tables (Computed, Imported) inherit from both Table and
+ AutoPopulate. They must implement the ``make()`` method that computes
+ and inserts data for one primary key.
+
+ Attributes
+ ----------
+ key_source : QueryExpression
+ Query yielding keys to be populated. Default is join of FK parents.
+ jobs : Job
+ Job table (``~~table_name``) for distributed processing.
+
+ Notes
+ -----
+ Subclasses may override ``key_source`` to customize population scope.
+ """
+
+ _key_source = None
+ _allow_insert = False
+ _jobs = None
+
+ class _JobsDescriptor:
+ """Descriptor allowing jobs access on both class and instance."""
+
+ def __get__(self, obj, objtype=None):
+ """
+ Access the job table for this auto-populated table.
+
+ The job table (``~~table_name``) is created lazily on first access.
+ It tracks job status, priority, scheduling, and error information
+ for distributed populate operations.
+
+ Can be accessed on either the class or an instance::
+
+ # Both work equivalently
+ Analysis.jobs.refresh()
+ Analysis().jobs.refresh()
+
+ Returns
+ -------
+ Job
+ Job management object for this table.
+ """
+ if obj is None:
+ # Accessed on class - instantiate first
+ obj = objtype()
+ if obj._jobs is None:
+ from .jobs import Job
+
+ obj._jobs = Job(obj)
+ if not obj._jobs.is_declared:
+ obj._jobs.declare()
+ return obj._jobs
+
+ jobs: Job = _JobsDescriptor()
+
+ def _declare_check(self, primary_key: list[str], fk_attribute_map: dict[str, tuple[str, str]]) -> None:
+ """
+ Validate FK-only primary key constraint for auto-populated tables.
+
+ Auto-populated tables (Computed/Imported) must derive all primary key
+ attributes from foreign key references. This ensures proper job granularity
+ for distributed populate operations.
+
+ Parameters
+ ----------
+ primary_key : list
+ List of primary key attribute names.
+ fk_attribute_map : dict
+ Mapping of child_attr -> (parent_table, parent_attr).
+
+ Raises
+ ------
+ DataJointError
+ If native (non-FK) PK attributes are found, unless bypassed via
+ ``dj.config.jobs.allow_new_pk_fields_in_computed_tables = True``.
+ """
+ # Check if validation is bypassed
+ if self.connection._config.jobs.allow_new_pk_fields_in_computed_tables:
+ return
+
+ # Check for native (non-FK) primary key attributes
+ native_pk_attrs = [attr for attr in primary_key if attr not in fk_attribute_map]
+
+ if native_pk_attrs:
+ raise DataJointError(
+ f"Auto-populated table `{self.full_table_name}` has non-FK primary key "
+ f"attribute(s): {', '.join(native_pk_attrs)}. "
+ f"Computed and Imported tables must derive all primary key attributes "
+ f"from foreign key references. The make() method is called once per entity "
+ f"(row) in the table. If you need to compute multiple entities per job, "
+ f"define a Part table to store them. "
+ f"To bypass this restriction, set: dj.config.jobs.allow_new_pk_fields_in_computed_tables = True"
+ )
+
+ @property
+ def key_source(self) -> QueryExpression:
+ """
+ Query expression yielding keys to be populated.
+
+ Returns the primary key values to be passed sequentially to ``make()``
+ when ``populate()`` is called. The default is the join of parent tables
+ referenced from the primary key.
+
+ Returns
+ -------
+ QueryExpression
+ Expression yielding keys for population.
+
+ Notes
+ -----
+ Subclasses may override to change the scope or granularity of make calls.
+ """
+
+ def _rename_attributes(table, props):
+ return (
+ table.proj(**{attr: ref for attr, ref in props["attr_map"].items() if attr != ref})
+ if props["aliased"]
+ else table.proj()
+ )
+
+ if self._key_source is None:
+ parents = self.parents(primary=True, as_objects=True, foreign_key_info=True)
+ if not parents:
+ raise DataJointError("A table must have dependencies from its primary key for auto-populate to work")
+ self._key_source = _rename_attributes(*parents[0])
+ for q in parents[1:]:
+ self._key_source *= _rename_attributes(*q)
+ return self._key_source
+
+ def make(self, key: dict[str, Any], **kwargs) -> None | Generator[Any, Any, None]:
+ """
+ Compute and insert data for one key.
+
+ Must be implemented by subclasses to perform automated computation.
+ The method implements three steps:
+
+ 1. Fetch data from parent tables, restricted by the given key
+ 2. Compute secondary attributes based on the fetched data
+ 3. Insert the new row(s) into the current table
+
+ Parameters
+ ----------
+ key : dict
+ Primary key value identifying the entity to compute.
+ **kwargs
+ Keyword arguments passed from ``populate(make_kwargs=...)``.
+ These are forwarded to ``make_fetch`` for the tripartite pattern.
+
+ Raises
+ ------
+ NotImplementedError
+ If neither ``make()`` nor the tripartite methods are implemented.
+
+ Notes
+ -----
+ **Simple make**: Implement as a regular method that performs all three
+ steps in a single database transaction. Must return None.
+
+ **Tripartite make**: For long-running computations, implement:
+
+ - ``make_fetch(key, **kwargs)``: Fetch data from parent tables
+ - ``make_compute(key, *fetched_data)``: Compute results
+ - ``make_insert(key, *computed_result)``: Insert results
+
+ The tripartite pattern allows computation outside the transaction,
+ with referential integrity checking before commit.
+ """
+
+ if not (hasattr(self, "make_fetch") and hasattr(self, "make_insert") and hasattr(self, "make_compute")):
+ # user must implement `make`
+ raise NotImplementedError(
+ "Subclasses of AutoPopulate must implement the method `make` "
+ "or (`make_fetch` + `make_compute` + `make_insert`)"
+ )
+
+ # User has implemented `_fetch`, `_compute`, and `_insert` methods instead
+
+ # Step 1: Fetch data from parent tables
+ fetched_data = self.make_fetch(key, **kwargs) # fetched_data is a tuple
+ computed_result = yield fetched_data # passed as input into make_compute
+
+ # Step 2: If computed result is not passed in, compute the result
+ if computed_result is None:
+ # this is only executed in the first invocation
+ computed_result = self.make_compute(key, *fetched_data)
+ yield computed_result # this is passed to the second invocation of make
+
+ # Step 3: Insert the computed result into the current table.
+ self.make_insert(key, *computed_result)
+ yield
+
+ def _jobs_to_do(self, restrictions: tuple) -> QueryExpression:
+ """
+ Return the query yielding keys to be computed.
+
+ Parameters
+ ----------
+ restrictions : tuple
+ Conditions to filter key_source.
+
+ Returns
+ -------
+ QueryExpression
+ Keys derived from key_source that need computation.
+ """
+ if self.restriction:
+ raise DataJointError(
+ "Cannot call populate on a restricted table. Instead, pass conditions to populate() as arguments."
+ )
+ todo = self.key_source
+
+ # key_source is a QueryExpression subclass -- trigger instantiation
+ if inspect.isclass(todo) and issubclass(todo, QueryExpression):
+ todo = todo()
+
+ if not isinstance(todo, QueryExpression):
+ raise DataJointError("Invalid key_source value")
+
+ try:
+ # check if target lacks any attributes from the primary key of key_source
+ raise DataJointError(
+ "The populate target lacks attribute %s "
+ "from the primary key of key_source"
+ % next(name for name in todo.heading.primary_key if name not in self.heading)
+ )
+ except StopIteration:
+ pass
+ return (todo & AndList(restrictions)).proj()
+
+ def populate(
+ self,
+ *restrictions: Any,
+ suppress_errors: bool = False,
+ return_exception_objects: bool = False,
+ reserve_jobs: bool = False,
+ max_calls: int | None = None,
+ display_progress: bool = False,
+ processes: int = 1,
+ make_kwargs: dict[str, Any] | None = None,
+ priority: int | None = None,
+ refresh: bool | None = None,
+ ) -> dict[str, Any]:
+ """
+ Populate the table by calling ``make()`` for unpopulated keys.
+
+ Calls ``make(key)`` for every primary key in ``key_source`` for which
+ there is not already a row in this table.
+
+ Parameters
+ ----------
+ *restrictions
+ Conditions to filter key_source.
+ suppress_errors : bool, optional
+ If True, collect errors instead of raising. Default False.
+ return_exception_objects : bool, optional
+ If True, return exception objects instead of messages. Default False.
+ reserve_jobs : bool, optional
+ If True, use job table for distributed processing. Default False.
+ max_calls : int, optional
+ Maximum number of ``make()`` calls.
+ display_progress : bool, optional
+ If True, show progress bar. Default False.
+ processes : int, optional
+ Number of worker processes. Default 1.
+ make_kwargs : dict, optional
+ Keyword arguments passed to each ``make()`` call.
+ priority : int, optional
+ (Distributed mode) Only process jobs at this priority or higher.
+ refresh : bool, optional
+ (Distributed mode) Refresh job queue before processing.
+ Default from ``config.jobs.auto_refresh``.
+
+ Returns
+ -------
+ dict
+ ``{"success_count": int, "error_list": list}``.
+
+ Notes
+ -----
+ **Direct mode** (``reserve_jobs=False``): Keys computed from
+ ``(key_source & restrictions) - target``. No job table. Suitable for
+ single-worker, development, and debugging.
+
+ **Distributed mode** (``reserve_jobs=True``): Uses job table
+ (``~~table_name``) for multi-worker coordination with priority and
+ status tracking.
+ """
+ if self.connection.in_transaction:
+ raise DataJointError("Populate cannot be called during a transaction.")
+
+ if reserve_jobs:
+ return self._populate_distributed(
+ *restrictions,
+ suppress_errors=suppress_errors,
+ return_exception_objects=return_exception_objects,
+ max_calls=max_calls,
+ display_progress=display_progress,
+ processes=processes,
+ make_kwargs=make_kwargs,
+ priority=priority,
+ refresh=refresh,
+ )
+ else:
+ return self._populate_direct(
+ *restrictions,
+ suppress_errors=suppress_errors,
+ return_exception_objects=return_exception_objects,
+ max_calls=max_calls,
+ display_progress=display_progress,
+ processes=processes,
+ make_kwargs=make_kwargs,
+ )
+
+ def _populate_direct(
+ self,
+ *restrictions,
+ suppress_errors,
+ return_exception_objects,
+ max_calls,
+ display_progress,
+ processes,
+ make_kwargs,
+ ):
+ """
+ Populate without job table coordination.
+
+ Computes keys directly from key_source, suitable for single-worker
+ execution, development, and debugging.
+ """
+ from tqdm import tqdm
+
+ keys = (self._jobs_to_do(restrictions) - self.proj()).keys()
+
+ logger.debug("Found %d keys to populate" % len(keys))
+
+ keys = keys[:max_calls]
+ nkeys = len(keys)
+
+ error_list = []
+ success_list = []
+
+ if nkeys:
+ processes = min(_ for _ in (processes, nkeys, mp.cpu_count()) if _)
+
+ populate_kwargs = dict(
+ suppress_errors=suppress_errors,
+ return_exception_objects=return_exception_objects,
+ make_kwargs=make_kwargs,
+ )
+
+ if processes == 1:
+ for key in tqdm(keys, desc=self.__class__.__name__) if display_progress else keys:
+ status = self._populate1(key, jobs=None, **populate_kwargs)
+ if status is True:
+ success_list.append(1)
+ elif isinstance(status, tuple):
+ error_list.append(status)
+ else:
+ assert status is False
+ else:
+ # spawn multiple processes
+ self.connection.close()
+ # Remove SSLContext if present (MySQL-specific, not pickleable)
+ if hasattr(self.connection._conn, "ctx"):
+ del self.connection._conn.ctx
+ with (
+ mp.Pool(processes, _initialize_populate, (self, None, populate_kwargs)) as pool,
+ tqdm(desc="Processes: ", total=nkeys) if display_progress else contextlib.nullcontext() as progress_bar,
+ ):
+ for status in pool.imap(_call_populate1, keys, chunksize=1):
+ if status is True:
+ success_list.append(1)
+ elif isinstance(status, tuple):
+ error_list.append(status)
+ else:
+ assert status is False
+ if display_progress:
+ progress_bar.update()
+ self.connection.connect()
+
+ return {
+ "success_count": sum(success_list),
+ "error_list": error_list,
+ }
+
+ def _populate_distributed(
+ self,
+ *restrictions,
+ suppress_errors,
+ return_exception_objects,
+ max_calls,
+ display_progress,
+ processes,
+ make_kwargs,
+ priority,
+ refresh,
+ ):
+ """
+ Populate with job table coordination.
+
+ Uses job table for multi-worker coordination, priority scheduling,
+ and status tracking.
+ """
+ from tqdm import tqdm
+
+ # Define a signal handler for SIGTERM
+ def handler(signum, frame):
+ logger.info("Populate terminated by SIGTERM")
+ raise SystemExit("SIGTERM received")
+
+ old_handler = signal.signal(signal.SIGTERM, handler)
+
+ try:
+ # Refresh job queue if configured
+ if refresh is None:
+ refresh = self.connection._config.jobs.auto_refresh
+ if refresh:
+ # Use delay=-1 to ensure jobs are immediately schedulable
+ # (avoids race condition with scheduled_time <= CURRENT_TIMESTAMP(3) check)
+ self.jobs.refresh(*restrictions, priority=priority, delay=-1)
+
+ # Fetch pending jobs ordered by priority (use CURRENT_TIMESTAMP(3) for datetime(3) precision)
+ pending_query = self.jobs.pending & "scheduled_time <= CURRENT_TIMESTAMP(3)"
+ if restrictions:
+ # Restrict to jobs whose keys match the caller's restrictions.
+ # semantic_check=False is required because the jobs table PK has
+ # different lineage than key_source (see jobs.py refresh()).
+ pending_query = pending_query.restrict(self._jobs_to_do(restrictions), semantic_check=False)
+ if priority is not None:
+ pending_query = pending_query & f"priority <= {priority}"
+
+ keys = pending_query.keys(order_by="priority ASC, scheduled_time ASC", limit=max_calls)
+
+ logger.debug("Found %d pending jobs to populate" % len(keys))
+
+ nkeys = len(keys)
+ error_list = []
+ success_list = []
+
+ if nkeys:
+ processes = min(_ for _ in (processes, nkeys, mp.cpu_count()) if _)
+
+ populate_kwargs = dict(
+ suppress_errors=suppress_errors,
+ return_exception_objects=return_exception_objects,
+ make_kwargs=make_kwargs,
+ )
+
+ if processes == 1:
+ for key in tqdm(keys, desc=self.__class__.__name__) if display_progress else keys:
+ status = self._populate1(key, jobs=self.jobs, **populate_kwargs)
+ if status is True:
+ success_list.append(1)
+ elif isinstance(status, tuple):
+ error_list.append(status)
+ # status is False means job was already reserved
+ else:
+ # spawn multiple processes
+ self.connection.close()
+ if hasattr(self.connection._conn, "ctx"):
+ del self.connection._conn.ctx # SSLContext is not pickleable
+ with (
+ mp.Pool(processes, _initialize_populate, (self, self.jobs, populate_kwargs)) as pool,
+ tqdm(desc="Processes: ", total=nkeys)
+ if display_progress
+ else contextlib.nullcontext() as progress_bar,
+ ):
+ for status in pool.imap(_call_populate1, keys, chunksize=1):
+ if status is True:
+ success_list.append(1)
+ elif isinstance(status, tuple):
+ error_list.append(status)
+ if display_progress:
+ progress_bar.update()
+ self.connection.connect()
+
+ return {
+ "success_count": sum(success_list),
+ "error_list": error_list,
+ }
+ finally:
+ signal.signal(signal.SIGTERM, old_handler)
+
+ def _populate1(
+ self,
+ key: dict[str, Any],
+ jobs: Job | None,
+ suppress_errors: bool,
+ return_exception_objects: bool,
+ make_kwargs: dict[str, Any] | None = None,
+ ) -> bool | tuple[dict[str, Any], Any]:
+ """
+ Populate table for one key, calling make() inside a transaction.
+
+ Parameters
+ ----------
+ key : dict
+ Primary key specifying the job to populate.
+ jobs : Job or None
+ Job object for distributed mode, None for direct mode.
+ suppress_errors : bool
+ If True, errors are suppressed and returned.
+ return_exception_objects : bool
+ If True, return exception objects instead of messages.
+ make_kwargs : dict, optional
+ Keyword arguments passed to ``make()``.
+
+ Returns
+ -------
+ bool or tuple
+ True if make() succeeded, False if skipped (already done or reserved),
+ (key, error) tuple if suppress_errors=True and error occurred.
+ """
+ import time
+
+ import deepdiff
+
+ # use the legacy `_make_tuples` callback.
+ make = self._make_tuples if hasattr(self, "_make_tuples") else self.make
+
+ # Try to reserve the job (distributed mode only)
+ if jobs is not None and not jobs.reserve(key):
+ return False
+
+ start_time = time.time()
+
+ # if make is a generator, transaction can be delayed until the final stage
+ is_generator = inspect.isgeneratorfunction(make)
+ if not is_generator:
+ self.connection.start_transaction()
+
+ if key in self: # already populated
+ if not is_generator:
+ self.connection.cancel_transaction()
+ if jobs is not None:
+ jobs.complete(key)
+ return False
+
+ logger.jobs(f"Making {key} -> {self.full_table_name}")
+ self.__class__._allow_insert = True
+
+ try:
+ if not is_generator:
+ make(dict(key), **(make_kwargs or {}))
+ else:
+ # tripartite make - transaction is delayed until the final stage
+ gen = make(dict(key), **(make_kwargs or {}))
+ fetched_data = next(gen)
+ fetch_hash = deepdiff.DeepHash(fetched_data, ignore_iterable_order=False)[fetched_data]
+ computed_result = next(gen) # perform the computation
+ # fetch and insert inside a transaction
+ self.connection.start_transaction()
+ gen = make(dict(key), **(make_kwargs or {})) # restart make
+ fetched_data = next(gen)
+ if (
+ fetch_hash != deepdiff.DeepHash(fetched_data, ignore_iterable_order=False)[fetched_data]
+ ): # raise error if fetched data has changed
+ raise DataJointError("Referential integrity failed! The `make_fetch` data has changed")
+ gen.send(computed_result) # insert
+
+ except (KeyboardInterrupt, SystemExit, Exception) as error:
+ try:
+ self.connection.cancel_transaction()
+ except LostConnectionError:
+ pass
+ error_message = "{exception}{msg}".format(
+ exception=error.__class__.__name__,
+ msg=": " + str(error) if str(error) else "",
+ )
+ logger.jobs(f"Error making {key} -> {self.full_table_name} - {error_message}")
+ if jobs is not None:
+ jobs.error(key, error_message=error_message, error_stack=traceback.format_exc())
+ if not suppress_errors or isinstance(error, SystemExit):
+ raise
+ else:
+ logger.error(error)
+ return key, error if return_exception_objects else error_message
+ else:
+ self.connection.commit_transaction()
+ duration = time.time() - start_time
+ logger.jobs(f"Success making {key} -> {self.full_table_name}")
+
+ # Update hidden job metadata if table has the columns
+ if self._has_job_metadata_attrs():
+ from .jobs import _get_job_version
+
+ self._update_job_metadata(
+ key,
+ start_time=datetime.datetime.fromtimestamp(start_time),
+ duration=duration,
+ version=_get_job_version(self.connection._config),
+ )
+
+ if jobs is not None:
+ jobs.complete(key, duration=duration)
+ return True
+ finally:
+ self.__class__._allow_insert = False
+
+ def progress(self, *restrictions: Any, display: bool = False) -> tuple[int, int]:
+ """
+ Report the progress of populating the table.
+
+ Uses a single aggregation query to efficiently compute both total and
+ remaining counts.
+
+ Parameters
+ ----------
+ *restrictions
+ Conditions to restrict key_source.
+ display : bool, optional
+ If True, log the progress. Default False.
+
+ Returns
+ -------
+ tuple
+ (remaining, total) - number of keys yet to populate and total keys.
+ """
+ todo = self._jobs_to_do(restrictions)
+
+ # Get primary key attributes from key_source for join condition
+ # These are the "job keys" - the granularity at which populate() works
+ pk_attrs = todo.primary_key
+ assert pk_attrs, "key_source must have a primary key"
+
+ # Find common attributes between key_source and self for the join
+ # This handles cases where self has additional PK attributes
+ common_attrs = [attr for attr in pk_attrs if attr in self.heading.names]
+
+ if not common_attrs:
+ # No common attributes - fall back to two-query method
+ total = len(todo)
+ remaining = len(todo - self.proj())
+ else:
+ # Build a single query that computes both total and remaining
+ # Using LEFT JOIN with COUNT(DISTINCT) to handle 1:many relationships
+ todo_sql = todo.make_sql()
+ target_sql = self.make_sql()
+
+ # Get adapter for backend-specific quoting
+ adapter = self.connection.adapter
+ q = adapter.quote_identifier
+
+ # Alias names for subqueries
+ ks_alias = q("$ks")
+ tgt_alias = q("$tgt")
+
+ # Build join condition on common attributes
+ join_cond = " AND ".join(f"{ks_alias}.{q(attr)} = {tgt_alias}.{q(attr)}" for attr in common_attrs)
+
+ # Build DISTINCT key expression for counting unique jobs
+ # Use CONCAT_WS for composite keys (supported by both MySQL and PostgreSQL)
+ if len(pk_attrs) == 1:
+ distinct_key = f"{ks_alias}.{q(pk_attrs[0])}"
+ null_check = f"{tgt_alias}.{q(common_attrs[0])}"
+ else:
+ key_cols = ", ".join(f"{ks_alias}.{q(attr)}" for attr in pk_attrs)
+ distinct_key = f"CONCAT_WS('|', {key_cols})"
+ null_check = f"{tgt_alias}.{q(common_attrs[0])}"
+
+ # Single aggregation query:
+ # - COUNT(DISTINCT key) gives total unique jobs in key_source
+ # - Remaining = jobs where no matching target row exists
+ sql = f"""
+ SELECT
+ COUNT(DISTINCT {distinct_key}) AS total,
+ COUNT(DISTINCT CASE WHEN {null_check} IS NULL THEN {distinct_key} END) AS remaining
+ FROM ({todo_sql}) AS {ks_alias}
+ LEFT JOIN ({target_sql}) AS {tgt_alias} ON {join_cond}
+ """
+
+ result = self.connection.query(sql).fetchone()
+ total, remaining = result
+
+ if display:
+ logger.info(
+ "%-20s" % self.__class__.__name__
+ + " Completed %d of %d (%2.1f%%) %s"
+ % (
+ total - remaining,
+ total,
+ 100 - 100 * remaining / (total + 1e-12),
+ datetime.datetime.strftime(datetime.datetime.now(), "%Y-%m-%d %H:%M:%S"),
+ ),
+ )
+ return remaining, total
+
+ def _has_job_metadata_attrs(self):
+ """Check if table has hidden job metadata columns."""
+ # Access _attributes directly to include hidden attributes
+ all_attrs = self.heading._attributes
+ return all_attrs is not None and "_job_start_time" in all_attrs
+
+ def _update_job_metadata(self, key, start_time, duration, version):
+ """
+ Update hidden job metadata for the given key.
+
+ Parameters
+ ----------
+ key : dict
+ Primary key identifying the row(s) to update.
+ start_time : datetime
+ When computation started.
+ duration : float
+ Computation duration in seconds.
+ version : str
+ Code version (truncated to 64 chars).
+ """
+ from .condition import make_condition
+
+ pk_condition = make_condition(self, key, set())
+ self.connection.query(
+ f"UPDATE {self.full_table_name} SET "
+ "_job_start_time=%s, _job_duration=%s, _job_version=%s "
+ f"WHERE {pk_condition}",
+ args=(start_time, duration, version[:64] if version else ""),
+ )
diff --git a/src/datajoint/blob.py b/src/datajoint/blob.py
new file mode 100644
index 000000000..633f55b79
--- /dev/null
+++ b/src/datajoint/blob.py
@@ -0,0 +1,637 @@
+"""
+Binary serialization for DataJoint blob storage.
+
+Provides (de)serialization for Python/NumPy objects with backward compatibility
+for MATLAB mYm-format blobs. Supports arrays, scalars, structs, cells, and
+Python built-in types (dict, list, tuple, set, datetime, UUID, Decimal).
+"""
+
+from __future__ import annotations
+
+import collections
+import datetime
+import uuid
+import zlib
+from decimal import Decimal
+from itertools import repeat
+
+import numpy as np
+
+from .errors import DataJointError
+
+deserialize_lookup = {
+ 0: {"dtype": None, "scalar_type": "UNKNOWN"},
+ 1: {"dtype": None, "scalar_type": "CELL"},
+ 2: {"dtype": None, "scalar_type": "STRUCT"},
+ 3: {"dtype": np.dtype("bool"), "scalar_type": "LOGICAL"},
+ 4: {"dtype": np.dtype("c"), "scalar_type": "CHAR"},
+ 5: {"dtype": np.dtype("O"), "scalar_type": "VOID"},
+ 6: {"dtype": np.dtype("float64"), "scalar_type": "DOUBLE"},
+ 7: {"dtype": np.dtype("float32"), "scalar_type": "SINGLE"},
+ 8: {"dtype": np.dtype("int8"), "scalar_type": "INT8"},
+ 9: {"dtype": np.dtype("uint8"), "scalar_type": "UINT8"},
+ 10: {"dtype": np.dtype("int16"), "scalar_type": "INT16"},
+ 11: {"dtype": np.dtype("uint16"), "scalar_type": "UINT16"},
+ 12: {"dtype": np.dtype("int32"), "scalar_type": "INT32"},
+ 13: {"dtype": np.dtype("uint32"), "scalar_type": "UINT32"},
+ 14: {"dtype": np.dtype("int64"), "scalar_type": "INT64"},
+ 15: {"dtype": np.dtype("uint64"), "scalar_type": "UINT64"},
+ 16: {"dtype": None, "scalar_type": "FUNCTION"},
+ 65_536: {"dtype": np.dtype("datetime64[Y]"), "scalar_type": "DATETIME64[Y]"},
+ 65_537: {"dtype": np.dtype("datetime64[M]"), "scalar_type": "DATETIME64[M]"},
+ 65_538: {"dtype": np.dtype("datetime64[W]"), "scalar_type": "DATETIME64[W]"},
+ 65_539: {"dtype": np.dtype("datetime64[D]"), "scalar_type": "DATETIME64[D]"},
+ 65_540: {"dtype": np.dtype("datetime64[h]"), "scalar_type": "DATETIME64[h]"},
+ 65_541: {"dtype": np.dtype("datetime64[m]"), "scalar_type": "DATETIME64[m]"},
+ 65_542: {"dtype": np.dtype("datetime64[s]"), "scalar_type": "DATETIME64[s]"},
+ 65_543: {"dtype": np.dtype("datetime64[ms]"), "scalar_type": "DATETIME64[ms]"},
+ 65_544: {"dtype": np.dtype("datetime64[us]"), "scalar_type": "DATETIME64[us]"},
+ 65_545: {"dtype": np.dtype("datetime64[ns]"), "scalar_type": "DATETIME64[ns]"},
+ 65_546: {"dtype": np.dtype("datetime64[ps]"), "scalar_type": "DATETIME64[ps]"},
+ 65_547: {"dtype": np.dtype("datetime64[fs]"), "scalar_type": "DATETIME64[fs]"},
+ 65_548: {"dtype": np.dtype("datetime64[as]"), "scalar_type": "DATETIME64[as]"},
+}
+serialize_lookup = {
+ v["dtype"]: {"type_id": k, "scalar_type": v["scalar_type"]}
+ for k, v in deserialize_lookup.items()
+ if v["dtype"] is not None
+}
+
+
+compression = {b"ZL123\0": zlib.decompress}
+
+# runtime setting to read integers as 32-bit to read blobs created by the 32-bit
+# version of the mYm library for MATLAB
+use_32bit_dims = False
+
+
+def len_u64(obj):
+ return np.uint64(len(obj)).tobytes()
+
+
+def len_u32(obj):
+ return np.uint32(len(obj)).tobytes()
+
+
+class MatCell(np.ndarray):
+ """
+ NumPy ndarray subclass representing a MATLAB cell array.
+
+ Used to distinguish cell arrays from regular arrays during serialization
+ for MATLAB compatibility.
+ """
+
+ pass
+
+
+class MatStruct(np.recarray):
+ """
+ NumPy recarray subclass representing a MATLAB struct array.
+
+ Used to distinguish struct arrays from regular recarrays during
+ serialization for MATLAB compatibility.
+ """
+
+ pass
+
+
+class Blob:
+ """
+ Binary serializer/deserializer for DataJoint blob storage.
+
+ Handles packing Python objects into binary format and unpacking binary
+ data back to Python objects. Supports two protocols:
+
+ - ``mYm``: Original MATLAB-compatible format (default)
+ - ``dj0``: Extended format for Python-specific types
+
+ Parameters
+ ----------
+ squeeze : bool, optional
+ If True, remove singleton dimensions from arrays and convert
+ 0-dimensional arrays to scalars. Default False.
+
+ Attributes
+ ----------
+ protocol : bytes or None
+ Current serialization protocol (``b"mYm\\0"`` or ``b"dj0\\0"``).
+ """
+
+ def __init__(self, squeeze: bool = False) -> None:
+ self._squeeze = squeeze
+ self._blob = None
+ self._pos = 0
+ self.protocol = None
+
+ def set_dj0(self) -> None:
+ """Switch to dj0 protocol for extended type support."""
+ self.protocol = b"dj0\0" # when using new blob features
+
+ def squeeze(self, array: np.ndarray, convert_to_scalar: bool = True) -> np.ndarray:
+ """
+ Remove singleton dimensions from an array.
+
+ Parameters
+ ----------
+ array : np.ndarray
+ Input array.
+ convert_to_scalar : bool, optional
+ If True, convert 0-dimensional arrays to Python scalars. Default True.
+
+ Returns
+ -------
+ np.ndarray or scalar
+ Squeezed array or scalar value.
+ """
+ if not self._squeeze:
+ return array
+ array = array.squeeze()
+ return array.item() if array.ndim == 0 and convert_to_scalar else array
+
+ def unpack(self, blob):
+ # PostgreSQL returns bytea as memoryview; convert to bytes for string operations
+ if isinstance(blob, memoryview):
+ blob = bytes(blob)
+ self._blob = blob
+ try:
+ # decompress
+ prefix = next(p for p in compression if self._blob[self._pos :].startswith(p))
+ except StopIteration:
+ pass # assume uncompressed but could be unrecognized compression
+ else:
+ self._pos += len(prefix)
+ blob_size = self.read_value()
+ blob = compression[prefix](self._blob[self._pos :])
+ if len(blob) != blob_size:
+ raise DataJointError(f"Blob size mismatch: expected {blob_size}, got {len(blob)}")
+ self._blob = blob
+ self._pos = 0
+ blob_format = self.read_zero_terminated_string()
+ if blob_format in ("mYm", "dj0"):
+ return self.read_blob(n_bytes=len(self._blob) - self._pos)
+
+ def read_blob(self, n_bytes=None):
+ start = self._pos
+ data_structure_code = chr(self.read_value("uint8"))
+ try:
+ call = {
+ # MATLAB-compatible, inherited from original mYm
+ "A": self.read_array, # matlab-compatible numeric arrays and scalars with ndim==0
+ "P": self.read_sparse_array, # matlab sparse array -- not supported yet
+ "S": self.read_struct, # matlab struct array
+ "C": self.read_cell_array, # matlab cell array
+ # basic data types
+ "\xff": self.read_none, # None
+ "\x01": self.read_tuple, # a Sequence (e.g. tuple)
+ "\x02": self.read_list, # a MutableSequence (e.g. list)
+ "\x03": self.read_set, # a Set
+ "\x04": self.read_dict, # a Mapping (e.g. dict)
+ "\x05": self.read_string, # a UTF8-encoded string
+ "\x06": self.read_bytes, # a ByteString
+ "\x0a": self.read_int, # unbounded scalar int
+ "\x0b": self.read_bool, # scalar boolean
+ "\x0c": self.read_complex, # scalar 128-bit complex number
+ "\x0d": self.read_float, # scalar 64-bit float
+ "F": self.read_recarray, # numpy array with fields, including recarrays
+ "d": self.read_decimal, # a decimal
+ "t": self.read_datetime, # date, time, or datetime
+ "u": self.read_uuid, # UUID
+ }[data_structure_code]
+ except KeyError:
+ raise DataJointError('Unknown data structure code "%s". Upgrade datajoint.' % data_structure_code)
+ v = call()
+ if n_bytes is not None and self._pos - start != n_bytes:
+ raise DataJointError("Blob length check failed! Invalid blob")
+ return v
+
+ def pack_blob(self, obj):
+ # original mYm-based serialization from datajoint-matlab
+ if isinstance(obj, MatCell):
+ return self.pack_cell_array(obj)
+ if isinstance(obj, MatStruct):
+ return self.pack_struct(obj)
+ if isinstance(obj, np.ndarray) and obj.dtype.fields is None:
+ return self.pack_array(obj)
+
+ # blob types in the expanded dj0 blob format
+ self.set_dj0()
+ if not isinstance(obj, (np.ndarray, np.number)):
+ # python built-in data types
+ if isinstance(obj, bool):
+ return self.pack_bool(obj)
+ if isinstance(obj, int):
+ return self.pack_int(obj)
+ if isinstance(obj, complex):
+ return self.pack_complex(obj)
+ if isinstance(obj, float):
+ return self.pack_float(obj)
+ if isinstance(obj, np.ndarray) and obj.dtype.fields:
+ return self.pack_recarray(np.array(obj))
+ if isinstance(obj, (np.number, np.datetime64)):
+ return self.pack_array(np.array(obj))
+ if isinstance(obj, (bool, np.bool_)):
+ return self.pack_array(np.array(obj))
+ if isinstance(obj, (float, int, complex)):
+ return self.pack_array(np.array(obj))
+ if isinstance(obj, (datetime.datetime, datetime.date, datetime.time)):
+ return self.pack_datetime(obj)
+ if isinstance(obj, Decimal):
+ return self.pack_decimal(obj)
+ if isinstance(obj, uuid.UUID):
+ return self.pack_uuid(obj)
+ if isinstance(obj, collections.abc.Mapping):
+ return self.pack_dict(obj)
+ if isinstance(obj, str):
+ return self.pack_string(obj)
+ if isinstance(obj, (bytes, bytearray)):
+ return self.pack_bytes(obj)
+ if isinstance(obj, collections.abc.MutableSequence):
+ return self.pack_list(obj)
+ if isinstance(obj, collections.abc.Sequence):
+ return self.pack_tuple(obj)
+ if isinstance(obj, collections.abc.Set):
+ return self.pack_set(obj)
+ if obj is None:
+ return self.pack_none()
+ raise DataJointError("Packing object of type %s currently not supported!" % type(obj))
+
+ def read_array(self):
+ n_dims = int(self.read_value())
+ shape = self.read_value(count=n_dims)
+ n_elem = np.prod(shape, dtype=int)
+ dtype_id, is_complex = self.read_value("uint32", 2)
+
+ # Get dtype from type id
+ dtype = deserialize_lookup[dtype_id]["dtype"]
+
+ # Check if name is void
+ if deserialize_lookup[dtype_id]["scalar_type"] == "VOID":
+ data = np.array(
+ list(self.read_blob(self.read_value()) for _ in range(n_elem)),
+ dtype=np.dtype("O"),
+ )
+ # Check if name is char
+ elif deserialize_lookup[dtype_id]["scalar_type"] == "CHAR":
+ # compensate for MATLAB packing of char arrays
+ data = self.read_value(dtype, count=2 * n_elem)
+ data = data[::2].astype("U1")
+ if n_dims == 2 and shape[0] == 1 or n_dims == 1:
+ compact = data.squeeze()
+ data = compact if compact.shape == () else np.array("".join(data.squeeze()))
+ shape = (1,)
+ else:
+ data = self.read_value(dtype, count=n_elem)
+ if is_complex:
+ data = data + 1j * self.read_value(dtype, count=n_elem)
+ return self.squeeze(data.reshape(shape, order="F"))
+
+ def pack_array(self, array: np.ndarray) -> bytes:
+ """
+ Serialize a NumPy array into bytes.
+
+ Parameters
+ ----------
+ array : np.ndarray
+ Array to serialize. Scalars are encoded with ndim=0.
+
+ Returns
+ -------
+ bytes
+ Serialized array data.
+ """
+ if "datetime64" in array.dtype.name:
+ self.set_dj0()
+ blob = b"A" + np.uint64(array.ndim).tobytes() + np.array(array.shape, dtype=np.uint64).tobytes()
+ is_complex = np.iscomplexobj(array)
+ if is_complex:
+ array, imaginary = np.real(array), np.imag(array)
+ try:
+ type_id = serialize_lookup[array.dtype]["type_id"]
+ except KeyError:
+ # U is for unicode string
+ if array.dtype.char == "U":
+ type_id = serialize_lookup[np.dtype("O")]["type_id"]
+ else:
+ raise DataJointError(f"Type {array.dtype} is ambiguous or unknown")
+
+ blob += np.array([type_id, is_complex], dtype=np.uint32).tobytes()
+ if array.dtype.char == "U" or serialize_lookup[array.dtype]["scalar_type"] == "VOID":
+ blob += b"".join(len_u64(it) + it for it in (self.pack_blob(e) for e in array.flatten(order="F")))
+ self.set_dj0() # not supported by original mym
+ elif serialize_lookup[array.dtype]["scalar_type"] == "CHAR":
+ blob += array.view(np.uint8).astype(np.uint16).tobytes() # convert to 16-bit chars for MATLAB
+ else: # numeric arrays
+ if array.ndim == 0: # not supported by original mym
+ self.set_dj0()
+ blob += array.tobytes(order="F")
+ if is_complex:
+ blob += imaginary.tobytes(order="F")
+ return blob
+
+ def read_recarray(self):
+ """
+ Serialize an np.ndarray with fields, including recarrays
+ """
+ n_fields = self.read_value("uint32")
+ if not n_fields:
+ return np.array(None) # empty array
+ field_names = [self.read_zero_terminated_string() for _ in range(n_fields)]
+ arrays = [self.read_blob() for _ in range(n_fields)]
+ rec = np.empty(
+ arrays[0].shape,
+ np.dtype([(f, t.dtype) for f, t in zip(field_names, arrays)]),
+ )
+ for f, t in zip(field_names, arrays):
+ rec[f] = t
+ return rec.view(np.recarray)
+
+ def pack_recarray(self, array):
+ """Serialize a Matlab struct array"""
+ return (
+ b"F"
+ + len_u32(array.dtype)
+ + "\0".join(array.dtype.names).encode() # number of fields
+ + b"\0"
+ + b"".join( # field names
+ (self.pack_recarray(array[f]) if array[f].dtype.fields else self.pack_array(array[f]))
+ for f in array.dtype.names
+ )
+ )
+
+ def read_sparse_array(self):
+ raise DataJointError("datajoint-python does not yet support sparse arrays. Issue (#590)")
+
+ def read_int(self):
+ return int.from_bytes(self.read_binary(self.read_value("uint16")), byteorder="little", signed=True)
+
+ @staticmethod
+ def pack_int(v):
+ n_bytes = v.bit_length() // 8 + 1
+ if not (0 < n_bytes <= 0xFFFF):
+ raise DataJointError("Integers are limited to 65535 bytes")
+ return b"\x0a" + np.uint16(n_bytes).tobytes() + v.to_bytes(n_bytes, byteorder="little", signed=True)
+
+ def read_bool(self):
+ return bool(self.read_value("bool"))
+
+ @staticmethod
+ def pack_bool(v):
+ return b"\x0b" + np.array(v, dtype="bool").tobytes()
+
+ def read_complex(self):
+ return complex(self.read_value("complex128"))
+
+ @staticmethod
+ def pack_complex(v):
+ return b"\x0c" + np.array(v, dtype="complex128").tobytes()
+
+ def read_float(self):
+ return float(self.read_value("float64"))
+
+ @staticmethod
+ def pack_float(v):
+ return b"\x0d" + np.array(v, dtype="float64").tobytes()
+
+ def read_decimal(self):
+ return Decimal(self.read_string())
+
+ @staticmethod
+ def pack_decimal(d):
+ s = str(d)
+ return b"d" + len_u64(s) + s.encode()
+
+ def read_string(self):
+ return self.read_binary(self.read_value()).decode()
+
+ @staticmethod
+ def pack_string(s):
+ blob = s.encode()
+ return b"\5" + len_u64(blob) + blob
+
+ def read_bytes(self):
+ return self.read_binary(self.read_value())
+
+ @staticmethod
+ def pack_bytes(s):
+ return b"\6" + len_u64(s) + s
+
+ def read_none(self):
+ pass
+
+ @staticmethod
+ def pack_none():
+ return b"\xff"
+
+ def read_tuple(self):
+ return tuple(self.read_blob(self.read_value()) for _ in range(self.read_value()))
+
+ def pack_tuple(self, t):
+ return b"\1" + len_u64(t) + b"".join(len_u64(it) + it for it in (self.pack_blob(i) for i in t))
+
+ def read_list(self):
+ return list(self.read_blob(self.read_value()) for _ in range(self.read_value()))
+
+ def pack_list(self, t):
+ return b"\2" + len_u64(t) + b"".join(len_u64(it) + it for it in (self.pack_blob(i) for i in t))
+
+ def read_set(self):
+ return set(self.read_blob(self.read_value()) for _ in range(self.read_value()))
+
+ def pack_set(self, t):
+ return b"\3" + len_u64(t) + b"".join(len_u64(it) + it for it in (self.pack_blob(i) for i in t))
+
+ def read_dict(self):
+ return dict((self.read_blob(self.read_value()), self.read_blob(self.read_value())) for _ in range(self.read_value()))
+
+ def pack_dict(self, d):
+ return (
+ b"\4"
+ + len_u64(d)
+ + b"".join(
+ b"".join((len_u64(it) + it) for it in packed) for packed in (map(self.pack_blob, pair) for pair in d.items())
+ )
+ )
+
+ def read_struct(self):
+ """deserialize matlab struct"""
+ n_dims = self.read_value()
+ shape = self.read_value(count=n_dims)
+ n_elem = np.prod(shape, dtype=int)
+ n_fields = self.read_value("uint32")
+ if not n_fields:
+ return np.array(None) # empty array
+ field_names = [self.read_zero_terminated_string() for _ in range(n_fields)]
+ raw_data = [tuple(self.read_blob(n_bytes=int(self.read_value())) for _ in range(n_fields)) for __ in range(n_elem)]
+ data = np.array(raw_data, dtype=list(zip(field_names, repeat(object))))
+ return self.squeeze(data.reshape(shape, order="F"), convert_to_scalar=False).view(MatStruct)
+
+ def pack_struct(self, array):
+ """Serialize a Matlab struct array"""
+ return (
+ b"S"
+ + np.array((array.ndim,) + array.shape, dtype=np.uint64).tobytes()
+ + len_u32(array.dtype.names) # dimensionality
+ + "\0".join(array.dtype.names).encode() # number of fields
+ + b"\0"
+ + b"".join( # field names
+ len_u64(it) + it for it in (self.pack_blob(e) for rec in array.flatten(order="F") for e in rec)
+ )
+ ) # values
+
+ def read_cell_array(self):
+ """
+ Deserialize MATLAB cell array.
+
+ Handles edge cases from MATLAB:
+ - Empty cell arrays ({})
+ - Cell arrays with empty elements ({[], [], []})
+ - Nested arrays ({[1,2], [3,4,5]}) - ragged arrays
+ - Cell matrices with mixed content
+ """
+ n_dims = self.read_value()
+ shape = self.read_value(count=n_dims)
+ n_elem = int(np.prod(shape))
+ result = [self.read_blob(n_bytes=self.read_value()) for _ in range(n_elem)]
+
+ # Handle empty cell array
+ if n_elem == 0:
+ return np.empty(0, dtype=object).view(MatCell)
+
+ # Use object dtype to handle ragged/nested arrays without reshape errors.
+ # This avoids NumPy's array homogeneity requirements that cause failures
+ # with MATLAB cell arrays containing arrays of different sizes.
+ arr = np.empty(n_elem, dtype=object)
+ arr[:] = result
+ return self.squeeze(arr.reshape(shape, order="F"), convert_to_scalar=False).view(MatCell)
+
+ def pack_cell_array(self, array):
+ return (
+ b"C"
+ + np.array((array.ndim,) + array.shape, dtype=np.uint64).tobytes()
+ + b"".join(len_u64(it) + it for it in (self.pack_blob(e) for e in array.flatten(order="F")))
+ )
+
+ def read_datetime(self):
+ """deserialize datetime.date, .time, or .datetime"""
+ date, time = self.read_value("int32"), self.read_value("int64")
+ date = datetime.date(year=date // 10000, month=(date // 100) % 100, day=date % 100) if date >= 0 else None
+ time = (
+ datetime.time(
+ hour=(time // 10000000000) % 100,
+ minute=(time // 100000000) % 100,
+ second=(time // 1000000) % 100,
+ microsecond=time % 1000000,
+ )
+ if time >= 0
+ else None
+ )
+ return time and date and datetime.datetime.combine(date, time) or time or date
+
+ @staticmethod
+ def pack_datetime(d):
+ if isinstance(d, datetime.datetime):
+ date, time = d.date(), d.time()
+ elif isinstance(d, datetime.date):
+ date, time = d, None
+ else:
+ date, time = None, d
+ return b"t" + (
+ np.int32(-1 if date is None else (date.year * 100 + date.month) * 100 + date.day).tobytes()
+ + np.int64(
+ -1 if time is None else ((time.hour * 100 + time.minute) * 100 + time.second) * 1000000 + time.microsecond
+ ).tobytes()
+ )
+
+ def read_uuid(self):
+ q = self.read_binary(16)
+ return uuid.UUID(bytes=q)
+
+ @staticmethod
+ def pack_uuid(obj):
+ return b"u" + obj.bytes
+
+ def read_zero_terminated_string(self):
+ target = self._blob.find(b"\0", self._pos)
+ data = self._blob[self._pos : target].decode()
+ self._pos = target + 1
+ return data
+
+ def read_value(self, dtype=None, count=1):
+ if dtype is None:
+ dtype = "uint32" if use_32bit_dims else "uint64"
+ data = np.frombuffer(self._blob, dtype=dtype, count=count, offset=self._pos)
+ self._pos += data.dtype.itemsize * data.size
+ return data[0] if count == 1 else data
+
+ def read_binary(self, size):
+ self._pos += int(size)
+ return self._blob[self._pos - int(size) : self._pos]
+
+ def pack(self, obj, compress):
+ self.protocol = b"mYm\0" # will be replaced with dj0 if new features are used
+ blob = self.pack_blob(obj) # this may reset the protocol and must precede protocol evaluation
+ blob = self.protocol + blob
+ if compress and len(blob) > 1000:
+ compressed = b"ZL123\0" + len_u64(blob) + zlib.compress(blob)
+ if len(compressed) < len(blob):
+ blob = compressed
+ return blob
+
+
+def pack(obj, compress: bool = True) -> bytes:
+ """
+ Serialize a Python object to binary blob format.
+
+ Parameters
+ ----------
+ obj : any
+ Object to serialize. Supports NumPy arrays, Python scalars,
+ collections (dict, list, tuple, set), datetime objects, UUID,
+ Decimal, and MATLAB-compatible MatCell/MatStruct.
+ compress : bool, optional
+ If True (default), compress blobs larger than 1000 bytes using zlib.
+
+ Returns
+ -------
+ bytes
+ Serialized binary data.
+
+ Raises
+ ------
+ DataJointError
+ If the object type is not supported.
+
+ Examples
+ --------
+ >>> data = np.array([1, 2, 3])
+ >>> blob = pack(data)
+ >>> unpacked = unpack(blob)
+ """
+ return Blob().pack(obj, compress=compress)
+
+
+def unpack(blob: bytes, squeeze: bool = False):
+ """
+ Deserialize a binary blob to a Python object.
+
+ Parameters
+ ----------
+ blob : bytes
+ Binary data from ``pack()`` or MATLAB mYm serialization.
+ squeeze : bool, optional
+ If True, remove singleton dimensions from arrays. Default False.
+
+ Returns
+ -------
+ any
+ Deserialized Python object.
+
+ Examples
+ --------
+ >>> blob = pack({'a': 1, 'b': [1, 2, 3]})
+ >>> data = unpack(blob)
+ >>> data['b']
+ [1, 2, 3]
+ """
+ if blob is not None:
+ return Blob(squeeze=squeeze).unpack(blob)
diff --git a/src/datajoint/builtin_codecs/__init__.py b/src/datajoint/builtin_codecs/__init__.py
new file mode 100644
index 000000000..1f2dd2ec7
--- /dev/null
+++ b/src/datajoint/builtin_codecs/__init__.py
@@ -0,0 +1,77 @@
+"""
+Built-in DataJoint codecs.
+
+This package defines the standard codecs that ship with DataJoint.
+These serve as both useful built-in codecs and as examples for users who
+want to create their own custom codecs.
+
+Built-in Codecs:
+ - ````: Serialize Python objects (in-table storage)
+ - ````: Serialize Python objects (in-store with hash-addressed dedup)
+ - ````: File attachment (in-table storage)
+ - ````: File attachment (in-store with hash-addressed dedup)
+ - ````: Hash-addressed storage with MD5 deduplication (store only)
+ - ``
".join(
+ [
+ "\n".join(["
%s
" % get_html_display_value(tup, name, idx) for name in heading.names])
+ for idx, tup in enumerate(tuples)
+ ]
+ ),
+ count=(("
Total: %d
" % len(rel)) if config["display.show_tuple_count"] else ""),
+ )
diff --git a/src/datajoint/schemas.py b/src/datajoint/schemas.py
new file mode 100644
index 000000000..fa934d569
--- /dev/null
+++ b/src/datajoint/schemas.py
@@ -0,0 +1,838 @@
+"""
+Schema management for DataJoint.
+
+This module provides the Schema class for binding Python table classes to
+database schemas, and utilities for schema introspection and management.
+"""
+
+from __future__ import annotations
+
+import inspect
+import logging
+import re
+import types
+import warnings
+from typing import TYPE_CHECKING, Any
+
+from .errors import AccessError, DataJointError
+from .instance import _get_singleton_connection
+
+if TYPE_CHECKING:
+ from .connection import Connection
+from .heading import Heading
+from .jobs import Job
+from .table import FreeTable, lookup_class_name
+from .user_tables import Computed, Imported, Lookup, Manual, Part, _get_tier
+from .utils import to_camel_case, user_choice
+
+logger = logging.getLogger(__name__.split(".")[0])
+
+
+def ordered_dir(class_: type) -> list[str]:
+ """
+ List class attributes respecting declaration order.
+
+ Similar to the ``dir()`` built-in, but preserves attribute declaration
+ order as much as possible.
+
+ Parameters
+ ----------
+ class_ : type
+ Class to list members for.
+
+ Returns
+ -------
+ list[str]
+ Attributes declared in class_ and its superclasses.
+ """
+ attr_list = list()
+ for c in reversed(class_.mro()):
+ attr_list.extend(e for e in c.__dict__ if e not in attr_list)
+ return attr_list
+
+
+class _Schema:
+ """
+ Decorator that binds table classes to a database schema.
+
+ Schema objects associate Python table classes with database schemas and
+ provide the namespace context for foreign key resolution.
+
+ Parameters
+ ----------
+ schema_name : str, optional
+ Database schema name. If omitted, call ``activate()`` later.
+ context : dict, optional
+ Namespace for foreign key lookup. None uses caller's context.
+ connection : Connection, optional
+ Database connection. Defaults to ``dj.conn()``.
+ create_schema : bool, optional
+ If False, raise error if schema doesn't exist. Default True.
+ create_tables : bool, optional
+ If False, raise error when accessing missing tables.
+ Default from ``dj.config.database.create_tables`` (True unless configured).
+ add_objects : dict, optional
+ Additional objects for the declaration context.
+
+ Examples
+ --------
+ >>> schema = dj.Schema('my_schema')
+ >>> @schema
+ ... class Session(dj.Manual):
+ ... definition = '''
+ ... session_id : int
+ ... '''
+ """
+
+ def __init__(
+ self,
+ schema_name: str | None = None,
+ context: dict[str, Any] | None = None,
+ *,
+ connection: Connection | None = None,
+ create_schema: bool = True,
+ create_tables: bool | None = None,
+ add_objects: dict[str, Any] | None = None,
+ ) -> None:
+ """
+ Initialize the schema object.
+
+ Parameters
+ ----------
+ schema_name : str, optional
+ Database schema name. If omitted, call ``activate()`` later.
+ context : dict, optional
+ Namespace for foreign key lookup. None uses caller's context.
+ connection : Connection, optional
+ Database connection. Defaults to ``dj.conn()``.
+ create_schema : bool, optional
+ If False, raise error if schema doesn't exist. Default True.
+ create_tables : bool, optional
+ If False, raise error when accessing missing tables.
+ Default from ``dj.config.database.create_tables`` (True unless configured).
+ add_objects : dict, optional
+ Additional objects for the declaration context.
+ """
+ self.connection = connection
+ self.database = None
+ self.context = context
+ self.create_schema = create_schema
+ self.create_tables = create_tables # None means "use connection config default"
+ self.add_objects = add_objects
+ self.declare_list = []
+ if schema_name:
+ self.activate(schema_name)
+
+ def is_activated(self) -> bool:
+ """Check if the schema has been activated."""
+ return self.database is not None
+
+ def activate(
+ self,
+ schema_name: str | None = None,
+ *,
+ connection: Connection | None = None,
+ create_schema: bool | None = None,
+ create_tables: bool | None = None,
+ add_objects: dict[str, Any] | None = None,
+ ) -> None:
+ """
+ Associate with a database schema.
+
+ If the schema does not exist, attempts to create it on the server.
+
+ Parameters
+ ----------
+ schema_name : str, optional
+ Database schema name. None asserts schema is already activated.
+ connection : Connection, optional
+ Database connection. Defaults to ``dj.conn()``.
+ create_schema : bool, optional
+ If False, raise error if schema doesn't exist.
+ create_tables : bool, optional
+ If False, raise error when accessing missing tables.
+ add_objects : dict, optional
+ Additional objects for the declaration context.
+
+ Raises
+ ------
+ DataJointError
+ If schema_name is None and schema not yet activated, or if
+ schema already activated for a different database.
+ """
+ if schema_name is None:
+ if self.exists:
+ return
+ raise DataJointError("Please provide a schema_name to activate the schema.")
+ if self.database is not None and self.exists:
+ if self.database == schema_name: # already activated
+ return
+ raise DataJointError("The schema is already activated for schema {db}.".format(db=self.database))
+ if connection is not None:
+ self.connection = connection
+ if self.connection is None:
+ self.connection = _get_singleton_connection()
+ if self.connection._config.get("database.database_prefix"):
+ warnings.warn(
+ "database_prefix is deprecated and will be removed in DataJoint 2.3. "
+ "Use database.name to select a PostgreSQL database instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ self.database = schema_name
+ if create_schema is not None:
+ self.create_schema = create_schema
+ if create_tables is not None:
+ self.create_tables = create_tables
+ if add_objects:
+ self.add_objects = add_objects
+ if not self.exists:
+ if not self.create_schema or not self.database:
+ raise DataJointError(
+ "Database `{name}` has not yet been declared. Set argument create_schema=True to create it.".format(
+ name=schema_name
+ )
+ )
+ # create database
+ logger.debug("Creating schema `{name}`.".format(name=schema_name))
+ try:
+ create_sql = self.connection.adapter.create_schema_sql(schema_name)
+ self.connection.query(create_sql)
+ except AccessError:
+ raise DataJointError(
+ "Schema `{name}` does not exist and could not be created. Check permissions.".format(name=schema_name)
+ )
+ self.connection.register(self)
+
+ # decorate all tables already decorated
+ for cls, context in self.declare_list:
+ if self.add_objects:
+ context = dict(context, **self.add_objects)
+ self._decorate_master(cls, context)
+
+ def _assert_exists(self, message=None):
+ if not self.exists:
+ raise DataJointError(message or "Schema `{db}` has not been created.".format(db=self.database))
+
+ def __call__(self, cls: type, *, context: dict[str, Any] | None = None) -> type:
+ """
+ Bind a table class to this schema. Used as a decorator.
+
+ Parameters
+ ----------
+ cls : type
+ Table class to decorate.
+ context : dict, optional
+ Declaration context. Supplied by make_classes.
+
+ Returns
+ -------
+ type
+ The decorated class.
+
+ Raises
+ ------
+ DataJointError
+ If applied to a Part table (use on master only).
+ """
+ context = context or self.context or inspect.currentframe().f_back.f_locals
+ if issubclass(cls, Part):
+ raise DataJointError("The schema decorator should not be applied to Part tables.")
+ if self.is_activated():
+ self._decorate_master(cls, context)
+ else:
+ self.declare_list.append((cls, context))
+ return cls
+
+ def _decorate_master(self, cls: type, context: dict[str, Any]) -> None:
+ """
+ Process a master table class and its part tables.
+
+ Parameters
+ ----------
+ cls : type
+ Master table class to process.
+ context : dict
+ Declaration context for foreign key resolution.
+ """
+ self._decorate_table(cls, context=dict(context, self=cls, **{cls.__name__: cls}))
+ # Process part tables
+ for part in ordered_dir(cls):
+ if part[0].isupper():
+ part = getattr(cls, part)
+ if inspect.isclass(part) and issubclass(part, Part):
+ part._master = cls
+ # allow addressing master by name or keyword 'master'
+ self._decorate_table(
+ part,
+ context=dict(context, master=cls, self=part, **{cls.__name__: cls}),
+ )
+
+ def _decorate_table(self, table_class: type, context: dict[str, Any], assert_declared: bool = False) -> None:
+ """
+ Assign schema properties to the table class and declare the table.
+
+ Parameters
+ ----------
+ table_class : type
+ Table class to decorate.
+ context : dict
+ Declaration context for foreign key resolution.
+ assert_declared : bool, optional
+ If True, assert table is already declared. Default False.
+ """
+ table_class.database = self.database
+ table_class._connection = self.connection
+ table_class._heading = Heading(
+ table_info=dict(
+ conn=self.connection,
+ database=self.database,
+ table_name=table_class.table_name,
+ context=context,
+ )
+ )
+ table_class._support = [table_class.full_table_name]
+ table_class.declaration_context = context
+
+ # instantiate the class, declare the table if not already
+ instance = table_class()
+ is_declared = instance.is_declared
+ create_tables = (
+ self.create_tables if self.create_tables is not None else self.connection._config.database.create_tables
+ )
+ if not is_declared and not assert_declared and create_tables:
+ instance.declare(context)
+ self.connection.dependencies.clear()
+ elif is_declared and create_tables:
+ # Table already exists — declare() didn't run, so _populate_lineage
+ # didn't either. Scan the already-loaded heading for the symptom
+ # of stale/missing lineage rows (#1454): any PK attribute with
+ # lineage=None indicates the ~lineage table is missing rows for
+ # this table. Only then trigger a refresh — no extra DB queries
+ # on healthy schemas, automatic repair when the bug is present.
+ #
+ # Note: stale-but-non-None rows (DJ version skew that wrote a
+ # different string format) are not auto-detected here; users hit
+ # the tailored "rebuild_lineage" error message on first join.
+ try:
+ pk_lineages = [instance.heading[attr].lineage for attr in instance.primary_key]
+ except Exception:
+ pk_lineages = []
+ if pk_lineages and any(lineage is None for lineage in pk_lineages):
+ instance._refresh_lineage(context)
+ is_declared = is_declared or instance.is_declared
+
+ # add table definition to the doc string
+ if isinstance(table_class.definition, str):
+ table_class.__doc__ = (table_class.__doc__ or "") + "\nTable definition:\n\n" + table_class.definition
+
+ # fill values in Lookup tables from their contents property
+ if isinstance(instance, Lookup) and hasattr(instance, "contents") and is_declared:
+ contents = list(instance.contents)
+ if len(contents) > len(instance):
+ if instance.heading.has_autoincrement:
+ warnings.warn(
+ ("Contents has changed but cannot be inserted because {table} has autoincrement.").format(
+ table=instance.__class__.__name__
+ )
+ )
+ else:
+ instance.insert(contents, skip_duplicates=True)
+
+ def __repr__(self):
+ return "Schema `{name}`\n".format(name=self.database)
+
+ def make_classes(self, into: dict[str, Any] | None = None) -> None:
+ """
+ Create Python table classes for tables in the schema.
+
+ Introspects the database schema and creates appropriate Python classes
+ (Lookup, Manual, Imported, Computed, Part) for tables that don't have
+ corresponding classes in the target namespace.
+
+ Parameters
+ ----------
+ into : dict, optional
+ Namespace to place created classes into. Defaults to caller's
+ local namespace.
+ """
+ self._assert_exists()
+ if into is None:
+ if self.context is not None:
+ into = self.context
+ else:
+ # if into is missing, use the calling namespace
+ frame = inspect.currentframe().f_back
+ into = frame.f_locals
+ del frame
+ adapter = self.connection.adapter
+ tables = [
+ row[0]
+ for row in self.connection.query(adapter.list_tables_sql(self.database))
+ if lookup_class_name(adapter.make_full_table_name(self.database, row[0]), into, 0) is None
+ ]
+ master_classes = (Lookup, Manual, Imported, Computed)
+ part_tables = []
+ for table_name in tables:
+ class_name = to_camel_case(table_name)
+ if class_name not in into:
+ try:
+ cls = next(cls for cls in master_classes if re.fullmatch(cls.tier_regexp, table_name))
+ except StopIteration:
+ if re.fullmatch(Part.tier_regexp, table_name):
+ part_tables.append(table_name)
+ else:
+ # declare and decorate master table classes
+ into[class_name] = self(type(class_name, (cls,), dict()), context=into)
+
+ # attach parts to masters
+ for table_name in part_tables:
+ groups = re.fullmatch(Part.tier_regexp, table_name).groupdict()
+ class_name = to_camel_case(groups["part"])
+ try:
+ master_class = into[to_camel_case(groups["master"])]
+ except KeyError:
+ raise DataJointError("The table %s does not follow DataJoint naming conventions" % table_name)
+ part_class = type(class_name, (Part,), dict(definition=...))
+ part_class._master = master_class
+ self._decorate_table(part_class, context=into, assert_declared=True)
+ setattr(master_class, class_name, part_class)
+
+ def drop(self, prompt: bool | None = None) -> None:
+ """
+ Drop the associated schema and all its tables.
+
+ Parameters
+ ----------
+ prompt : bool, optional
+ If True, show confirmation prompt before dropping.
+ If False, drop without confirmation.
+ If None (default), use ``dj.config['safemode']`` setting.
+
+ Raises
+ ------
+ AccessError
+ If insufficient permissions to drop the schema.
+ """
+ prompt = self.connection._config["safemode"] if prompt is None else prompt
+
+ if not self.exists:
+ logger.info("Schema named `{database}` does not exist. Doing nothing.".format(database=self.database))
+ elif not prompt or user_choice("Proceed to delete entire schema `%s`?" % self.database, default="no") == "yes":
+ logger.debug("Dropping `{database}`.".format(database=self.database))
+ try:
+ drop_sql = self.connection.adapter.drop_schema_sql(self.database)
+ self.connection.query(drop_sql)
+ logger.debug("Schema `{database}` was dropped successfully.".format(database=self.database))
+ except AccessError:
+ raise AccessError(
+ "An attempt to drop schema `{database}` has failed. Check permissions.".format(database=self.database)
+ )
+
+ @property
+ def exists(self) -> bool:
+ """
+ Check if the associated schema exists on the server.
+
+ Returns
+ -------
+ bool
+ True if the schema exists.
+
+ Raises
+ ------
+ DataJointError
+ If schema has not been activated.
+ """
+ if self.database is None:
+ raise DataJointError("Schema must be activated first.")
+ return bool(self.connection.query(self.connection.adapter.schema_exists_sql(self.database)).rowcount)
+
+ @property
+ def lineage_table_exists(self) -> bool:
+ """
+ Check if the ~lineage table exists in this schema.
+
+ Returns
+ -------
+ bool
+ True if the lineage table exists.
+ """
+ from .lineage import lineage_table_exists
+
+ self._assert_exists()
+ return lineage_table_exists(self.connection, self.database)
+
+ @property
+ def lineage(self) -> dict[str, str]:
+ """
+ Get all lineages for tables in this schema.
+
+ Returns
+ -------
+ dict[str, str]
+ Mapping of ``'schema.table.attribute'`` to its lineage origin.
+ """
+ from .lineage import get_schema_lineages
+
+ self._assert_exists()
+ return get_schema_lineages(self.connection, self.database)
+
+ def rebuild_lineage(self) -> None:
+ """
+ Rebuild the ~lineage table for all tables in this schema.
+
+ Recomputes lineage for all attributes by querying FK relationships
+ from the information_schema. Use to restore lineage for schemas that
+ predate the lineage system or after corruption.
+
+ Notes
+ -----
+ After rebuilding, restart the Python kernel and reimport to pick up
+ the new lineage information.
+
+ Upstream schemas (referenced via cross-schema foreign keys) must
+ have their lineage rebuilt first.
+ """
+ from .lineage import rebuild_schema_lineage
+
+ self._assert_exists()
+ rebuild_schema_lineage(self.connection, self.database)
+
+ @property
+ def jobs(self) -> list[Job]:
+ """
+ Return Job objects for auto-populated tables with job tables.
+
+ Only returns Job objects when both the target table and its
+ ``~~table_name`` job table exist in the database. Job tables are
+ created lazily on first access to ``table.jobs`` or
+ ``populate(reserve_jobs=True)``.
+
+ Returns
+ -------
+ list[Job]
+ Job objects for existing job tables.
+ """
+ self._assert_exists()
+ jobs_list = []
+
+ # Get all existing job tables (~~prefix)
+ # Note: %% escapes the % in pymysql/psycopg2
+ adapter = self.connection.adapter
+ sql = adapter.list_tables_sql(self.database, pattern="~~%%")
+ result = self.connection.query(sql).fetchall()
+ existing_job_tables = {row[0] for row in result}
+
+ # Iterate over auto-populated tables and check if their job table exists
+ for table_name in self.list_tables():
+ adapter = self.connection.adapter
+ full_name = adapter.make_full_table_name(self.database, table_name)
+ table = FreeTable(self.connection, full_name)
+ tier = _get_tier(table.full_table_name)
+ if tier in (Computed, Imported):
+ # Compute expected job table name: ~~base_name
+ base_name = table_name.lstrip("_")
+ job_table_name = f"~~{base_name}"
+ if job_table_name in existing_job_tables:
+ jobs_list.append(Job(table))
+
+ return jobs_list
+
+ def list_tables(self) -> list[str]:
+ """
+ Return all user tables in the schema.
+
+ Excludes hidden tables (starting with ``~``) such as ``~lineage``
+ and job tables (``~~``).
+
+ Returns
+ -------
+ list[str]
+ Table names in topological order.
+ """
+ self.connection.dependencies.load()
+ return [
+ t
+ for d, t in (
+ self.connection.adapter.split_full_table_name(table_name)
+ for table_name in self.connection.dependencies.topo_sort()
+ )
+ if d == self.database
+ ]
+
+ def _find_table_name(self, name: str) -> str | None:
+ """
+ Find the actual SQL table name for a given base name.
+
+ Handles tier prefixes: Manual (none), Lookup (#), Imported (_), Computed (__).
+
+ Parameters
+ ----------
+ name : str
+ Base table name without tier prefix.
+
+ Returns
+ -------
+ str or None
+ The actual SQL table name, or None if not found.
+ """
+ tables = self.list_tables()
+ # Check exact match first
+ if name in tables:
+ return name
+ # Check with tier prefixes
+ for prefix in ("", "#", "_", "__"):
+ candidate = f"{prefix}{name}"
+ if candidate in tables:
+ return candidate
+ return None
+
+ def get_table(self, name: str) -> FreeTable:
+ """
+ Get a table instance by name.
+
+ Returns a FreeTable instance for the given table name. This is useful
+ for accessing tables when you don't have the Python class available.
+
+ Parameters
+ ----------
+ name : str
+ Table name (e.g., 'experiment', 'session__trial' for parts).
+ Can be snake_case (SQL name) or CamelCase (class name).
+ Tier prefixes are optional and will be auto-detected.
+
+ Returns
+ -------
+ FreeTable
+ A FreeTable instance for the table.
+
+ Raises
+ ------
+ DataJointError
+ If the table does not exist.
+
+ Examples
+ --------
+ >>> schema = dj.Schema('my_schema')
+ >>> experiment = schema.get_table('experiment')
+ >>> experiment.fetch()
+ """
+ self._assert_exists()
+ # Convert CamelCase to snake_case if needed
+ if name[0].isupper():
+ name = re.sub(r"(? FreeTable:
+ """
+ Get a table instance by name using bracket notation.
+
+ Parameters
+ ----------
+ name : str
+ Table name (snake_case or CamelCase).
+
+ Returns
+ -------
+ FreeTable
+ A FreeTable instance for the table.
+
+ Examples
+ --------
+ >>> schema = dj.Schema('my_schema')
+ >>> schema['Experiment'].fetch()
+ >>> schema['session'].fetch()
+ """
+ return self.get_table(name)
+
+ def __iter__(self):
+ """
+ Iterate over all tables in the schema.
+
+ Yields FreeTable instances for each table in topological order.
+
+ Yields
+ ------
+ FreeTable
+ Table instances in dependency order.
+
+ Examples
+ --------
+ >>> for table in schema:
+ ... print(table.full_table_name, len(table))
+ """
+ self._assert_exists()
+ for table_name in self.list_tables():
+ yield self.get_table(table_name)
+
+ def __contains__(self, name: str) -> bool:
+ """
+ Check if a table exists in the schema.
+
+ Parameters
+ ----------
+ name : str
+ Table name (snake_case or CamelCase).
+ Tier prefixes are optional and will be auto-detected.
+
+ Returns
+ -------
+ bool
+ True if the table exists.
+
+ Examples
+ --------
+ >>> 'Experiment' in schema
+ True
+ """
+ if name[0].isupper():
+ name = re.sub(r"(?>> lab = dj.VirtualModule('lab', 'my_lab_schema')
+ >>> lab.Subject.fetch()
+ """
+
+ def __init__(
+ self,
+ module_name: str,
+ schema_name: str,
+ *,
+ create_schema: bool = False,
+ create_tables: bool = False,
+ connection: Connection | None = None,
+ add_objects: dict[str, Any] | None = None,
+ ) -> None:
+ """
+ Initialize the virtual module.
+
+ Parameters
+ ----------
+ module_name : str
+ Display name for the module.
+ schema_name : str
+ Database schema name.
+ create_schema : bool, optional
+ If True, create the schema if it doesn't exist. Default False.
+ create_tables : bool, optional
+ If True, allow declaring new tables. Default False.
+ connection : Connection, optional
+ Database connection. Defaults to ``dj.conn()``.
+ add_objects : dict, optional
+ Additional objects to add to the module namespace.
+ """
+ super(VirtualModule, self).__init__(name=module_name)
+ _schema = _Schema(
+ schema_name,
+ create_schema=create_schema,
+ create_tables=create_tables,
+ connection=connection,
+ )
+ if add_objects:
+ self.__dict__.update(add_objects)
+ self.__dict__["schema"] = _schema
+ _schema.make_classes(into=self.__dict__)
+
+
+def list_schemas(connection: Connection | None = None) -> list[str]:
+ """
+ List all accessible schemas on the server.
+
+ Parameters
+ ----------
+ connection : Connection, optional
+ Database connection. Defaults to ``dj.conn()``.
+
+ Returns
+ -------
+ list[str]
+ Names of all accessible schemas.
+ """
+ conn = connection or _get_singleton_connection()
+ return [r[0] for r in conn.query(conn.adapter.list_schemas_sql())]
+
+
+def virtual_schema(
+ schema_name: str,
+ *,
+ connection: Connection | None = None,
+ create_schema: bool = False,
+ create_tables: bool = False,
+ add_objects: dict[str, Any] | None = None,
+) -> VirtualModule:
+ """
+ Create a virtual module for an existing database schema.
+
+ This is the recommended way to access database schemas when you don't have
+ the Python source code that defined them. Returns a module-like object with
+ table classes as attributes.
+
+ Parameters
+ ----------
+ schema_name : str
+ Database schema name.
+ connection : Connection, optional
+ Database connection. Defaults to ``dj.conn()``.
+ create_schema : bool, optional
+ If True, create the schema if it doesn't exist. Default False.
+ create_tables : bool, optional
+ If True, allow declaring new tables. Default False.
+ add_objects : dict, optional
+ Additional objects to add to the module namespace.
+
+ Returns
+ -------
+ VirtualModule
+ A module-like object with table classes as attributes.
+
+ Examples
+ --------
+ >>> lab = dj.virtual_schema('my_lab')
+ >>> lab.Subject.fetch()
+ >>> lab.Session & "subject_id='M001'"
+
+ See Also
+ --------
+ Schema : For defining new schemas with Python classes.
+ VirtualModule : The underlying class (prefer virtual_schema function).
+ """
+ return VirtualModule(
+ schema_name,
+ schema_name,
+ connection=connection,
+ create_schema=create_schema,
+ create_tables=create_tables,
+ add_objects=add_objects,
+ )
diff --git a/src/datajoint/settings.py b/src/datajoint/settings.py
new file mode 100644
index 000000000..7a035f6d8
--- /dev/null
+++ b/src/datajoint/settings.py
@@ -0,0 +1,1039 @@
+"""
+DataJoint configuration system using pydantic-settings.
+
+This module provides strongly-typed configuration with automatic loading
+from environment variables, secrets directories, and JSON config files.
+
+Configuration sources (in priority order):
+
+1. Environment variables (``DJ_*``)
+2. Secrets directories (``.secrets/`` in project, ``/run/secrets/datajoint/``)
+3. Project config file (``datajoint.json``, searched recursively up to ``.git/.hg``)
+
+Examples
+--------
+>>> import datajoint as dj
+>>> dj.config.database.host
+'localhost'
+>>> dj.config.database.backend
+'mysql'
+>>> dj.config.database.port # Auto-detects: 3306 for MySQL, 5432 for PostgreSQL
+3306
+>>> with dj.config.override(safemode=False):
+... # dangerous operations here
+... pass
+
+Project structure::
+
+ myproject/
+ ├── .git/
+ ├── datajoint.json # Project config (commit this)
+ ├── .secrets/ # Local secrets (gitignore this)
+ │ ├── database.password
+ │ └── aws.secret_access_key
+ └── src/
+ └── analysis.py # Config found via parent search
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import warnings
+from contextlib import contextmanager
+from copy import deepcopy
+from enum import Enum
+from pathlib import Path
+from typing import Any, Iterator, Literal
+
+from pydantic import Field, SecretStr, field_validator, model_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+from .errors import DataJointError
+
+CONFIG_FILENAME = "datajoint.json"
+SECRETS_DIRNAME = ".secrets"
+SYSTEM_SECRETS_DIR = Path("/run/secrets/datajoint")
+DEFAULT_SUBFOLDING = (2, 2)
+
+# Mapping of config keys to environment variables
+# Environment variables take precedence over config file values
+ENV_VAR_MAPPING = {
+ "database.host": "DJ_HOST",
+ "database.user": "DJ_USER",
+ "database.password": "DJ_PASS",
+ "database.backend": "DJ_BACKEND",
+ "database.port": "DJ_PORT",
+ "database.name": "DJ_DATABASE_NAME",
+ "database.database_prefix": "DJ_DATABASE_PREFIX",
+ "database.create_tables": "DJ_CREATE_TABLES",
+ "loglevel": "DJ_LOG_LEVEL",
+ "display.diagram_direction": "DJ_DIAGRAM_DIRECTION",
+}
+
+Role = Enum("Role", "manual lookup imported computed job")
+role_to_prefix = {
+ Role.manual: "",
+ Role.lookup: "#",
+ Role.imported: "_",
+ Role.computed: "__",
+ Role.job: "~",
+}
+prefix_to_role = dict(zip(role_to_prefix.values(), role_to_prefix))
+
+logger = logging.getLogger(__name__.split(".")[0])
+
+
+def find_config_file(start: Path | None = None) -> Path | None:
+ """
+ Search for datajoint.json in current and parent directories.
+
+ Searches upward from ``start`` until finding the config file or hitting
+ a project boundary (``.git``, ``.hg``) or filesystem root.
+
+ Parameters
+ ----------
+ start : Path, optional
+ Directory to start search from. Defaults to current working directory.
+
+ Returns
+ -------
+ Path or None
+ Path to config file if found, None otherwise.
+ """
+ current = (start or Path.cwd()).resolve()
+
+ while True:
+ config_path = current / CONFIG_FILENAME
+ if config_path.is_file():
+ return config_path
+
+ # Stop at project/repo root
+ if (current / ".git").exists() or (current / ".hg").exists():
+ return None
+
+ # Stop at filesystem root
+ if current == current.parent:
+ return None
+
+ current = current.parent
+
+
+def find_secrets_dir(config_path: Path | None = None) -> Path | None:
+ """
+ Find the secrets directory.
+
+ Priority:
+
+ 1. ``.secrets/`` in same directory as datajoint.json (project secrets)
+ 2. ``/run/secrets/datajoint/`` (Docker/Kubernetes secrets)
+
+ Parameters
+ ----------
+ config_path : Path, optional
+ Path to datajoint.json if found.
+
+ Returns
+ -------
+ Path or None
+ Path to secrets directory if found, None otherwise.
+ """
+ # Check project secrets directory (next to config file)
+ if config_path is not None:
+ project_secrets = config_path.parent / SECRETS_DIRNAME
+ if project_secrets.is_dir():
+ return project_secrets
+
+ # Check system secrets directory (Docker/Kubernetes)
+ if SYSTEM_SECRETS_DIR.is_dir():
+ return SYSTEM_SECRETS_DIR
+
+ return None
+
+
+def read_secret_file(secrets_dir: Path | None, name: str) -> str | None:
+ """
+ Read a secret value from a file in the secrets directory.
+
+ Parameters
+ ----------
+ secrets_dir : Path or None
+ Path to secrets directory.
+ name : str
+ Name of the secret file (e.g., ``'database.password'``).
+
+ Returns
+ -------
+ str or None
+ Secret value as string, or None if not found.
+ """
+ if secrets_dir is None:
+ return None
+
+ secret_path = secrets_dir / name
+ if secret_path.is_file():
+ return secret_path.read_text().strip()
+
+ return None
+
+
+class DatabaseSettings(BaseSettings):
+ """Database connection settings."""
+
+ model_config = SettingsConfigDict(
+ env_prefix="DJ_",
+ case_sensitive=False,
+ extra="forbid",
+ validate_assignment=True,
+ )
+
+ host: str = Field(default="localhost", validation_alias="DJ_HOST")
+ user: str | None = Field(default=None, validation_alias="DJ_USER")
+ password: SecretStr | None = Field(default=None, validation_alias="DJ_PASS")
+ backend: Literal["mysql", "postgresql"] = Field(
+ default="mysql",
+ validation_alias="DJ_BACKEND",
+ description="Database backend: 'mysql' or 'postgresql'",
+ )
+ port: int | None = Field(default=None, validation_alias="DJ_PORT")
+ name: str | None = Field(
+ default=None,
+ validation_alias="DJ_DATABASE_NAME",
+ description="Database name for PostgreSQL connections. Defaults to 'postgres' if not set.",
+ )
+ reconnect: bool = True
+ use_tls: bool | None = Field(default=None, validation_alias="DJ_USE_TLS")
+ database_prefix: str = Field(
+ default="",
+ validation_alias="DJ_DATABASE_PREFIX",
+ description="Deprecated. Use database.name instead.",
+ )
+ create_tables: bool = Field(
+ default=True,
+ validation_alias="DJ_CREATE_TABLES",
+ description="Default for Schema create_tables parameter. "
+ "Set to False for production mode to prevent automatic table creation.",
+ )
+
+ @model_validator(mode="after")
+ def set_default_port_from_backend(self) -> "DatabaseSettings":
+ """Set default port based on backend if not explicitly provided."""
+ if self.port is None:
+ self.port = 5432 if self.backend == "postgresql" else 3306
+ return self
+
+
+class ConnectionSettings(BaseSettings):
+ """Connection behavior settings."""
+
+ model_config = SettingsConfigDict(extra="forbid", validate_assignment=True)
+
+ charset: str = "" # pymysql uses '' as default
+
+
+class DisplaySettings(BaseSettings):
+ """Display and preview settings."""
+
+ model_config = SettingsConfigDict(extra="forbid", validate_assignment=True)
+
+ limit: int = 12
+ width: int = 14
+ show_tuple_count: bool = True
+ diagram_direction: Literal["TB", "LR"] = Field(
+ default="LR",
+ validation_alias="DJ_DIAGRAM_DIRECTION",
+ description="Default diagram layout direction: 'TB' (top-to-bottom) or 'LR' (left-to-right)",
+ )
+
+
+class StoresSettings(BaseSettings):
+ """
+ Unified object storage configuration.
+
+ Stores configuration supports both hash-addressed and schema-addressed storage
+ using the same named stores with _hash and _schema sections.
+ """
+
+ model_config = SettingsConfigDict(
+ case_sensitive=False,
+ extra="allow", # Allow dynamic store names
+ validate_assignment=True,
+ )
+
+ default: str | None = Field(default=None, description="Name of the default store")
+
+ # Named stores are added dynamically as stores..*
+ # Structure: stores..protocol, stores..location, etc.
+
+
+class JobsSettings(BaseSettings):
+ """Job queue configuration for AutoPopulate 2.0."""
+
+ model_config = SettingsConfigDict(
+ env_prefix="DJ_JOBS_",
+ case_sensitive=False,
+ extra="forbid",
+ validate_assignment=True,
+ )
+
+ auto_refresh: bool = Field(default=True, description="Auto-refresh jobs queue on populate")
+ keep_completed: bool = Field(default=False, description="Keep success records in jobs table")
+ stale_timeout: int = Field(default=3600, ge=0, description="Seconds before pending job is checked for staleness")
+ default_priority: int = Field(default=5, ge=0, le=255, description="Default priority for new jobs (lower = more urgent)")
+ version_method: Literal["git", "none"] | None = Field(
+ default=None, description="Method to obtain version: 'git' (commit hash), 'none' (empty), or None (disabled)"
+ )
+ allow_new_pk_fields_in_computed_tables: bool = Field(
+ default=False,
+ description="Allow native (non-FK) primary key fields in Computed/Imported tables. "
+ "When True, bypasses the FK-only PK validation. Job granularity will be degraded for such tables.",
+ )
+ add_job_metadata: bool = Field(
+ default=False,
+ description="Add hidden job metadata attributes (_job_start_time, _job_duration, _job_version) "
+ "to Computed and Imported tables during declaration. Tables created without this setting "
+ "will not receive metadata updates during populate.",
+ )
+
+
+class Config(BaseSettings):
+ """
+ Main DataJoint configuration.
+
+ Settings are loaded from (in priority order):
+
+ 1. Environment variables (``DJ_*``)
+ 2. Secrets directory (``.secrets/`` or ``/run/secrets/datajoint/``)
+ 3. Config file (``datajoint.json``, searched in parent directories)
+ 4. Default values
+
+ Examples
+ --------
+ Access settings via attributes:
+
+ >>> config.database.host
+ >>> config.safemode
+
+ Override temporarily with context manager:
+
+ >>> with config.override(safemode=False):
+ ... pass
+ """
+
+ model_config = SettingsConfigDict(
+ env_prefix="DJ_",
+ case_sensitive=False,
+ extra="forbid",
+ validate_assignment=True,
+ )
+
+ # Nested settings groups
+ database: DatabaseSettings = Field(default_factory=DatabaseSettings)
+ connection: ConnectionSettings = Field(default_factory=ConnectionSettings)
+ display: DisplaySettings = Field(default_factory=DisplaySettings)
+ jobs: JobsSettings = Field(default_factory=JobsSettings)
+
+ # Unified stores configuration (replaces external and object_storage)
+ # ``validation_alias`` redirects pydantic-settings' env source away from the
+ # natural ``DJ_STORES`` so it doesn't auto-parse on Config() construction.
+ # ``DJ_STORES`` is handled by ``_apply_stores_env`` after the config file
+ # load so env-var precedence is honored. *New in 2.2.3.*
+ stores: dict[str, Any] = Field(
+ default_factory=dict,
+ validation_alias="_DJ_STORES_PYDANTIC_DISABLED",
+ description="Unified object storage configuration. "
+ "Use stores.default to designate default store. "
+ "Configure named stores as stores..protocol, stores..location, etc. "
+ "Set via DJ_STORES (JSON object) or in datajoint.json. *New in 2.2.3* for "
+ "DJ_STORES env-var support.",
+ )
+
+ # Top-level settings
+ loglevel: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(default="INFO", validation_alias="DJ_LOG_LEVEL")
+ safemode: bool = True
+
+ ignore_config_file: bool = Field(
+ default=False,
+ validation_alias="DJ_IGNORE_CONFIG_FILE",
+ description="If True, skip loading datajoint.json and the secrets directory. "
+ "Intended for env-var-only deployments (e.g. the DataJoint platform). "
+ "*New in 2.2.3.*",
+ )
+
+ # Cache path for query results
+ query_cache: Path | None = None
+
+ # Download path for attachments and filepaths
+ download_path: str = "."
+
+ # Internal: track where config was loaded from
+ _config_path: Path | None = None
+ _secrets_dir: Path | None = None
+
+ @field_validator("loglevel", mode="after")
+ @classmethod
+ def set_logger_level(cls, v: str) -> str:
+ """Update logger level when loglevel changes."""
+ logger.setLevel(v)
+ return v
+
+ @field_validator("query_cache", mode="before")
+ @classmethod
+ def convert_path(cls, v: Any) -> Path | None:
+ """Convert string paths to Path objects."""
+ if v is None:
+ return None
+ return Path(v) if not isinstance(v, Path) else v
+
+ def get_store_spec(self, store: str | None = None, *, use_filepath_default: bool = False) -> dict[str, Any]:
+ """
+ Get configuration for a storage store.
+
+ Parameters
+ ----------
+ store : str, optional
+ Name of the store to retrieve. If None, uses the appropriate default.
+ use_filepath_default : bool, optional
+ If True and store is None, uses stores.filepath_default instead of
+ stores.default. Use for filepath references which are not part of OAS.
+ Default: False (use stores.default for integrated storage).
+
+ Returns
+ -------
+ dict[str, Any]
+ Store configuration dict with validated fields.
+
+ Raises
+ ------
+ DataJointError
+ If store is not configured or has invalid config.
+ """
+ # Handle default store
+ if store is None:
+ if use_filepath_default:
+ # Filepath references use separate default (not part of OAS)
+ if "filepath_default" not in self.stores:
+ raise DataJointError(
+ "stores.filepath_default is not configured. "
+ "Set stores.filepath_default or specify store explicitly with "
+ )
+ store = self.stores["filepath_default"]
+ else:
+ # Integrated storage (hash, schema) uses stores.default
+ if "default" not in self.stores:
+ raise DataJointError("stores.default is not configured")
+ store = self.stores["default"]
+
+ if not isinstance(store, str):
+ default_key = "filepath_default" if use_filepath_default else "default"
+ raise DataJointError(f"stores.{default_key} must be a string")
+
+ # Check store exists
+ if store not in self.stores:
+ raise DataJointError(f"Storage '{store}' is requested but not configured in stores")
+
+ spec = dict(self.stores[store])
+
+ self._apply_common_store_defaults(spec)
+
+ # Validate protocol
+ protocol = spec.get("protocol", "").lower()
+ supported_protocols = ("file", "s3", "gcs", "azure")
+ if protocol not in supported_protocols:
+ from .storage_adapter import get_storage_adapter
+
+ adapter = get_storage_adapter(protocol)
+ if adapter is None:
+ raise DataJointError(
+ f'Unknown protocol "{protocol}" in config.stores["{store}"]. '
+ f"Built-in: {', '.join(supported_protocols)}. "
+ f"Install a plugin package for additional protocols."
+ )
+ adapter.validate_spec(spec)
+ self._validate_prefix_separation(
+ store_name=store,
+ hash_prefix=spec.get("hash_prefix"),
+ schema_prefix=spec.get("schema_prefix"),
+ filepath_prefix=spec.get("filepath_prefix"),
+ )
+ return spec
+
+ # Set protocol-specific defaults
+ if protocol == "s3":
+ spec.setdefault("secure", True) # HTTPS by default for S3
+
+ # Define required and allowed keys by protocol
+ required_keys: dict[str, tuple[str, ...]] = {
+ "file": ("protocol", "location"),
+ "s3": ("protocol", "endpoint", "bucket", "access_key", "secret_key", "location"),
+ "gcs": ("protocol", "bucket", "location"),
+ "azure": ("protocol", "container", "location"),
+ }
+ allowed_keys: dict[str, tuple[str, ...]] = {
+ "file": (
+ "protocol",
+ "location",
+ "subfolding",
+ "partition_pattern",
+ "token_length",
+ "hash_prefix",
+ "schema_prefix",
+ "filepath_prefix",
+ "stage",
+ ),
+ "s3": (
+ "protocol",
+ "endpoint",
+ "bucket",
+ "access_key",
+ "secret_key",
+ "location",
+ "secure",
+ "subfolding",
+ "partition_pattern",
+ "token_length",
+ "hash_prefix",
+ "schema_prefix",
+ "filepath_prefix",
+ "stage",
+ "proxy_server",
+ ),
+ "gcs": (
+ "protocol",
+ "bucket",
+ "location",
+ "token",
+ "project",
+ "subfolding",
+ "partition_pattern",
+ "token_length",
+ "hash_prefix",
+ "schema_prefix",
+ "filepath_prefix",
+ "stage",
+ ),
+ "azure": (
+ "protocol",
+ "container",
+ "location",
+ "account_name",
+ "account_key",
+ "connection_string",
+ "subfolding",
+ "partition_pattern",
+ "token_length",
+ "hash_prefix",
+ "schema_prefix",
+ "filepath_prefix",
+ "stage",
+ ),
+ }
+
+ # Check required keys
+ missing = [k for k in required_keys[protocol] if k not in spec]
+ if missing:
+ raise DataJointError(f'config.stores["{store}"] is missing: {", ".join(missing)}')
+
+ # Check for invalid keys
+ invalid = [k for k in spec if k not in allowed_keys[protocol]]
+ if invalid:
+ raise DataJointError(f'Invalid key(s) in config.stores["{store}"]: {", ".join(invalid)}')
+
+ # Validate prefix separation to prevent overlap
+ self._validate_prefix_separation(
+ store_name=store,
+ hash_prefix=spec.get("hash_prefix"),
+ schema_prefix=spec.get("schema_prefix"),
+ filepath_prefix=spec.get("filepath_prefix"),
+ )
+
+ return spec
+
+ def _validate_prefix_separation(
+ self,
+ store_name: str,
+ hash_prefix: str | None,
+ schema_prefix: str | None,
+ filepath_prefix: str | None,
+ ) -> None:
+ """
+ Validate that storage section prefixes don't overlap.
+
+ Parameters
+ ----------
+ store_name : str
+ Name of the store being validated (for error messages).
+ hash_prefix : str or None
+ Prefix for hash-addressed storage.
+ schema_prefix : str or None
+ Prefix for schema-addressed storage.
+ filepath_prefix : str or None
+ Prefix for filepath storage (None means unrestricted).
+
+ Raises
+ ------
+ DataJointError
+ If any prefixes overlap (one is a parent/child of another).
+ """
+ # Collect non-null prefixes with their names
+ prefixes = []
+ if hash_prefix:
+ prefixes.append(("hash_prefix", hash_prefix))
+ if schema_prefix:
+ prefixes.append(("schema_prefix", schema_prefix))
+ if filepath_prefix:
+ prefixes.append(("filepath_prefix", filepath_prefix))
+
+ # Normalize prefixes: remove leading/trailing slashes, ensure trailing slash for comparison
+ def normalize(p: str) -> str:
+ return p.strip("/") + "/"
+
+ normalized = [(name, normalize(prefix)) for name, prefix in prefixes]
+
+ # Check each pair for overlap
+ for i, (name1, p1) in enumerate(normalized):
+ for j, (name2, p2) in enumerate(normalized[i + 1 :], start=i + 1):
+ # Check if one prefix is a parent of another
+ if p1.startswith(p2) or p2.startswith(p1):
+ raise DataJointError(
+ f'config.stores["{store_name}"]: {name1}="{prefixes[i][1]}" and '
+ f'{name2}="{prefixes[j][1]}" overlap. '
+ f"Storage section prefixes must be mutually exclusive."
+ )
+
+ @staticmethod
+ def _apply_common_store_defaults(spec: dict[str, Any]) -> None:
+ """Apply defaults shared by every store protocol (built-in and plugin)."""
+ spec.setdefault("subfolding", None)
+ spec.setdefault("partition_pattern", None)
+ spec.setdefault("token_length", 8)
+ spec.setdefault("hash_prefix", "_hash")
+ spec.setdefault("schema_prefix", "_schema")
+ spec.setdefault("filepath_prefix", None)
+
+ def load(self, filename: str | Path) -> None:
+ """
+ Load settings from a JSON file.
+
+ Parameters
+ ----------
+ filename : str or Path
+ Path to load configuration from.
+ """
+ filepath = Path(filename)
+ if not filepath.exists():
+ raise FileNotFoundError(f"Config file not found: {filepath}")
+
+ logger.info(f"Loading configuration from {filepath.absolute()}")
+
+ with open(filepath) as f:
+ data = json.load(f)
+
+ self._update_from_flat_dict(data)
+ self._config_path = filepath
+
+ def _update_from_flat_dict(self, data: dict[str, Any]) -> None:
+ """
+ Update settings from a dict (flat dot-notation or nested).
+
+ Environment variables take precedence over config file values.
+ If an env var is set for a setting, the file value is skipped.
+ """
+ for key, value in data.items():
+ # Special handling for stores - accept nested dict directly
+ if key == "stores" and isinstance(value, dict):
+ # Merge stores dict
+ for store_key, store_value in value.items():
+ self.stores[store_key] = store_value
+ continue
+
+ # Handle nested dicts by recursively updating
+ if isinstance(value, dict) and hasattr(self, key):
+ group_obj = getattr(self, key)
+ for nested_key, nested_value in value.items():
+ if hasattr(group_obj, nested_key):
+ # Check if env var is set for this nested key
+ full_key = f"{key}.{nested_key}"
+ env_var = ENV_VAR_MAPPING.get(full_key)
+ if env_var and os.environ.get(env_var):
+ logger.debug(f"Skipping {full_key} from file (env var {env_var} takes precedence)")
+ continue
+ setattr(group_obj, nested_key, nested_value)
+ continue
+
+ # Handle flat dot-notation keys
+ parts = key.split(".")
+ if len(parts) == 1:
+ if hasattr(self, key) and not key.startswith("_"):
+ # Check if env var is set for this key
+ env_var = ENV_VAR_MAPPING.get(key)
+ if env_var and os.environ.get(env_var):
+ logger.debug(f"Skipping {key} from file (env var {env_var} takes precedence)")
+ continue
+ setattr(self, key, value)
+ elif len(parts) == 2:
+ group, attr = parts
+ if hasattr(self, group):
+ group_obj = getattr(self, group)
+ if hasattr(group_obj, attr):
+ # Check if env var is set for this key
+ env_var = ENV_VAR_MAPPING.get(key)
+ if env_var and os.environ.get(env_var):
+ logger.debug(f"Skipping {key} from file (env var {env_var} takes precedence)")
+ continue
+ setattr(group_obj, attr, value)
+ elif len(parts) == 3:
+ # Handle stores.. pattern
+ group, store_name, attr = parts
+ if group == "stores":
+ if store_name not in self.stores:
+ self.stores[store_name] = {}
+ self.stores[store_name][attr] = value
+
+ def _load_secrets(self, secrets_dir: Path) -> None:
+ """Load secrets from a secrets directory."""
+ self._secrets_dir = secrets_dir
+
+ # Load database secrets
+ db_user = read_secret_file(secrets_dir, "database.user")
+ if db_user is not None and self.database.user is None:
+ self.database.user = db_user
+ logger.debug(f"Loaded database.user from {secrets_dir}")
+
+ db_password = read_secret_file(secrets_dir, "database.password")
+ if db_password is not None and self.database.password is None:
+ self.database.password = db_password
+ logger.debug(f"Loaded database.password from {secrets_dir}")
+
+ # Load per-store secrets from any stores.. file.
+ # The attr name is recorded as-is on stores.; this lets
+ # plugin-registered adapters define their own secret fields
+ # (e.g. a Bearer ``token`` for HTTP-based protocols) without
+ # forcing AWS-style ``access_key`` / ``secret_key`` naming.
+ if secrets_dir.is_dir():
+ for secret_file in secrets_dir.iterdir():
+ if not secret_file.is_file() or secret_file.name.startswith("."):
+ continue
+
+ parts = secret_file.name.split(".")
+ if len(parts) == 3 and parts[0] == "stores":
+ store_name, attr = parts[1], parts[2]
+ value = secret_file.read_text().strip()
+ # Initialize store dict if needed
+ if store_name not in self.stores:
+ self.stores[store_name] = {}
+ # Only set if not already present (config / env vars win)
+ if attr not in self.stores[store_name]:
+ self.stores[store_name][attr] = value
+ logger.debug(f"Loaded stores.{store_name}.{attr} from {secrets_dir}")
+
+ def _apply_stores_env(self) -> None:
+ """Replace ``self.stores`` from the ``DJ_STORES`` env var if set.
+
+ ``DJ_STORES`` holds a JSON object in the same shape as the ``stores``
+ block of ``datajoint.json``. This lets env-var-only deployments
+ configure plugin-registered storage adapters with arbitrary attr
+ names (e.g. a Bearer ``token`` field) without negotiating an env-var
+ naming scheme per attr.
+
+ *New in 2.2.3.*
+ """
+ raw = os.environ.get("DJ_STORES")
+ if not raw:
+ return
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError as e:
+ raise ValueError(f"DJ_STORES contains invalid JSON: {e}") from e
+ if not isinstance(data, dict):
+ raise ValueError(f"DJ_STORES must be a JSON object, got {type(data).__name__}")
+ self.stores = data
+ logger.debug("Loaded stores from DJ_STORES env var")
+
+ @contextmanager
+ def override(self, **kwargs: Any) -> Iterator["Config"]:
+ """
+ Temporarily override configuration values.
+
+ Parameters
+ ----------
+ **kwargs : Any
+ Settings to override. Use double underscore for nested settings
+ (e.g., ``database__host="localhost"``).
+
+ Yields
+ ------
+ Config
+ The config instance with overridden values.
+
+ Examples
+ --------
+ >>> with config.override(safemode=False, database__host="test"):
+ ... # config.safemode is False here
+ ... pass
+ >>> # config.safemode is restored
+ """
+ # Store original values
+ backup = {}
+
+ # Convert double underscore to nested access
+ converted = {}
+ for key, value in kwargs.items():
+ if "__" in key:
+ parts = key.split("__")
+ converted[tuple(parts)] = value
+ else:
+ converted[(key,)] = value
+
+ try:
+ # Save originals and apply overrides
+ for key_parts, value in converted.items():
+ if len(key_parts) == 1:
+ key = key_parts[0]
+ if hasattr(self, key):
+ backup[key_parts] = deepcopy(getattr(self, key))
+ setattr(self, key, value)
+ elif len(key_parts) == 2:
+ group, attr = key_parts
+ if hasattr(self, group):
+ group_obj = getattr(self, group)
+ if hasattr(group_obj, attr):
+ backup[key_parts] = deepcopy(getattr(group_obj, attr))
+ setattr(group_obj, attr, value)
+
+ yield self
+
+ finally:
+ # Restore original values
+ for key_parts, original in backup.items():
+ if len(key_parts) == 1:
+ setattr(self, key_parts[0], original)
+ elif len(key_parts) == 2:
+ group, attr = key_parts
+ setattr(getattr(self, group), attr, original)
+
+ @staticmethod
+ def save_template(
+ path: str | Path = "datajoint.json",
+ minimal: bool = True,
+ create_secrets_dir: bool = True,
+ ) -> Path:
+ """
+ Create a template datajoint.json configuration file.
+
+ Credentials should NOT be stored in datajoint.json. Instead, use either:
+
+ - Environment variables (``DJ_USER``, ``DJ_PASS``, ``DJ_HOST``,
+ ``DJ_STORES`` for JSON-encoded store configs, etc.)
+ - The ``.secrets/`` directory (created alongside datajoint.json)
+
+ Set ``DJ_IGNORE_CONFIG_FILE=true`` to skip both ``datajoint.json`` and
+ the secrets directory entirely (env-var-only configuration).
+
+ Parameters
+ ----------
+ path : str or Path, optional
+ Where to save the template. Default ``'datajoint.json'``.
+ minimal : bool, optional
+ If True (default), create minimal template with just database settings.
+ If False, create full template with all available settings.
+ create_secrets_dir : bool, optional
+ If True (default), also create a ``.secrets/`` directory with
+ template files for credentials.
+
+ Returns
+ -------
+ Path
+ Absolute path to the created config file.
+
+ Raises
+ ------
+ FileExistsError
+ If config file already exists (won't overwrite).
+
+ Examples
+ --------
+ >>> import datajoint as dj
+ >>> dj.config.save_template() # Creates minimal template + .secrets/
+ >>> dj.config.save_template("full-config.json", minimal=False)
+ """
+ filepath = Path(path)
+ if filepath.exists():
+ raise FileExistsError(f"File already exists: {filepath}. Remove it first or choose a different path.")
+
+ if minimal:
+ template = {
+ "database": {
+ "host": "localhost",
+ "port": 3306,
+ },
+ }
+ else:
+ template = {
+ "database": {
+ "host": "localhost",
+ "port": 3306,
+ "reconnect": True,
+ "use_tls": None,
+ },
+ "connection": {
+ "charset": "",
+ },
+ "display": {
+ "limit": 12,
+ "width": 14,
+ "show_tuple_count": True,
+ },
+ "stores": {
+ "default": "main",
+ "filepath_default": "raw_data",
+ "main": {
+ "protocol": "file",
+ "location": "/data/my-project/main",
+ "partition_pattern": None,
+ "token_length": 8,
+ "subfolding": None,
+ },
+ "raw_data": {
+ "protocol": "file",
+ "location": "/data/my-project/raw",
+ },
+ },
+ "loglevel": "INFO",
+ "safemode": True,
+ "query_cache": None,
+ "download_path": ".",
+ }
+
+ with open(filepath, "w") as f:
+ json.dump(template, f, indent=2)
+ f.write("\n")
+
+ logger.info(f"Created template configuration at {filepath.absolute()}")
+
+ # Create .secrets/ directory with template files
+ if create_secrets_dir:
+ secrets_dir = filepath.parent / SECRETS_DIRNAME
+ secrets_dir.mkdir(exist_ok=True)
+
+ # Create placeholder secret files
+ secret_templates = {
+ "database.user": "your_username",
+ "database.password": "your_password",
+ }
+ for secret_name, placeholder in secret_templates.items():
+ secret_file = secrets_dir / secret_name
+ if not secret_file.exists():
+ secret_file.write_text(placeholder)
+
+ # Create .gitignore to prevent committing secrets
+ gitignore_path = secrets_dir / ".gitignore"
+ if not gitignore_path.exists():
+ gitignore_path.write_text("# Never commit secrets\n*\n!.gitignore\n")
+
+ logger.info(
+ f"Created {SECRETS_DIRNAME}/ directory with credential templates. "
+ f"Edit the files in {secrets_dir.absolute()}/ to set your credentials."
+ )
+
+ return filepath.absolute()
+
+ # Dict-like access for convenience
+ def __getitem__(self, key: str) -> Any:
+ """Get setting by dot-notation key (e.g., 'database.host')."""
+ parts = key.split(".")
+ obj: Any = self
+ for part in parts:
+ if hasattr(obj, part):
+ obj = getattr(obj, part)
+ elif isinstance(obj, dict):
+ obj = obj[part]
+ else:
+ raise KeyError(f"Setting '{key}' not found")
+ # Unwrap SecretStr for compatibility
+ if isinstance(obj, SecretStr):
+ return obj.get_secret_value()
+ return obj
+
+ def __setitem__(self, key: str, value: Any) -> None:
+ """Set setting by dot-notation key (e.g., 'database.host')."""
+ parts = key.split(".")
+ if len(parts) == 1:
+ if hasattr(self, key):
+ setattr(self, key, value)
+ else:
+ raise KeyError(f"Setting '{key}' not found")
+ else:
+ obj: Any = self
+ for part in parts[:-1]:
+ obj = getattr(obj, part)
+ setattr(obj, parts[-1], value)
+
+ def __delitem__(self, key: str) -> None:
+ """Reset setting to default by dot-notation key."""
+ # Get the default value from the model fields (access from class, not instance)
+ parts = key.split(".")
+ if len(parts) == 1:
+ field_info = type(self).model_fields.get(key)
+ if field_info is not None:
+ default = field_info.default
+ if default is not None:
+ setattr(self, key, default)
+ elif field_info.default_factory is not None:
+ setattr(self, key, field_info.default_factory())
+ else:
+ setattr(self, key, None)
+ else:
+ raise KeyError(f"Setting '{key}' not found")
+ else:
+ # For nested settings, reset to None or empty
+ obj: Any = self
+ for part in parts[:-1]:
+ obj = getattr(obj, part)
+ setattr(obj, parts[-1], None)
+
+ def get(self, key: str, default: Any = None) -> Any:
+ """Get setting with optional default value."""
+ try:
+ return self[key]
+ except KeyError:
+ return default
+
+
+def _create_config() -> Config:
+ """Create and initialize the global config instance."""
+ cfg = Config()
+
+ config_path: Path | None = None
+ if not cfg.ignore_config_file:
+ config_path = find_config_file()
+ if config_path is not None:
+ try:
+ cfg.load(config_path)
+ except Exception as e:
+ warnings.warn(f"Failed to load config from {config_path}: {e}")
+ else:
+ warnings.warn(
+ f"No {CONFIG_FILENAME} found. Using defaults and environment variables. "
+ f"Run `dj.config.save_template()` to create a template configuration.",
+ stacklevel=2,
+ )
+
+ # DJ_STORES (if set) overrides the stores dict from the config file
+ cfg._apply_stores_env()
+
+ # Secrets fill missing attrs in whatever ended up in self.stores
+ if not cfg.ignore_config_file:
+ secrets_dir = find_secrets_dir(config_path)
+ if secrets_dir is not None:
+ cfg._load_secrets(secrets_dir)
+
+ # Set initial log level
+ logger.setLevel(cfg.loglevel)
+
+ return cfg
+
+
+# Global config instance
+config = _create_config()
diff --git a/src/datajoint/spark.py b/src/datajoint/spark.py
new file mode 100644
index 000000000..29397b64f
--- /dev/null
+++ b/src/datajoint/spark.py
@@ -0,0 +1,92 @@
+"""
+SparkAdapter Codec Protocol.
+
+Opt-in contract for codecs that adapt their decoded values to Spark-native
+types — primitives, lists, dicts, and nested combinations.
+
+Codecs implement this method when they want their column eligible for
+downstream typed-query systems (Spark SQL, Delta Sharing, BI tools).
+Generic codecs like ```` and ```` deliberately do not
+implement it: their decoded values can be arbitrary Python objects with
+no fixed Spark-native shape.
+
+The contract is intentionally a Protocol rather than an abstract method
+on :class:`datajoint.Codec`:
+
+- Generic codecs need no acknowledgement (no ``NotImplementedError`` stubs).
+- Existing plugin codecs continue to work unchanged.
+- Codec authors opt in by adding the method on their own release cadence.
+- Consumers detect support structurally via ``isinstance(codec, SparkAdapter)``.
+
+See ``datajoint-docs/src/reference/specs/spark-adapter.md`` for the
+normative specification (signature, return-value shape constraints,
+worked codec examples).
+"""
+
+from __future__ import annotations
+
+from typing import Any, Protocol, runtime_checkable
+
+
+@runtime_checkable
+class SparkAdapter(Protocol):
+ """
+ A codec that adapts its decoded values to Spark-native types.
+
+ Opt-in. Codecs implementing this method declare that their decoded
+ values can be expressed as primitives, lists, or dicts of the same —
+ i.e., shapes that map cleanly to Spark's ``StructType`` /
+ ``ArrayType`` / ``MapType``.
+
+ Consumers (e.g., a Databricks silver-layer publish pipeline) check
+ ``isinstance(codec, SparkAdapter)`` per column to determine eligibility.
+
+ Allowed return-value shapes:
+
+ - Primitives: ``bool``, ``int``, ``float``, ``str``, ``bytes``,
+ ``None``, ``datetime.date``, ``datetime.datetime``.
+ - ``list[T]`` where ``T`` is any allowed shape (→ Spark ``ArrayType``).
+ - ``dict[str, T]`` where ``T`` is any allowed shape (→ Spark
+ ``StructType`` or ``MapType``, consumer-decided).
+
+ NumPy arrays must be converted to lists; no tuples, sets, or custom
+ objects in the return value.
+
+ Examples
+ --------
+ A 1D float-array codec (shipped as a plugin, not in datajoint-python)::
+
+ class FloatArrayCodec(dj.Codec):
+ name = "float_array"
+
+ def encode(self, value, *, key=None, store_name=None): ...
+ def decode(self, stored, *, key=None) -> np.ndarray: ...
+
+ def to_spark(self, decoded: np.ndarray, *, key=None) -> list[float]:
+ return decoded.tolist() # → Spark ARRAY
+
+ Eligibility check::
+
+ from datajoint import SparkAdapter
+ isinstance(FloatArrayCodec(), SparkAdapter) # True
+ """
+
+ def to_spark(self, decoded: Any, *, key: dict | None = None) -> Any:
+ """
+ Adapt a decoded codec value to a Spark-native shape.
+
+ Parameters
+ ----------
+ decoded : Any
+ The Python value produced by the codec's ``decode()``.
+ key : dict, optional
+ Optional context dict — same shape as ``Codec.encode``'s
+ ``key`` parameter. Most codecs ignore it.
+
+ Returns
+ -------
+ Any
+ A value composed entirely of allowed Spark-native shapes
+ (see class docstring).
+ """
+ ...
diff --git a/src/datajoint/staged_insert.py b/src/datajoint/staged_insert.py
new file mode 100644
index 000000000..ffbe8a8f2
--- /dev/null
+++ b/src/datajoint/staged_insert.py
@@ -0,0 +1,274 @@
+"""
+Staged insert context manager for direct object storage writes.
+
+This module provides the StagedInsert class which allows writing directly
+to object storage before finalizing the database insert.
+"""
+
+from contextlib import contextmanager
+from datetime import datetime, timezone
+from typing import IO, TYPE_CHECKING, Any
+
+import fsspec
+
+from .codecs import resolve_dtype
+from .errors import DataJointError
+from .hash_registry import get_store_backend
+from .storage import build_object_path
+
+if TYPE_CHECKING:
+ from .storage import StorageBackend
+
+
+class StagedInsert:
+ """
+ Context manager for staged insert operations.
+
+ Allows direct writes to object storage before finalizing the database insert.
+ Used for large objects like Zarr arrays where copying from local storage
+ is inefficient.
+
+ Usage:
+ with table.staged_insert1 as staged:
+ staged.rec['subject_id'] = 123
+ staged.rec['session_id'] = 45
+
+ # Write directly to object storage
+ z = zarr.open(staged.store('raw_data', '.zarr'), mode='w', shape=(1000, 1000))
+ z[:] = data
+
+ # On clean exit: metadata is computed and the row is inserted.
+ # The caller does NOT assign anything to staged.rec[] —
+ # the framework computes the metadata dict.
+ # On exception: storage cleaned up, no row inserted.
+ """
+
+ def __init__(self, table):
+ """
+ Initialize a staged insert.
+
+ Args:
+ table: The Table instance to insert into
+ """
+ self._table = table
+ self._rec: dict[str, Any] = {}
+ self._staged_objects: dict[str, dict] = {} # field -> {relative_path, ext, token, store_name}
+
+ @property
+ def rec(self) -> dict[str, Any]:
+ """Record dict for setting attribute values."""
+ return self._rec
+
+ @property
+ def fs(self) -> fsspec.AbstractFileSystem:
+ """
+ Return fsspec filesystem for the default store, for advanced operations.
+
+ For per-field access, prefer ``staged.store(field)`` or ``staged.open(field)`` —
+ those route to the store resolved from the field's type spec.
+ """
+ return self._default_backend().fs
+
+ def _default_backend(self):
+ """Return the StorageBackend for the default store, or raise a clear error."""
+ try:
+ return get_store_backend(None, config=self._table.connection._config)
+ except DataJointError:
+ raise DataJointError("Storage is not configured. Set stores.default and stores. settings in datajoint.json.")
+
+ def _resolve_field(self, field: str, ext: str) -> tuple[str, "StorageBackend"]:
+ """
+ Resolve a field to its (relative_path, backend), caching on first call.
+
+ Validates the field is an ```` attribute and that the full
+ primary key is set on ``staged.rec``.
+ """
+ if field in self._staged_objects:
+ info = self._staged_objects[field]
+ return info["relative_path"], self._field_backend(info["store_name"])
+
+ if field not in self._table.heading:
+ raise DataJointError(f"Attribute '{field}' not found in table heading")
+
+ attr = self._table.heading[field]
+ if not (attr.codec and attr.codec.name == "object"):
+ raise DataJointError(f"Attribute '{field}' is not an type")
+
+ primary_key = {k: self._rec[k] for k in self._table.primary_key if k in self._rec}
+ if len(primary_key) != len(self._table.primary_key):
+ raise DataJointError(
+ "Primary key values must be set in staged.rec before calling store() or open(). "
+ f"Missing: {set(self._table.primary_key) - set(primary_key)}"
+ )
+
+ # Resolve the store name from the field's type spec (e.g., -> "local")
+ _, _, store_name = resolve_dtype(f"<{attr.codec.name}>", store_name=attr.store)
+
+ config = self._table.connection._config
+ try:
+ spec = config.get_store_spec(store_name)
+ except DataJointError:
+ raise DataJointError("Storage is not configured. Set stores.default and stores. settings in datajoint.json.")
+ partition_pattern = spec.get("partition_pattern")
+ token_length = spec.get("token_length", 8)
+
+ relative_path, token = build_object_path(
+ schema=self._table.database,
+ table=self._table.class_name,
+ field=field,
+ primary_key=primary_key,
+ ext=ext if ext else None,
+ partition_pattern=partition_pattern,
+ token_length=token_length,
+ )
+
+ self._staged_objects[field] = {
+ "relative_path": relative_path,
+ "ext": ext if ext else None,
+ "token": token,
+ "store_name": store_name,
+ }
+
+ return relative_path, self._field_backend(store_name)
+
+ def _field_backend(self, store_name: str | None):
+ """Return the StorageBackend for the named store."""
+ try:
+ return get_store_backend(store_name, config=self._table.connection._config)
+ except DataJointError:
+ raise DataJointError("Storage is not configured. Set stores.default and stores. settings in datajoint.json.")
+
+ def store(self, field: str, ext: str = "") -> fsspec.FSMap:
+ """
+ Get an FSMap for direct writes to an ```` field.
+
+ Args:
+ field: Name of the object attribute
+ ext: Optional extension (e.g., ".zarr", ".hdf5")
+
+ Returns:
+ fsspec.FSMap suitable for Zarr/xarray
+ """
+ relative_path, backend = self._resolve_field(field, ext)
+ return backend.get_fsmap(relative_path)
+
+ def open(self, field: str, ext: str = "", mode: str = "wb") -> IO:
+ """
+ Open a file for direct writes to an ```` field.
+
+ Args:
+ field: Name of the object attribute
+ ext: Optional extension (e.g., ".bin", ".dat")
+ mode: File mode (default: "wb")
+
+ Returns:
+ File-like object for writing
+ """
+ relative_path, backend = self._resolve_field(field, ext)
+ return backend.open(relative_path, mode)
+
+ def _compute_metadata(self, field: str) -> dict:
+ """
+ Compute the canonical ```` metadata dict for a staged write.
+
+ The returned dict is structurally equal to what ``ObjectCodec.encode``
+ would produce for the same content, modulo ``timestamp``.
+
+ Returns
+ -------
+ dict
+ ``{path, store, size, ext, is_dir, item_count, timestamp}``
+ """
+ info = self._staged_objects[field]
+ relative_path = info["relative_path"]
+ ext = info["ext"]
+ store_name = info["store_name"]
+ backend = self._field_backend(store_name)
+
+ full_remote_path = backend._full_path(relative_path)
+
+ try:
+ is_dir = backend.fs.isdir(full_remote_path)
+ except Exception:
+ is_dir = False
+
+ if is_dir:
+ total_size = 0
+ item_count = 0
+ for root, _dirs, filenames in backend.fs.walk(full_remote_path):
+ for filename in filenames:
+ try:
+ total_size += backend.fs.size(f"{root}/{filename}")
+ item_count += 1
+ except Exception:
+ pass
+ size = total_size
+ else:
+ try:
+ size = backend.size(relative_path)
+ except Exception:
+ size = 0
+ item_count = None
+
+ return {
+ "path": relative_path,
+ "store": store_name,
+ "size": size,
+ "ext": ext,
+ "is_dir": is_dir,
+ "item_count": item_count,
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ }
+
+ def _finalize(self):
+ """
+ Compute metadata for each staged object and insert the row.
+ """
+ for field in list(self._staged_objects.keys()):
+ self._rec[field] = self._compute_metadata(field)
+ self._table.insert1(self._rec)
+
+ def _cleanup(self):
+ """
+ Best-effort removal of staged objects on failure.
+ """
+ for field, info in self._staged_objects.items():
+ relative_path = info["relative_path"]
+ try:
+ backend = self._field_backend(info["store_name"])
+ full_remote_path = backend._full_path(relative_path)
+ if backend.fs.exists(full_remote_path):
+ if backend.fs.isdir(full_remote_path):
+ backend.remove_folder(relative_path)
+ else:
+ backend.remove(relative_path)
+ except Exception:
+ pass # Best-effort cleanup
+
+
+@contextmanager
+def staged_insert1(table):
+ """
+ Context manager for staged insert operations.
+
+ Args:
+ table: The Table instance to insert into
+
+ Yields:
+ StagedInsert instance for setting record values and getting storage handles
+
+ Example:
+ with staged_insert1(Recording) as staged:
+ staged.rec['subject_id'] = 123
+ staged.rec['session_id'] = 45
+ z = zarr.open(staged.store('raw_data', '.zarr'), mode='w')
+ z[:] = data
+ # Metadata for 'raw_data' is computed on clean exit; do not assign it here.
+ """
+ staged = StagedInsert(table)
+ try:
+ yield staged
+ staged._finalize()
+ except Exception:
+ staged._cleanup()
+ raise
diff --git a/src/datajoint/storage.py b/src/datajoint/storage.py
new file mode 100644
index 000000000..6a8260163
--- /dev/null
+++ b/src/datajoint/storage.py
@@ -0,0 +1,1040 @@
+"""
+Storage backend abstraction using fsspec for unified file operations.
+
+This module provides a unified interface for storage operations across different
+backends (local filesystem, S3, GCS, Azure, etc.) using the fsspec library.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import secrets
+import urllib.parse
+from datetime import datetime, timezone
+from pathlib import Path, PurePosixPath
+from typing import Any
+
+import fsspec
+
+from . import errors
+
+logger = logging.getLogger(__name__.split(".")[0])
+
+# Characters safe for use in filenames and URLs
+TOKEN_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
+
+# Supported URL protocols
+URL_PROTOCOLS = ("file://", "s3://", "gs://", "gcs://", "az://", "abfs://", "http://", "https://")
+
+
+def is_url(path: str) -> bool:
+ """
+ Check if a path is a URL.
+
+ Parameters
+ ----------
+ path : str
+ Path string to check.
+
+ Returns
+ -------
+ bool
+ True if path starts with a supported URL protocol.
+ """
+ return path.lower().startswith(URL_PROTOCOLS)
+
+
+def normalize_to_url(path: str) -> str:
+ """
+ Normalize a path to URL form.
+
+ Converts local filesystem paths to file:// URLs. URLs are returned unchanged.
+
+ Parameters
+ ----------
+ path : str
+ Path string (local path or URL).
+
+ Returns
+ -------
+ str
+ URL form of the path.
+
+ Examples
+ --------
+ >>> normalize_to_url("/data/file.dat")
+ 'file:///data/file.dat'
+ >>> normalize_to_url("s3://bucket/key")
+ 's3://bucket/key'
+ >>> normalize_to_url("file:///already/url")
+ 'file:///already/url'
+ """
+ if is_url(path):
+ return path
+ # Convert local path to file:// URL
+ # Ensure absolute path and proper format
+ abs_path = str(Path(path).resolve())
+ # Handle Windows paths (C:\...) vs Unix paths (/...)
+ if abs_path.startswith("/"):
+ return f"file://{abs_path}"
+ else:
+ # Windows: file:///C:/path
+ return f"file:///{abs_path.replace(chr(92), '/')}"
+
+
+def parse_url(url: str) -> tuple[str, str]:
+ """
+ Parse a URL into protocol and path.
+
+ Parameters
+ ----------
+ url : str
+ URL (e.g., ``'s3://bucket/path/file.dat'`` or ``'file:///path/to/file'``).
+
+ Returns
+ -------
+ tuple[str, str]
+ ``(protocol, path)`` where protocol is fsspec-compatible.
+
+ Raises
+ ------
+ DataJointError
+ If URL protocol is not supported.
+
+ Examples
+ --------
+ >>> parse_url("s3://bucket/key/file.dat")
+ ('s3', 'bucket/key/file.dat')
+ >>> parse_url("file:///data/file.dat")
+ ('file', '/data/file.dat')
+ """
+ url_lower = url.lower()
+
+ # Map URL schemes to fsspec protocols
+ protocol_map = {
+ "file://": "file",
+ "s3://": "s3",
+ "gs://": "gcs",
+ "gcs://": "gcs",
+ "az://": "abfs",
+ "abfs://": "abfs",
+ "http://": "http",
+ "https://": "https",
+ }
+
+ for prefix, protocol in protocol_map.items():
+ if url_lower.startswith(prefix):
+ path = url[len(prefix) :]
+ return protocol, path
+
+ raise errors.DataJointError(f"Unsupported URL protocol: {url}")
+
+
+def generate_token(length: int = 8) -> str:
+ """
+ Generate a random token for filename collision avoidance.
+
+ Parameters
+ ----------
+ length : int, optional
+ Token length, clamped to 4-16 characters. Default 8.
+
+ Returns
+ -------
+ str
+ Random URL-safe string.
+ """
+ length = max(4, min(16, length))
+ return "".join(secrets.choice(TOKEN_ALPHABET) for _ in range(length))
+
+
+def encode_pk_value(value: Any) -> str:
+ """
+ Encode a primary key value for use in storage paths.
+
+ Parameters
+ ----------
+ value : any
+ Primary key value (int, str, date, datetime, etc.).
+
+ Returns
+ -------
+ str
+ Path-safe string representation.
+ """
+ if isinstance(value, (int, float)):
+ return str(value)
+ if isinstance(value, datetime):
+ # Use ISO format with safe separators
+ return value.strftime("%Y-%m-%dT%H-%M-%S")
+ if hasattr(value, "isoformat"):
+ # Handle date objects
+ return value.isoformat()
+
+ # String handling
+ s = str(value)
+ # Check if path-safe (no special characters)
+ unsafe_chars = '/\\:*?"<>|'
+ if any(c in s for c in unsafe_chars) or len(s) > 100:
+ # URL-encode unsafe strings or truncate long ones
+ if len(s) > 100:
+ # Truncate and add hash suffix for uniqueness
+ import hashlib
+
+ hash_suffix = hashlib.md5(s.encode()).hexdigest()[:8]
+ s = s[:50] + "_" + hash_suffix
+ return urllib.parse.quote(s, safe="")
+ return s
+
+
+def build_object_path(
+ schema: str,
+ table: str,
+ field: str,
+ primary_key: dict[str, Any],
+ ext: str | None,
+ partition_pattern: str | None = None,
+ token_length: int = 8,
+) -> tuple[str, str]:
+ """
+ Build the storage path for an object attribute.
+
+ Parameters
+ ----------
+ schema : str
+ Schema name.
+ table : str
+ Table name.
+ field : str
+ Field/attribute name.
+ primary_key : dict[str, Any]
+ Dict of primary key attribute names to values.
+ ext : str or None
+ File extension (e.g., ``".dat"``).
+ partition_pattern : str, optional
+ Partition pattern with ``{attr}`` placeholders.
+ token_length : int, optional
+ Length of random token suffix. Default 8.
+
+ Returns
+ -------
+ tuple[str, str]
+ ``(relative_path, token)``.
+ """
+ token = generate_token(token_length)
+
+ # Build filename: field_token.ext
+ filename = f"{field}_{token}"
+ if ext:
+ if not ext.startswith("."):
+ ext = "." + ext
+ filename += ext
+
+ # Build primary key path components
+ pk_parts = []
+ partition_attrs = set()
+ partition_attr_list = []
+
+ # Extract partition attributes if pattern specified
+ if partition_pattern:
+ import re
+
+ # Preserve order from pattern
+ partition_attr_list = re.findall(r"\{(\w+)\}", partition_pattern)
+ partition_attrs = set(partition_attr_list) # For fast lookup
+
+ # Build partition prefix (attributes in order from partition pattern)
+ partition_parts = []
+ for attr in partition_attr_list:
+ if attr in primary_key:
+ partition_parts.append(f"{attr}={encode_pk_value(primary_key[attr])}")
+
+ # Build remaining PK path (attributes not in partition)
+ for attr, value in primary_key.items():
+ if attr not in partition_attrs:
+ pk_parts.append(f"{attr}={encode_pk_value(value)}")
+
+ # Construct full path
+ # Pattern: {partition_attrs}/{schema}/{table}/{remaining_pk}/{filename}
+ parts = []
+ if partition_parts:
+ parts.extend(partition_parts)
+ parts.append(schema)
+ parts.append(table)
+ if pk_parts:
+ parts.extend(pk_parts)
+ parts.append(filename)
+
+ return "/".join(parts), token
+
+
+class StorageBackend:
+ """
+ Unified storage backend using fsspec.
+
+ Provides a consistent interface for file operations across different storage
+ backends including local filesystem and cloud object storage (S3, GCS, Azure).
+
+ Parameters
+ ----------
+ spec : dict[str, Any]
+ Storage configuration dictionary. See ``__init__`` for details.
+
+ Attributes
+ ----------
+ spec : dict
+ Storage configuration dictionary.
+ protocol : str
+ Storage protocol (``'file'``, ``'s3'``, ``'gcs'``, ``'azure'``).
+ """
+
+ def __init__(self, spec: dict[str, Any]) -> None:
+ """
+ Initialize storage backend from configuration spec.
+
+ Parameters
+ ----------
+ spec : dict[str, Any]
+ Storage configuration dictionary containing:
+
+ - ``protocol``: Storage protocol (``'file'``, ``'s3'``, ``'gcs'``, ``'azure'``)
+ - ``location``: Base path or bucket prefix
+ - ``bucket``: Bucket name (for cloud storage)
+ - ``endpoint``: Endpoint URL (for S3-compatible storage)
+ - ``access_key``: Access key (for cloud storage)
+ - ``secret_key``: Secret key (for cloud storage)
+ - ``secure``: Use HTTPS (default True for cloud)
+ """
+ self.spec = spec
+ self.protocol = spec.get("protocol", "file")
+ self._fs = None
+ self._validate_spec()
+
+ def _validate_spec(self):
+ """Validate configuration spec for the protocol."""
+ if self.protocol == "file":
+ location = self.spec.get("location")
+ if location and not Path(location).is_dir():
+ raise FileNotFoundError(f"Inaccessible local directory {location}")
+ elif self.protocol == "s3":
+ required = ["endpoint", "bucket", "access_key", "secret_key"]
+ missing = [k for k in required if not self.spec.get(k)]
+ if missing:
+ raise errors.DataJointError(f"Missing S3 configuration: {', '.join(missing)}")
+
+ @property
+ def fs(self) -> fsspec.AbstractFileSystem:
+ """Get or create the fsspec filesystem instance."""
+ if self._fs is None:
+ self._fs = self._create_filesystem()
+ return self._fs
+
+ def _require_adapter(self):
+ """Look up a registered storage adapter, raising if none is registered."""
+ from .storage_adapter import get_storage_adapter
+
+ adapter = get_storage_adapter(self.protocol)
+ if adapter is None:
+ raise errors.DataJointError(f"Unsupported storage protocol: {self.protocol}")
+ return adapter
+
+ def _create_filesystem(self) -> fsspec.AbstractFileSystem:
+ """Create fsspec filesystem based on protocol."""
+ if self.protocol == "file":
+ return fsspec.filesystem("file", auto_mkdir=True)
+
+ elif self.protocol == "s3":
+ # Build S3 configuration
+ endpoint = self.spec["endpoint"]
+ # Determine if endpoint includes protocol
+ if not endpoint.startswith(("http://", "https://")):
+ secure = self.spec.get("secure", False)
+ endpoint_url = f"{'https' if secure else 'http'}://{endpoint}"
+ else:
+ endpoint_url = endpoint
+
+ return fsspec.filesystem(
+ "s3",
+ key=self.spec["access_key"],
+ secret=self.spec["secret_key"],
+ client_kwargs={"endpoint_url": endpoint_url},
+ )
+
+ elif self.protocol == "gcs":
+ return fsspec.filesystem(
+ "gcs",
+ token=self.spec.get("token"),
+ project=self.spec.get("project"),
+ )
+
+ elif self.protocol == "azure":
+ return fsspec.filesystem(
+ "abfs",
+ account_name=self.spec.get("account_name"),
+ account_key=self.spec.get("account_key"),
+ connection_string=self.spec.get("connection_string"),
+ )
+
+ else:
+ return self._require_adapter().create_filesystem(self.spec)
+
+ def _full_path(self, path: str | PurePosixPath) -> str:
+ """
+ Construct full path including location/bucket prefix.
+
+ Parameters
+ ----------
+ path : str or PurePosixPath
+ Relative path within the storage location.
+
+ Returns
+ -------
+ str
+ Full path suitable for fsspec operations.
+ """
+ path = str(path)
+ if self.protocol == "s3":
+ bucket = self.spec["bucket"]
+ location = self.spec.get("location", "")
+ if location:
+ return f"{bucket}/{location}/{path}"
+ return f"{bucket}/{path}"
+ elif self.protocol in ("gcs", "azure"):
+ bucket = self.spec.get("bucket") or self.spec.get("container")
+ location = self.spec.get("location", "")
+ if location:
+ return f"{bucket}/{location}/{path}"
+ return f"{bucket}/{path}"
+ elif self.protocol == "file":
+ location = self.spec.get("location", "")
+ if location:
+ return str(Path(location) / path)
+ return path
+ else:
+ return self._require_adapter().full_path(self.spec, path)
+
+ def get_url(self, path: str | PurePosixPath) -> str:
+ """
+ Get the full URL for a path in storage.
+
+ Returns a consistent URL representation for any storage backend,
+ including file:// URLs for local filesystem.
+
+ Parameters
+ ----------
+ path : str or PurePosixPath
+ Relative path within the storage location.
+
+ Returns
+ -------
+ str
+ Full URL (e.g., 's3://bucket/path' or 'file:///data/path').
+
+ Examples
+ --------
+ >>> backend = StorageBackend({"protocol": "file", "location": "/data"})
+ >>> backend.get_url("schema/table/file.dat")
+ 'file:///data/schema/table/file.dat'
+
+ >>> backend = StorageBackend({"protocol": "s3", "bucket": "mybucket", ...})
+ >>> backend.get_url("schema/table/file.dat")
+ 's3://mybucket/schema/table/file.dat'
+ """
+ full_path = self._full_path(path)
+
+ if self.protocol == "file":
+ # Ensure absolute path for file:// URL
+ abs_path = str(Path(full_path).resolve())
+ if abs_path.startswith("/"):
+ return f"file://{abs_path}"
+ else:
+ # Windows path
+ return f"file:///{abs_path.replace(chr(92), '/')}"
+ elif self.protocol == "s3":
+ return f"s3://{full_path}"
+ elif self.protocol == "gcs":
+ return f"gs://{full_path}"
+ elif self.protocol == "azure":
+ return f"az://{full_path}"
+ else:
+ return self._require_adapter().get_url(self.spec, full_path)
+
+ def put_file(self, local_path: str | Path, remote_path: str | PurePosixPath, metadata: dict | None = None) -> None:
+ """
+ Upload a file from local filesystem to storage.
+
+ Parameters
+ ----------
+ local_path : str or Path
+ Path to local file.
+ remote_path : str or PurePosixPath
+ Destination path in storage.
+ metadata : dict, optional
+ Metadata to attach to the file (cloud storage only).
+ """
+ full_path = self._full_path(remote_path)
+ logger.debug(f"put_file: {local_path} -> {self.protocol}:{full_path}")
+
+ if self.protocol == "file":
+ # For local filesystem, use safe copy with atomic rename
+ from .utils import safe_copy
+
+ Path(full_path).parent.mkdir(parents=True, exist_ok=True)
+ safe_copy(local_path, full_path, overwrite=True)
+ else:
+ # For cloud storage, use fsspec put
+ self.fs.put_file(str(local_path), full_path)
+
+ def get_file(self, remote_path: str | PurePosixPath, local_path: str | Path) -> None:
+ """
+ Download a file from storage to local filesystem.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+ local_path : str or Path
+ Destination path on local filesystem.
+ """
+ full_path = self._full_path(remote_path)
+ logger.debug(f"get_file: {self.protocol}:{full_path} -> {local_path}")
+
+ local_path = Path(local_path)
+ local_path.parent.mkdir(parents=True, exist_ok=True)
+
+ if self.protocol == "file":
+ from .utils import safe_copy
+
+ safe_copy(full_path, local_path)
+ else:
+ self.fs.get_file(full_path, str(local_path))
+
+ def put_buffer(self, buffer: bytes, remote_path: str | PurePosixPath) -> None:
+ """
+ Write bytes to storage.
+
+ Parameters
+ ----------
+ buffer : bytes
+ Bytes to write.
+ remote_path : str or PurePosixPath
+ Destination path in storage.
+ """
+ full_path = self._full_path(remote_path)
+ logger.debug(f"put_buffer: {len(buffer)} bytes -> {self.protocol}:{full_path}")
+
+ if self.protocol == "file":
+ from .utils import safe_write
+
+ Path(full_path).parent.mkdir(parents=True, exist_ok=True)
+ safe_write(full_path, buffer)
+ else:
+ self.fs.pipe_file(full_path, buffer)
+
+ def get_buffer(self, remote_path: str | PurePosixPath) -> bytes:
+ """
+ Read bytes from storage.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+
+ Returns
+ -------
+ bytes
+ File contents.
+
+ Raises
+ ------
+ MissingExternalFile
+ If the file does not exist.
+ """
+ full_path = self._full_path(remote_path)
+ logger.debug(f"get_buffer: {self.protocol}:{full_path}")
+
+ try:
+ if self.protocol == "file":
+ return Path(full_path).read_bytes()
+ else:
+ return self.fs.cat_file(full_path)
+ except FileNotFoundError:
+ raise errors.MissingExternalFile(f"Missing external file {full_path}") from None
+
+ def exists(self, remote_path: str | PurePosixPath) -> bool:
+ """
+ Check if a path (file or directory) exists in storage.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+
+ Returns
+ -------
+ bool
+ True if the path exists.
+ """
+ full_path = self._full_path(remote_path)
+ logger.debug(f"exists: {self.protocol}:{full_path}")
+ return self.fs.exists(full_path)
+
+ def isdir(self, remote_path: str | PurePosixPath) -> bool:
+ """
+ Check if a path refers to a directory in storage.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+
+ Returns
+ -------
+ bool
+ True if the path is a directory.
+ """
+ full_path = self._full_path(remote_path)
+ return self.fs.isdir(full_path)
+
+ def remove(self, remote_path: str | PurePosixPath) -> None:
+ """
+ Remove a file from storage.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+ """
+ full_path = self._full_path(remote_path)
+ logger.debug(f"remove: {self.protocol}:{full_path}")
+
+ try:
+ if self.protocol == "file":
+ Path(full_path).unlink(missing_ok=True)
+ else:
+ self.fs.rm(full_path)
+ except FileNotFoundError:
+ pass # Already gone
+
+ def size(self, remote_path: str | PurePosixPath) -> int:
+ """
+ Get file size in bytes.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+
+ Returns
+ -------
+ int
+ File size in bytes.
+ """
+ full_path = self._full_path(remote_path)
+
+ if self.protocol == "file":
+ return Path(full_path).stat().st_size
+ else:
+ return self.fs.size(full_path)
+
+ def open(self, remote_path: str | PurePosixPath, mode: str = "rb"):
+ """
+ Open a file in storage.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+ mode : str, optional
+ File mode (``'rb'``, ``'wb'``, etc.). Default ``'rb'``.
+
+ Returns
+ -------
+ file-like
+ File-like object for reading or writing.
+ """
+ full_path = self._full_path(remote_path)
+
+ # For write modes on local filesystem, ensure parent directory exists
+ if self.protocol == "file" and "w" in mode:
+ Path(full_path).parent.mkdir(parents=True, exist_ok=True)
+
+ return self.fs.open(full_path, mode)
+
+ def put_folder(self, local_path: str | Path, remote_path: str | PurePosixPath) -> dict:
+ """
+ Upload a folder to storage.
+
+ Parameters
+ ----------
+ local_path : str or Path
+ Path to local folder.
+ remote_path : str or PurePosixPath
+ Destination path in storage.
+
+ Returns
+ -------
+ dict
+ Manifest with keys ``'files'``, ``'total_size'``, ``'item_count'``,
+ ``'created'``.
+ """
+ local_path = Path(local_path)
+ if not local_path.is_dir():
+ raise errors.DataJointError(f"Not a directory: {local_path}")
+
+ full_path = self._full_path(remote_path)
+ logger.debug(f"put_folder: {local_path} -> {self.protocol}:{full_path}")
+
+ # Collect file info for manifest
+ files = []
+ total_size = 0
+
+ # Use os.walk for Python 3.10 compatibility (Path.walk() requires 3.12+)
+ import os
+
+ for root, dirs, filenames in os.walk(local_path):
+ root_path = Path(root)
+ for filename in filenames:
+ file_path = root_path / filename
+ rel_path = file_path.relative_to(local_path).as_posix()
+ file_size = file_path.stat().st_size
+ files.append({"path": rel_path, "size": file_size})
+ total_size += file_size
+
+ # Upload folder contents
+ if self.protocol == "file":
+ import shutil
+
+ dest = Path(full_path)
+ dest.mkdir(parents=True, exist_ok=True)
+ for item in local_path.iterdir():
+ if item.is_file():
+ shutil.copy2(item, dest / item.name)
+ else:
+ shutil.copytree(item, dest / item.name, dirs_exist_ok=True)
+ else:
+ self.fs.put(str(local_path), full_path, recursive=True)
+
+ # Build manifest
+ manifest = {
+ "files": files,
+ "total_size": total_size,
+ "item_count": len(files),
+ "created": datetime.now(timezone.utc).isoformat(),
+ }
+
+ # Write manifest alongside folder
+ manifest_path = f"{remote_path}.manifest.json"
+ self.put_buffer(json.dumps(manifest, indent=2).encode(), manifest_path)
+
+ return manifest
+
+ def remove_folder(self, remote_path: str | PurePosixPath) -> None:
+ """
+ Remove a folder and its manifest from storage.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path to folder in storage.
+ """
+ full_path = self._full_path(remote_path)
+ logger.debug(f"remove_folder: {self.protocol}:{full_path}")
+
+ try:
+ if self.protocol == "file":
+ import shutil
+
+ shutil.rmtree(full_path, ignore_errors=True)
+ else:
+ self.fs.rm(full_path, recursive=True)
+ except FileNotFoundError:
+ pass
+
+ # Also remove manifest
+ manifest_path = f"{remote_path}.manifest.json"
+ self.remove(manifest_path)
+
+ def get_fsmap(self, remote_path: str | PurePosixPath) -> fsspec.FSMap:
+ """
+ Get an FSMap for a path (useful for Zarr/xarray).
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+
+ Returns
+ -------
+ fsspec.FSMap
+ Mapping interface for the storage path.
+ """
+ full_path = self._full_path(remote_path)
+ return fsspec.FSMap(full_path, self.fs)
+
+ def copy_from_url(self, source_url: str, dest_path: str | PurePosixPath) -> int:
+ """
+ Copy a file from a remote URL to managed storage.
+
+ Parameters
+ ----------
+ source_url : str
+ Remote URL (``s3://``, ``gs://``, ``http://``, etc.).
+ dest_path : str or PurePosixPath
+ Destination path in managed storage.
+
+ Returns
+ -------
+ int
+ Size of copied file in bytes.
+ """
+ protocol, source_path = parse_url(source_url)
+ full_dest = self._full_path(dest_path)
+
+ logger.debug(f"copy_from_url: {protocol}://{source_path} -> {self.protocol}:{full_dest}")
+
+ # Get source filesystem
+ source_fs = fsspec.filesystem(protocol)
+
+ # Check if source is a directory
+ if source_fs.isdir(source_path):
+ return self._copy_folder_from_url(source_fs, source_path, dest_path)
+
+ # Copy single file
+ if self.protocol == "file":
+ # Download to local destination
+ Path(full_dest).parent.mkdir(parents=True, exist_ok=True)
+ source_fs.get_file(source_path, full_dest)
+ return Path(full_dest).stat().st_size
+ else:
+ # Remote-to-remote copy via streaming
+ with source_fs.open(source_path, "rb") as src:
+ content = src.read()
+ self.fs.pipe_file(full_dest, content)
+ return len(content)
+
+ def _copy_folder_from_url(
+ self, source_fs: fsspec.AbstractFileSystem, source_path: str, dest_path: str | PurePosixPath
+ ) -> dict:
+ """
+ Copy a folder from a remote URL to managed storage.
+
+ Parameters
+ ----------
+ source_fs : fsspec.AbstractFileSystem
+ Source filesystem.
+ source_path : str
+ Path in source filesystem.
+ dest_path : str or PurePosixPath
+ Destination path in managed storage.
+
+ Returns
+ -------
+ dict
+ Manifest with keys ``'files'``, ``'total_size'``, ``'item_count'``,
+ ``'created'``.
+ """
+ full_dest = self._full_path(dest_path)
+ logger.debug(f"copy_folder_from_url: {source_path} -> {self.protocol}:{full_dest}")
+
+ # Collect file info for manifest
+ files = []
+ total_size = 0
+
+ # Walk source directory
+ for root, dirs, filenames in source_fs.walk(source_path):
+ for filename in filenames:
+ src_file = f"{root}/{filename}" if root != source_path else f"{source_path}/{filename}"
+ rel_path = src_file[len(source_path) :].lstrip("/")
+ file_size = source_fs.size(src_file)
+ files.append({"path": rel_path, "size": file_size})
+ total_size += file_size
+
+ # Copy file
+ dest_file = f"{full_dest}/{rel_path}"
+ if self.protocol == "file":
+ Path(dest_file).parent.mkdir(parents=True, exist_ok=True)
+ source_fs.get_file(src_file, dest_file)
+ else:
+ with source_fs.open(src_file, "rb") as src:
+ content = src.read()
+ self.fs.pipe_file(dest_file, content)
+
+ # Build manifest
+ manifest = {
+ "files": files,
+ "total_size": total_size,
+ "item_count": len(files),
+ "created": datetime.now(timezone.utc).isoformat(),
+ }
+
+ # Write manifest alongside folder
+ manifest_path = f"{dest_path}.manifest.json"
+ self.put_buffer(json.dumps(manifest, indent=2).encode(), manifest_path)
+
+ return manifest
+
+ def source_is_directory(self, source: str) -> bool:
+ """
+ Check if a source path (local or remote URL) is a directory.
+
+ Parameters
+ ----------
+ source : str
+ Local path or remote URL.
+
+ Returns
+ -------
+ bool
+ True if source is a directory.
+ """
+ if is_url(source):
+ protocol, path = parse_url(source)
+ source_fs = fsspec.filesystem(protocol)
+ return source_fs.isdir(path)
+ else:
+ return Path(source).is_dir()
+
+ def source_exists(self, source: str) -> bool:
+ """
+ Check if a source path (local or remote URL) exists.
+
+ Parameters
+ ----------
+ source : str
+ Local path or remote URL.
+
+ Returns
+ -------
+ bool
+ True if source exists.
+ """
+ if is_url(source):
+ protocol, path = parse_url(source)
+ source_fs = fsspec.filesystem(protocol)
+ return source_fs.exists(path)
+ else:
+ return Path(source).exists()
+
+ def get_source_size(self, source: str) -> int | None:
+ """
+ Get the size of a source file (local or remote URL).
+
+ Parameters
+ ----------
+ source : str
+ Local path or remote URL.
+
+ Returns
+ -------
+ int or None
+ Size in bytes, or None if directory or cannot determine.
+ """
+ try:
+ if is_url(source):
+ protocol, path = parse_url(source)
+ source_fs = fsspec.filesystem(protocol)
+ if source_fs.isdir(path):
+ return None
+ return source_fs.size(path)
+ else:
+ p = Path(source)
+ if p.is_dir():
+ return None
+ return p.stat().st_size
+ except Exception:
+ return None
+
+
+STORE_METADATA_FILENAME = "datajoint_store.json"
+
+
+def get_storage_backend(spec: dict[str, Any]) -> StorageBackend:
+ """
+ Factory function to create a storage backend from configuration.
+
+ Parameters
+ ----------
+ spec : dict[str, Any]
+ Storage configuration dictionary.
+
+ Returns
+ -------
+ StorageBackend
+ Configured storage backend instance.
+ """
+ return StorageBackend(spec)
+
+
+def verify_or_create_store_metadata(backend: StorageBackend, spec: dict[str, Any]) -> dict:
+ """
+ Verify or create the store metadata file at the storage root.
+
+ On first use, creates the ``datajoint_store.json`` file with project info.
+ On subsequent uses, verifies the ``project_name`` matches.
+
+ Parameters
+ ----------
+ backend : StorageBackend
+ Storage backend instance.
+ spec : dict[str, Any]
+ Object storage configuration spec.
+
+ Returns
+ -------
+ dict
+ Store metadata dictionary.
+
+ Raises
+ ------
+ DataJointError
+ If ``project_name`` mismatch detected.
+ """
+ from .version import __version__ as dj_version
+
+ project_name = spec.get("project_name")
+ location = spec.get("location", "")
+
+ # Metadata file path at storage root
+ metadata_path = f"{location}/{STORE_METADATA_FILENAME}" if location else STORE_METADATA_FILENAME
+
+ try:
+ # Try to read existing metadata
+ if backend.exists(metadata_path):
+ metadata_content = backend.get_buffer(metadata_path)
+ metadata = json.loads(metadata_content)
+
+ # Verify project_name matches
+ store_project = metadata.get("project_name")
+ if store_project and store_project != project_name:
+ raise errors.DataJointError(
+ f"Object store project name mismatch.\n"
+ f' Client configured: "{project_name}"\n'
+ f' Store metadata: "{store_project}"\n'
+ f"Ensure all clients use the same object_storage.project_name setting."
+ )
+
+ return metadata
+ else:
+ # Create new metadata
+ metadata = {
+ "project_name": project_name,
+ "created": datetime.now(timezone.utc).isoformat(),
+ "format_version": "1.0",
+ "datajoint_version": dj_version,
+ }
+
+ # Optional database info - not enforced, just informational
+ # These would need to be passed in from the connection context
+ # For now, omit them
+
+ backend.put_buffer(json.dumps(metadata, indent=2).encode(), metadata_path)
+ return metadata
+
+ except errors.DataJointError:
+ raise
+ except Exception as e:
+ # Log warning but don't fail - metadata is informational
+ logger.warning(f"Could not verify/create store metadata: {e}")
+ return {"project_name": project_name}
diff --git a/src/datajoint/storage_adapter.py b/src/datajoint/storage_adapter.py
new file mode 100644
index 000000000..0cb93031b
--- /dev/null
+++ b/src/datajoint/storage_adapter.py
@@ -0,0 +1,102 @@
+"""Plugin system for third-party storage protocols.
+
+Third-party packages register adapters via entry points::
+
+ [project.entry-points."datajoint.storage"]
+ myprotocol = "my_package:MyStorageAdapter"
+
+The adapter is auto-discovered when DataJoint encounters the protocol name
+in a store configuration. No explicit import is needed.
+"""
+
+from abc import ABC, abstractmethod
+from typing import Any
+import logging
+
+import fsspec
+
+from . import errors
+
+logger = logging.getLogger(__name__)
+
+
+class StorageAdapter(ABC):
+ """Base class for storage protocol adapters.
+
+ Subclass this and declare an entry point to add a new storage protocol
+ to DataJoint. At minimum, implement ``create_filesystem`` and set
+ ``protocol``, ``required_keys``, and ``allowed_keys``.
+ """
+
+ protocol: str
+ required_keys: tuple[str, ...] = ()
+ allowed_keys: tuple[str, ...] = ()
+
+ @abstractmethod
+ def create_filesystem(self, spec: dict[str, Any]) -> fsspec.AbstractFileSystem:
+ """Return an fsspec filesystem instance for this protocol."""
+ ...
+
+ def validate_spec(self, spec: dict[str, Any]) -> None:
+ """Validate protocol-specific config fields."""
+ missing = [k for k in self.required_keys if k not in spec]
+ if missing:
+ raise errors.DataJointError(f'{self.protocol} store is missing: {", ".join(missing)}')
+ all_allowed = set(self.allowed_keys) | _COMMON_STORE_KEYS
+ invalid = [k for k in spec if k not in all_allowed]
+ if invalid:
+ raise errors.DataJointError(f'Invalid key(s) for {self.protocol}: {", ".join(invalid)}')
+
+ def full_path(self, spec: dict[str, Any], relpath: str) -> str:
+ """Construct storage path from a relative path."""
+ location = spec.get("location", "")
+ return f"{location}/{relpath}" if location else relpath
+
+ def get_url(self, spec: dict[str, Any], path: str) -> str:
+ """Return a display URL for the stored object."""
+ return f"{self.protocol}://{path}"
+
+
+_COMMON_STORE_KEYS = frozenset(
+ {
+ "protocol",
+ "location",
+ "subfolding",
+ "partition_pattern",
+ "token_length",
+ "hash_prefix",
+ "schema_prefix",
+ "filepath_prefix",
+ "stage",
+ }
+)
+
+_adapter_registry: dict[str, StorageAdapter] = {}
+_adapters_loaded: bool = False
+
+
+def get_storage_adapter(protocol: str) -> StorageAdapter | None:
+ """Look up a registered storage adapter by protocol name."""
+ global _adapters_loaded
+ if not _adapters_loaded:
+ _discover_adapters()
+ _adapters_loaded = True
+ return _adapter_registry.get(protocol)
+
+
+def _discover_adapters() -> None:
+ """Load storage adapters from datajoint.storage entry points."""
+ from importlib.metadata import entry_points
+
+ eps = entry_points(group="datajoint.storage")
+
+ for ep in eps:
+ if ep.name in _adapter_registry:
+ continue
+ try:
+ adapter_cls = ep.load()
+ adapter = adapter_cls()
+ _adapter_registry[adapter.protocol] = adapter
+ logger.debug(f"Loaded storage adapter: {adapter.protocol}")
+ except Exception as e:
+ logger.warning(f"Failed to load storage adapter '{ep.name}': {e}")
diff --git a/src/datajoint/table.py b/src/datajoint/table.py
new file mode 100644
index 000000000..ea82cefec
--- /dev/null
+++ b/src/datajoint/table.py
@@ -0,0 +1,1614 @@
+import collections
+import csv
+import inspect
+import itertools
+import json
+import logging
+import uuid
+import warnings
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import numpy as np
+import pandas
+
+from .condition import make_condition
+from .declare import alter, declare
+from .dependencies import extract_master
+from .errors import (
+ AccessError,
+ DataJointError,
+ DuplicateError,
+ IntegrityError,
+ UnknownAttributeError,
+)
+from .expression import QueryExpression
+from .heading import Heading
+from .staged_insert import staged_insert1 as _staged_insert1
+from .utils import is_camel_case, user_choice
+
+logger = logging.getLogger(__name__.split(".")[0])
+
+# Note: Foreign key error parsing is now handled by adapter methods
+# Legacy regexp and query kept for reference but no longer used
+
+
+@dataclass
+class ValidationResult:
+ """
+ Result of table.validate() call.
+
+ Attributes:
+ is_valid: True if all rows passed validation
+ errors: List of (row_index, field_name, error_message) tuples
+ rows_checked: Number of rows that were validated
+ """
+
+ is_valid: bool
+ errors: list = field(default_factory=list) # list of (row_index, field_name | None, message)
+ rows_checked: int = 0
+
+ def __bool__(self) -> bool:
+ """Allow using ValidationResult in boolean context."""
+ return self.is_valid
+
+ def raise_if_invalid(self):
+ """Raise DataJointError if validation failed."""
+ if not self.is_valid:
+ raise DataJointError(self.summary())
+
+ def summary(self) -> str:
+ """Return formatted error summary."""
+ if self.is_valid:
+ return f"Validation passed: {self.rows_checked} rows checked"
+ lines = [f"Validation failed: {len(self.errors)} error(s) in {self.rows_checked} rows"]
+ for row_idx, field_name, message in self.errors[:10]: # Show first 10 errors
+ field_str = f" in field '{field_name}'" if field_name else ""
+ lines.append(f" Row {row_idx}{field_str}: {message}")
+ if len(self.errors) > 10:
+ lines.append(f" ... and {len(self.errors) - 10} more errors")
+ return "\n".join(lines)
+
+
+class Table(QueryExpression):
+ """
+ Table is an abstract class that represents a table in the schema.
+ It implements insert and delete methods and inherits query functionality.
+ To make it a concrete class, override the abstract properties specifying the connection,
+ table name, database, and definition.
+ """
+
+ _table_name = None # must be defined in subclass
+
+ # These properties must be set by the schema decorator (schemas.py) at class level
+ # or by FreeTable at instance level
+ database = None
+ declaration_context = None
+
+ @property
+ def table_name(self):
+ # For UserTable subclasses, table_name is computed by the metaclass.
+ # Delegate to the class's table_name if _table_name is not set.
+ if self._table_name is None:
+ return type(self).table_name
+ return self._table_name
+
+ @property
+ def class_name(self):
+ return self.__class__.__name__
+
+ # Base tier class names that should not raise errors when heading is None
+ _base_tier_classes = frozenset({"Table", "UserTable", "Lookup", "Manual", "Imported", "Computed", "Part"})
+
+ @property
+ def heading(self):
+ """
+ Return the table's heading, or raise a helpful error if not configured.
+
+ Overrides QueryExpression.heading to provide a clear error message
+ when the table is not properly associated with an activated schema.
+ For base tier classes (Lookup, Manual, etc.), returns None to support
+ introspection (e.g., help()).
+ """
+ if self._heading is None:
+ # Don't raise error for base tier classes - they're used for introspection
+ if self.__class__.__name__ in self._base_tier_classes:
+ return None
+ raise DataJointError(
+ f"Table `{self.__class__.__name__}` is not properly configured. "
+ "Ensure the schema is activated before using the table. "
+ "Example: schema.activate('database_name') or schema = dj.Schema('database_name')"
+ )
+ return self._heading
+
+ @property
+ def definition(self):
+ raise NotImplementedError("Subclasses of Table must implement the `definition` property")
+
+ def declare(self, context=None):
+ """
+ Declare the table in the schema based on self.definition.
+
+ Parameters
+ ----------
+ context : dict, optional
+ The context for foreign key resolution. If None, foreign keys are
+ not allowed.
+ """
+ if self.connection.in_transaction:
+ raise DataJointError("Cannot declare new tables inside a transaction, e.g. from inside a populate/make call")
+ # Validate class name #1150
+ class_name = self.class_name
+ if "_" in class_name:
+ warnings.warn(
+ f"Table class name `{class_name}` contains underscores. CamelCase names without underscores are recommended.",
+ UserWarning,
+ stacklevel=2,
+ )
+ class_name = class_name.replace("_", "")
+ if not is_camel_case(class_name):
+ raise DataJointError(
+ f"Table class name `{self.class_name}` is invalid. "
+ "Class names must be in CamelCase, starting with a capital letter."
+ )
+ sql, _external_stores, primary_key, fk_attribute_map, pre_ddl, post_ddl = declare(
+ self.full_table_name, self.definition, context, self.connection.adapter, config=self.connection._config
+ )
+
+ # Call declaration hook for validation (subclasses like AutoPopulate can override)
+ self._declare_check(primary_key, fk_attribute_map)
+
+ sql = sql.format(database=self.database)
+ try:
+ # Execute pre-DDL statements (e.g., CREATE TYPE for PostgreSQL enums)
+ for ddl in pre_ddl:
+ try:
+ self.connection.query(ddl.format(database=self.database))
+ except Exception:
+ # Ignore errors (type may already exist)
+ pass
+ self.connection.query(sql)
+ # Execute post-DDL statements (e.g., COMMENT ON for PostgreSQL)
+ for ddl in post_ddl:
+ self.connection.query(ddl.format(database=self.database))
+ except AccessError:
+ # Only suppress if table already exists (idempotent declaration)
+ # Otherwise raise - user needs to know about permission issues
+ if self.is_declared:
+ return
+ raise AccessError(
+ f"Cannot declare table {self.full_table_name}. "
+ f"Check that you have CREATE privilege on schema `{self.database}` "
+ f"and REFERENCES privilege on any referenced parent tables."
+ ) from None
+
+ # Populate lineage table for this table's attributes
+ self._populate_lineage(primary_key, fk_attribute_map)
+
+ def _declare_check(self, primary_key, fk_attribute_map):
+ """
+ Hook for declaration-time validation. Subclasses can override.
+
+ Called before the table is created in the database. Override this method
+ to add validation logic (e.g., AutoPopulate validates FK-only primary keys).
+
+ Parameters
+ ----------
+ primary_key : list
+ List of primary key attribute names.
+ fk_attribute_map : dict
+ Dict mapping child_attr -> (parent_table, parent_attr).
+ """
+ pass # Default: no validation
+
+ def _populate_lineage(self, primary_key, fk_attribute_map):
+ """
+ Populate the ~lineage table with lineage information for this table's attributes.
+
+ Lineage is stored for:
+ - All FK attributes (traced to their origin)
+ - Native primary key attributes (lineage = self)
+
+ Parameters
+ ----------
+ primary_key : list
+ List of primary key attribute names.
+ fk_attribute_map : dict
+ Dict mapping child_attr -> (parent_table, parent_attr).
+ """
+ from .lineage import (
+ ensure_lineage_table,
+ get_lineage,
+ delete_table_lineages,
+ insert_lineages,
+ )
+
+ # Ensure the ~lineage table exists
+ ensure_lineage_table(self.connection, self.database)
+
+ # Delete any existing lineage entries for this table (for idempotent re-declaration)
+ delete_table_lineages(self.connection, self.database, self.table_name)
+
+ entries = []
+
+ # FK attributes: copy lineage from parent (whether in PK or not)
+ for attr, (parent_table, parent_attr) in fk_attribute_map.items():
+ # Parse parent table name: `schema`.`table` or "schema"."table" -> (schema, table)
+ parent_db, parent_tbl = self.connection.adapter.split_full_table_name(parent_table)
+
+ # Get parent's lineage for this attribute
+ parent_lineage = get_lineage(self.connection, parent_db, parent_tbl, parent_attr)
+ if parent_lineage:
+ # Copy parent's lineage
+ entries.append((self.table_name, attr, parent_lineage))
+ else:
+ # Parent doesn't have lineage entry - use parent as origin
+ # This can happen for legacy/external schemas without lineage tracking
+ lineage = f"{parent_db}.{parent_tbl}.{parent_attr}"
+ entries.append((self.table_name, attr, lineage))
+ logger.warning(
+ f"Lineage for `{parent_db}`.`{parent_tbl}`.`{parent_attr}` not found "
+ f"(parent schema's ~lineage table may be missing or incomplete). "
+ f"Using it as origin. Once the parent schema's lineage is rebuilt, "
+ f"run schema.rebuild_lineage() on this schema to correct the lineage."
+ )
+
+ # Native PK attributes (in PK but not FK): this table is the origin
+ for attr in primary_key:
+ if attr not in fk_attribute_map:
+ lineage = f"{self.database}.{self.table_name}.{attr}"
+ entries.append((self.table_name, attr, lineage))
+
+ if entries:
+ insert_lineages(self.connection, self.database, entries)
+
+ def _refresh_lineage(self, context=None):
+ """
+ Re-derive ``~lineage`` rows from the current definition and overwrite them.
+
+ Called by ``@schema`` decoration on every pass — including when the table
+ is already declared — so that stale rows from earlier DataJoint versions
+ or partial declares do not survive a redeclare. The actual deletion +
+ re-insertion happens in ``_populate_lineage``; this method just parses
+ the definition to obtain ``primary_key`` and ``fk_attribute_map`` without
+ executing any DDL.
+
+ Errors during refresh (e.g. missing write permission on ``~lineage``) are
+ logged and swallowed; a stale row is preferable to a failed import.
+ """
+ try:
+ (
+ _,
+ _,
+ primary_key,
+ fk_attribute_map,
+ _,
+ _,
+ ) = declare(
+ self.full_table_name,
+ self.definition,
+ context,
+ self.connection.adapter,
+ config=self.connection._config,
+ )
+ self._populate_lineage(primary_key, fk_attribute_map)
+ except Exception as exc: # noqa: BLE001 — defensive; see docstring
+ logger.warning(
+ f"Could not refresh lineage for {self.full_table_name}: {exc}. "
+ "If you encounter `different lineages` errors, run "
+ "`schema.rebuild_lineage()` to rebuild from current FK definitions."
+ )
+
+ def alter(self, prompt=True, context=None):
+ """
+ Alter the table definition from self.definition
+ """
+ if self.connection.in_transaction:
+ raise DataJointError("Cannot update table declaration inside a transaction, e.g. from inside a populate/make call")
+ if context is None:
+ frame = inspect.currentframe().f_back
+ context = dict(frame.f_globals, **frame.f_locals)
+ del frame
+ old_definition = self.describe(context=context)
+ sql, _external_stores = alter(self.definition, old_definition, context, self.connection.adapter)
+ if not sql:
+ if prompt:
+ logger.warning("Nothing to alter.")
+ else:
+ sql = "ALTER TABLE {tab}\n\t".format(tab=self.full_table_name) + ",\n\t".join(sql)
+ if not prompt or user_choice(sql + "\n\nExecute?") == "yes":
+ try:
+ self.connection.query(sql)
+ except AccessError:
+ # skip if no create privilege
+ pass
+ else:
+ # reset heading
+ self.__class__._heading = Heading(table_info=self.heading.table_info)
+ if prompt:
+ logger.info("Table altered")
+
+ def from_clause(self):
+ """
+ Return the FROM clause of SQL SELECT statements.
+
+ Returns
+ -------
+ str
+ The full table name for use in SQL FROM clauses.
+ """
+ return self.full_table_name
+
+ def get_select_fields(self, select_fields=None):
+ """
+ Return the selected attributes from the SQL SELECT statement.
+
+ Parameters
+ ----------
+ select_fields : list, optional
+ List of attribute names to select. If None, selects all attributes.
+
+ Returns
+ -------
+ str
+ The SQL field selection string.
+ """
+ return "*" if select_fields is None else self.heading.project(select_fields).as_sql
+
+ def parents(self, primary=None, as_objects=False, foreign_key_info=False):
+ """
+ Return the list of parent tables.
+
+ Parameters
+ ----------
+ primary : bool, optional
+ If None, then all parents are returned. If True, then only foreign keys
+ composed of primary key attributes are considered. If False, return
+ foreign keys including at least one secondary attribute.
+ as_objects : bool, optional
+ If False, return table names. If True, return table objects.
+ foreign_key_info : bool, optional
+ If True, each element in result also includes foreign key info.
+
+ Returns
+ -------
+ list
+ List of parents as table names or table objects with (optional) foreign
+ key information.
+ """
+ get_edge = self.connection.dependencies.parents
+ nodes = [
+ next(iter(get_edge(name).items())) if name.isdigit() else (name, props)
+ for name, props in get_edge(self.full_table_name, primary).items()
+ ]
+ if as_objects:
+ nodes = [(FreeTable(self.connection, name), props) for name, props in nodes]
+ if not foreign_key_info:
+ nodes = [name for name, props in nodes]
+ return nodes
+
+ def children(self, primary=None, as_objects=False, foreign_key_info=False):
+ """
+ Return the list of child tables.
+
+ Parameters
+ ----------
+ primary : bool, optional
+ If None, then all children are returned. If True, then only foreign keys
+ composed of primary key attributes are considered. If False, return
+ foreign keys including at least one secondary attribute.
+ as_objects : bool, optional
+ If False, return table names. If True, return table objects.
+ foreign_key_info : bool, optional
+ If True, each element in result also includes foreign key info.
+
+ Returns
+ -------
+ list
+ List of children as table names or table objects with (optional) foreign
+ key information.
+ """
+ get_edge = self.connection.dependencies.children
+ nodes = [
+ next(iter(get_edge(name).items())) if name.isdigit() else (name, props)
+ for name, props in get_edge(self.full_table_name, primary).items()
+ ]
+ if as_objects:
+ nodes = [(FreeTable(self.connection, name), props) for name, props in nodes]
+ if not foreign_key_info:
+ nodes = [name for name, props in nodes]
+ return nodes
+
+ def descendants(self, as_objects=False):
+ """
+ Return list of descendant tables in topological order.
+
+ Parameters
+ ----------
+ as_objects : bool, optional
+ If False (default), return a list of table names. If True, return a
+ list of table objects.
+
+ Returns
+ -------
+ list
+ List of descendant tables in topological order.
+ """
+ return [
+ FreeTable(self.connection, node) if as_objects else node
+ for node in self.connection.dependencies.descendants(self.full_table_name)
+ if not node.isdigit()
+ ]
+
+ def ancestors(self, as_objects=False):
+ """
+ Return list of ancestor tables in topological order.
+
+ Parameters
+ ----------
+ as_objects : bool, optional
+ If False (default), return a list of table names. If True, return a
+ list of table objects.
+
+ Returns
+ -------
+ list
+ List of ancestor tables in topological order.
+ """
+ return [
+ FreeTable(self.connection, node) if as_objects else node
+ for node in self.connection.dependencies.ancestors(self.full_table_name)
+ if not node.isdigit()
+ ]
+
+ def parts(self, as_objects=False):
+ """
+ Return part tables for this master table.
+
+ Parameters
+ ----------
+ as_objects : bool, optional
+ If False (default), the output is a list of full table names. If True,
+ return table objects.
+
+ Returns
+ -------
+ list
+ List of part table names or table objects.
+ """
+ self.connection.dependencies.load(force=False)
+ nodes = [
+ node
+ for node in self.connection.dependencies.nodes
+ if not node.isdigit() and node.startswith(self.full_table_name[:-1] + "__")
+ ]
+ return [FreeTable(self.connection, c) for c in nodes] if as_objects else nodes
+
+ @property
+ def is_declared(self):
+ """
+ Check if the table is declared in the schema.
+
+ Returns
+ -------
+ bool
+ True if the table is declared in the schema.
+ """
+ query = self.connection.adapter.get_table_info_sql(self.database, self.table_name)
+ return self.connection.query(query).rowcount > 0
+
+ @property
+ def full_table_name(self):
+ """
+ Return the full table name in the schema.
+
+ Returns
+ -------
+ str
+ Full table name in the format `database`.`table_name`.
+ """
+ if self.database is None or self.table_name is None:
+ raise DataJointError(
+ f"Class {self.__class__.__name__} is not associated with a schema. "
+ "Apply a schema decorator or use schema() to bind it."
+ )
+ return self.adapter.make_full_table_name(self.database, self.table_name)
+
+ @property
+ def adapter(self):
+ """Database adapter for backend-agnostic SQL generation."""
+ return self.connection.adapter
+
+ def update1(self, row):
+ """
+ Update one existing entry in the table.
+
+ Caution: In DataJoint the primary modes for data manipulation is to ``insert`` and
+ ``delete`` entire records since referential integrity works on the level of records,
+ not fields. Therefore, updates are reserved for corrective operations outside of main
+ workflow. Use UPDATE methods sparingly with full awareness of potential violations of
+ assumptions.
+
+ The primary key attributes must always be provided.
+
+ Parameters
+ ----------
+ row : dict
+ A dict containing the primary key values and the attributes to update.
+ Setting an attribute value to None will reset it to the default value (if any).
+
+ Examples
+ --------
+ >>> table.update1({'id': 1, 'value': 3}) # update value in record with id=1
+ >>> table.update1({'id': 1, 'value': None}) # reset value to default
+ """
+ # argument validations
+ if not isinstance(row, collections.abc.Mapping):
+ raise DataJointError("The argument of update1 must be dict-like.")
+ if not set(row).issuperset(self.primary_key):
+ raise DataJointError("The argument of update1 must supply all primary key values.")
+ try:
+ raise DataJointError("Attribute `%s` not found." % next(k for k in row if k not in self.heading.names))
+ except StopIteration:
+ pass # ok
+ if len(self.restriction):
+ raise DataJointError("Update cannot be applied to a restricted table.")
+ key = {k: row[k] for k in self.primary_key}
+ if len(self & key) != 1:
+ raise DataJointError("Update can only be applied to one existing entry.")
+ # UPDATE query
+ row = [self.__make_placeholder(k, v) for k, v in row.items() if k not in self.primary_key]
+ assignments = ",".join(f"{self.adapter.quote_identifier(r[0])}={r[1]}" for r in row)
+ query = "UPDATE {table} SET {assignments} WHERE {where}".format(
+ table=self.full_table_name,
+ assignments=assignments,
+ where=make_condition(self, key, set()),
+ )
+ self.connection.query(query, args=list(r[2] for r in row if r[2] is not None))
+
+ def validate(self, rows, *, ignore_extra_fields=False) -> ValidationResult:
+ """
+ Validate rows without inserting them.
+
+ Validates:
+ - Field existence (all fields must be in table heading)
+ - Row format (correct number of attributes for positional inserts)
+ - Codec validation (type checking via codec.validate())
+ - NULL constraints (non-nullable fields must have values)
+ - Primary key completeness (all PK fields must be present)
+ - UUID format and JSON serializability
+
+ Cannot validate (database-enforced):
+ - Foreign key constraints
+ - Unique constraints (other than PK)
+ - Custom MySQL constraints
+
+ Parameters
+ ----------
+ rows : iterable
+ Same format as insert() - iterable of dicts, tuples, numpy records,
+ or a pandas DataFrame.
+ ignore_extra_fields : bool, optional
+ If True, ignore fields not in the table heading.
+
+ Returns
+ -------
+ ValidationResult
+ Result with is_valid, errors list, and rows_checked count.
+
+ Examples
+ --------
+ >>> result = table.validate(rows)
+ >>> if result:
+ ... table.insert(rows)
+ ... else:
+ ... print(result.summary())
+ """
+ errors = []
+
+ # Convert DataFrame to records
+ if isinstance(rows, pandas.DataFrame):
+ rows = rows.reset_index(drop=len(rows.index.names) == 1 and not rows.index.names[0]).to_records(index=False)
+
+ # Convert Path (CSV) to list of dicts
+ if isinstance(rows, Path):
+ with open(rows, newline="") as data_file:
+ rows = list(csv.DictReader(data_file, delimiter=","))
+
+ rows = list(rows) # Materialize iterator
+ row_count = len(rows)
+
+ for row_idx, row in enumerate(rows):
+ # Validate row format and fields
+ row_dict = None
+ try:
+ if isinstance(row, np.void): # numpy record
+ fields = list(row.dtype.fields.keys())
+ row_dict = {name: row[name] for name in fields}
+ elif isinstance(row, collections.abc.Mapping):
+ fields = list(row.keys())
+ row_dict = dict(row)
+ else: # positional tuple/list
+ if len(row) != len(self.heading):
+ errors.append(
+ (
+ row_idx,
+ None,
+ f"Incorrect number of attributes: {len(row)} given, {len(self.heading)} expected",
+ )
+ )
+ continue
+ fields = list(self.heading.names)
+ row_dict = dict(zip(fields, row))
+ except TypeError:
+ errors.append((row_idx, None, f"Invalid row type: {type(row).__name__}"))
+ continue
+
+ # Check for unknown fields
+ if not ignore_extra_fields:
+ for field_name in fields:
+ if field_name not in self.heading:
+ errors.append((row_idx, field_name, f"Field '{field_name}' not in table heading"))
+
+ # Validate each field value
+ for name in self.heading.names:
+ if name not in row_dict:
+ # Check if field is required (non-nullable, no default, not autoincrement)
+ attr = self.heading[name]
+ if not attr.nullable and attr.default is None and not attr.autoincrement:
+ errors.append((row_idx, name, f"Required field '{name}' is missing"))
+ continue
+
+ value = row_dict[name]
+ attr = self.heading[name]
+
+ # Skip validation for None values on nullable columns
+ if value is None:
+ if not attr.nullable and attr.default is None:
+ errors.append((row_idx, name, f"NULL value not allowed for non-nullable field '{name}'"))
+ continue
+
+ # Codec validation
+ if attr.codec:
+ try:
+ attr.codec.validate(value)
+ except (TypeError, ValueError) as e:
+ errors.append((row_idx, name, f"Codec validation failed: {e}"))
+ continue
+
+ # UUID validation
+ if attr.uuid and not isinstance(value, uuid.UUID):
+ try:
+ uuid.UUID(value)
+ except (AttributeError, ValueError):
+ errors.append((row_idx, name, f"Invalid UUID format: {value}"))
+ continue
+
+ # JSON serialization check
+ if attr.json:
+ try:
+ json.dumps(value)
+ except (TypeError, ValueError) as e:
+ errors.append((row_idx, name, f"Value not JSON serializable: {e}"))
+ continue
+
+ # Numeric NaN check
+ if attr.numeric and value != "" and not isinstance(value, (bool, np.bool_)):
+ try:
+ if np.isnan(float(value)):
+ # NaN is allowed - will be converted to NULL
+ pass
+ except (TypeError, ValueError):
+ # Not a number that can be checked for NaN - let it pass
+ pass
+
+ # Check primary key completeness
+ for pk_field in self.primary_key:
+ if pk_field not in row_dict or row_dict[pk_field] is None:
+ pk_attr = self.heading[pk_field]
+ if not pk_attr.autoincrement:
+ errors.append((row_idx, pk_field, f"Primary key field '{pk_field}' is missing or NULL"))
+
+ return ValidationResult(is_valid=len(errors) == 0, errors=errors, rows_checked=row_count)
+
+ def insert1(self, row, **kwargs):
+ """
+ Insert one data record into the table.
+
+ For ``kwargs``, see ``insert()``.
+
+ Parameters
+ ----------
+ row : numpy.void, dict, or sequence
+ A numpy record, a dict-like object, or an ordered sequence to be inserted
+ as one row.
+ **kwargs
+ Additional arguments passed to ``insert()``.
+
+ See Also
+ --------
+ insert : Insert multiple data records.
+ """
+ self.insert((row,), **kwargs)
+
+ @property
+ def staged_insert1(self):
+ """
+ Context manager for staged insert with direct object storage writes.
+
+ Use this for large objects like Zarr arrays where copying from local storage
+ is inefficient. Allows writing directly to the destination storage before
+ finalizing the database insert.
+
+ Example:
+ with table.staged_insert1 as staged:
+ staged.rec['subject_id'] = 123
+ staged.rec['session_id'] = 45
+
+ # Create object storage directly
+ z = zarr.open(staged.store('raw_data', '.zarr'), mode='w', shape=(1000, 1000))
+ z[:] = data
+
+ # Assign to record
+ staged.rec['raw_data'] = z
+
+ # On successful exit: metadata computed, record inserted
+ # On exception: storage cleaned up, no record inserted
+
+ Yields:
+ StagedInsert: Context for setting record values and getting storage handles
+ """
+ return _staged_insert1(self)
+
+ def insert(
+ self,
+ rows,
+ replace=False,
+ skip_duplicates=False,
+ ignore_extra_fields=False,
+ allow_direct_insert=None,
+ chunk_size=None,
+ ):
+ """
+ Insert a collection of rows.
+
+ Parameters
+ ----------
+ rows : iterable or pathlib.Path
+ Either (a) an iterable where an element is a numpy record, a dict-like
+ object, a pandas.DataFrame, a polars.DataFrame, a pyarrow.Table, a
+ sequence, or a query expression with the same heading as self, or
+ (b) a pathlib.Path object specifying a path relative to the current
+ directory with a CSV file, the contents of which will be inserted.
+ replace : bool, optional
+ If True, replaces the existing tuple.
+ skip_duplicates : bool, optional
+ If True, silently skip rows with duplicate primary key values.
+ On **PostgreSQL**, secondary unique constraint violations still
+ raise an error even when ``skip_duplicates=True``, because the
+ generated ``ON CONFLICT (pk) DO NOTHING`` clause targets only
+ the primary key. On **MySQL**, ``ON DUPLICATE KEY UPDATE``
+ catches all unique-key conflicts, so secondary unique violations
+ are also silently skipped.
+ ignore_extra_fields : bool, optional
+ If False (default), fields that are not in the heading raise error.
+ allow_direct_insert : bool, optional
+ Only applies in auto-populated tables. If False (default), insert may
+ only be called from inside the make callback.
+ chunk_size : int, optional
+ If set, insert rows in batches of this size. Useful for very large
+ inserts to avoid memory issues. Each chunk is a separate transaction.
+
+ Examples
+ --------
+ >>> Table.insert([
+ ... dict(subject_id=7, species="mouse", date_of_birth="2014-09-01"),
+ ... dict(subject_id=8, species="mouse", date_of_birth="2014-09-02")])
+
+ Large insert with chunking:
+
+ >>> Table.insert(large_dataset, chunk_size=10000)
+ """
+ if isinstance(rows, pandas.DataFrame):
+ # drop 'extra' synthetic index for 1-field index case -
+ # frames with more advanced indices should be prepared by user.
+ rows = rows.reset_index(drop=len(rows.index.names) == 1 and not rows.index.names[0]).to_records(index=False)
+
+ # Polars DataFrame -> list of dicts (soft dependency, check by type name)
+ if type(rows).__module__.startswith("polars") and type(rows).__name__ == "DataFrame":
+ rows = rows.to_dicts()
+
+ # PyArrow Table -> list of dicts (soft dependency, check by type name)
+ if type(rows).__module__.startswith("pyarrow") and type(rows).__name__ == "Table":
+ rows = rows.to_pylist()
+
+ if isinstance(rows, Path):
+ with open(rows, newline="") as data_file:
+ rows = list(csv.DictReader(data_file, delimiter=","))
+
+ # prohibit direct inserts into auto-populated tables
+ if not allow_direct_insert and not getattr(self, "_allow_insert", True):
+ raise DataJointError(
+ "Inserts into an auto-populated table can only be done inside "
+ "its make method during a populate call."
+ " To override, set keyword argument allow_direct_insert=True."
+ )
+
+ if inspect.isclass(rows) and issubclass(rows, QueryExpression):
+ rows = rows() # instantiate if a class
+ if isinstance(rows, QueryExpression):
+ # insert from select - chunk_size not applicable
+ if chunk_size is not None:
+ raise DataJointError("chunk_size is not supported for QueryExpression inserts")
+ if not ignore_extra_fields:
+ try:
+ raise DataJointError(
+ "Attribute %s not found. To ignore extra attributes in insert, "
+ "set ignore_extra_fields=True." % next(name for name in rows.heading if name not in self.heading)
+ )
+ except StopIteration:
+ pass
+ fields = list(name for name in rows.heading if name in self.heading)
+ quoted_fields = ",".join(self.adapter.quote_identifier(f) for f in fields)
+
+ # Duplicate handling (backend-agnostic)
+ if skip_duplicates:
+ duplicate = self.adapter.skip_duplicates_clause(self.full_table_name, self.primary_key)
+ else:
+ duplicate = ""
+
+ command = "REPLACE" if replace else "INSERT"
+ query = f"{command} INTO {self.full_table_name} ({quoted_fields}) {rows.make_sql(fields)}{duplicate}"
+ self.connection.query(query)
+ return
+
+ # Chunked insert mode
+ if chunk_size is not None:
+ rows_iter = iter(rows)
+ while True:
+ chunk = list(itertools.islice(rows_iter, chunk_size))
+ if not chunk:
+ break
+ self._insert_rows(chunk, replace, skip_duplicates, ignore_extra_fields)
+ return
+
+ # Single batch insert (original behavior)
+ self._insert_rows(rows, replace, skip_duplicates, ignore_extra_fields)
+
+ def _insert_rows(self, rows, replace, skip_duplicates, ignore_extra_fields):
+ """
+ Internal helper to insert a batch of rows.
+
+ Parameters
+ ----------
+ rows : iterable
+ Iterable of rows to insert.
+ replace : bool
+ If True, use REPLACE instead of INSERT.
+ skip_duplicates : bool
+ If True, use ON DUPLICATE KEY UPDATE.
+ ignore_extra_fields : bool
+ If True, ignore unknown fields.
+ """
+ # collects the field list from first row (passed by reference)
+ field_list = []
+ rows = list(self.__make_row_to_insert(row, field_list, ignore_extra_fields) for row in rows)
+ if rows:
+ try:
+ # Handle empty field_list (all-defaults insert)
+ if field_list:
+ fields_clause = f"({','.join(self.adapter.quote_identifier(f) for f in field_list)})"
+ else:
+ fields_clause = "()"
+
+ # Build duplicate clause (backend-agnostic)
+ if skip_duplicates:
+ duplicate = self.adapter.skip_duplicates_clause(self.full_table_name, self.primary_key)
+ else:
+ duplicate = ""
+
+ command = "REPLACE" if replace else "INSERT"
+ placeholders = ",".join("(" + ",".join(row["placeholders"]) + ")" for row in rows)
+ query = f"{command} INTO {self.from_clause()}{fields_clause} VALUES {placeholders}{duplicate}"
+ self.connection.query(
+ query,
+ args=list(itertools.chain.from_iterable((v for v in r["values"] if v is not None) for r in rows)),
+ )
+ except UnknownAttributeError as err:
+ raise err.suggest("To ignore extra fields in insert, set ignore_extra_fields=True")
+ except DuplicateError as err:
+ raise err.suggest("To ignore duplicate entries in insert, set skip_duplicates=True")
+
+ def insert_dataframe(self, df, index_as_pk=None, **insert_kwargs):
+ """
+ Insert DataFrame with explicit index handling.
+
+ This method provides symmetry with to_pandas(): data fetched with to_pandas()
+ (which sets primary key as index) can be modified and re-inserted using
+ insert_dataframe() without manual index manipulation.
+
+ Parameters
+ ----------
+ df : pandas.DataFrame
+ DataFrame to insert.
+ index_as_pk : bool, optional
+ How to handle DataFrame index:
+
+ - None (default): Auto-detect. Use index as primary key if index names
+ match primary_key columns. Drop if unnamed RangeIndex.
+ - True: Treat index as primary key columns. Raises if index names don't
+ match table primary key.
+ - False: Ignore index entirely (drop it).
+ **insert_kwargs
+ Passed to insert() - replace, skip_duplicates, ignore_extra_fields,
+ allow_direct_insert, chunk_size.
+
+ Examples
+ --------
+ Round-trip with to_pandas():
+
+ >>> df = table.to_pandas() # PK becomes index
+ >>> df['value'] = df['value'] * 2 # Modify data
+ >>> table.insert_dataframe(df) # Auto-detects index as PK
+
+ Explicit control:
+
+ >>> table.insert_dataframe(df, index_as_pk=True) # Use index
+ >>> table.insert_dataframe(df, index_as_pk=False) # Ignore index
+ """
+ if not isinstance(df, pandas.DataFrame):
+ raise DataJointError("insert_dataframe requires a pandas DataFrame")
+
+ # Auto-detect if index should be used as PK
+ if index_as_pk is None:
+ index_as_pk = self._should_index_be_pk(df)
+
+ # Validate index if using as PK
+ if index_as_pk:
+ self._validate_index_columns(df)
+
+ # Prepare rows
+ if index_as_pk:
+ rows = df.reset_index(drop=False).to_records(index=False)
+ else:
+ rows = df.reset_index(drop=True).to_records(index=False)
+
+ self.insert(rows, **insert_kwargs)
+
+ def _should_index_be_pk(self, df) -> bool:
+ """
+ Auto-detect if DataFrame index should map to primary key.
+
+ Returns True if:
+ - Index has named columns that exactly match the table's primary key
+ Returns False if:
+ - Index is unnamed RangeIndex (synthetic index)
+ - Index names don't match primary key
+ """
+ # RangeIndex with no name -> False (synthetic index)
+ if df.index.names == [None]:
+ return False
+ # Check if index names match PK columns
+ index_names = set(n for n in df.index.names if n is not None)
+ return index_names == set(self.primary_key)
+
+ def _validate_index_columns(self, df):
+ """Validate that index columns match the table's primary key."""
+ index_names = [n for n in df.index.names if n is not None]
+ if set(index_names) != set(self.primary_key):
+ raise DataJointError(
+ f"DataFrame index columns {index_names} do not match "
+ f"table primary key {list(self.primary_key)}. "
+ f"Use index_as_pk=False to ignore index, or reset_index() first."
+ )
+
+ def delete_quick(self, get_count=False):
+ """
+ Deletes the table without cascading and without user prompt.
+ If this table has populated dependent tables, this will fail.
+ """
+ query = "DELETE FROM " + self.full_table_name + self.where_clause()
+ cursor = self.connection.query(query)
+ # Use cursor.rowcount (DB-API 2.0 standard, works for both MySQL and PostgreSQL)
+ count = cursor.rowcount if get_count else None
+ return count
+
+ def delete(
+ self,
+ transaction: bool = True,
+ prompt: bool | None = None,
+ part_integrity: str = "enforce",
+ ) -> int:
+ """
+ Deletes the contents of the table and its dependent tables, recursively.
+
+ Uses graph-driven cascade: builds a dependency diagram, propagates
+ restrictions to all descendants, then deletes in reverse topological
+ order (leaves first).
+
+ With ``safemode=True`` (the default), delete previews all affected
+ tables and row counts, executes within a transaction, and asks for
+ confirmation before committing. Declining rolls back all changes —
+ effectively a built-in dry run.
+
+ To preview cascade impact without executing, use ``Diagram``::
+
+ dj.Diagram.cascade(MyTable & restriction).counts()
+
+ Args:
+ transaction: If `True`, use of the entire delete becomes an atomic transaction.
+ This is the default and recommended behavior. Set to `False` if this delete is
+ nested within another transaction.
+ prompt: If `True`, show what will be deleted and ask for confirmation.
+ If `False`, delete without confirmation. Default is `dj.config['safemode']`.
+ part_integrity: Policy for master-part integrity. One of:
+ - ``"enforce"`` (default): Error if parts would be deleted without masters.
+ - ``"ignore"``: Allow deleting parts without masters (breaks integrity).
+ - ``"cascade"``: Also delete masters when parts are deleted (maintains integrity).
+
+ Returns:
+ Number of deleted rows (excluding those from dependent tables).
+
+ Raises:
+ DataJointError: When deleting within an existing transaction.
+ DataJointError: Deleting a part table before its master (when part_integrity="enforce").
+ ValueError: Invalid part_integrity value.
+ """
+ if part_integrity not in ("enforce", "ignore", "cascade"):
+ raise ValueError(f"part_integrity must be 'enforce', 'ignore', or 'cascade', " f"got {part_integrity!r}")
+ from .diagram import Diagram
+
+ diagram = Diagram.cascade(self, part_integrity=part_integrity)
+
+ conn = self.connection
+ prompt = conn._config["safemode"] if prompt is None else prompt
+
+ # Preview
+ if prompt:
+ for ft in diagram:
+ logger.info("{table} ({count} tuples)".format(table=ft.full_table_name, count=len(ft)))
+
+ # Start transaction
+ if transaction:
+ if not conn.in_transaction:
+ conn.start_transaction()
+ else:
+ if not prompt:
+ transaction = False
+ else:
+ raise DataJointError(
+ "Delete cannot use a transaction within an "
+ "ongoing transaction. Set transaction=False "
+ "or prompt=False."
+ )
+
+ # Execute deletes in reverse topological order (leaves first)
+ root_count = 0
+ deleted_tables = set()
+ try:
+ for ft in reversed(diagram):
+ count = ft.delete_quick(get_count=True)
+ if count > 0:
+ deleted_tables.add(ft.full_table_name)
+ logger.info("Deleting {count} rows from {table}".format(count=count, table=ft.full_table_name))
+ if ft.full_table_name == self.full_table_name:
+ root_count = count
+ except IntegrityError as error:
+ if transaction:
+ conn.cancel_transaction()
+ match = conn.adapter.parse_foreign_key_error(error.args[0])
+ if match:
+ raise DataJointError(
+ "Delete blocked by table {child} in an unloaded "
+ "schema. Activate all dependent schemas before "
+ "deleting.".format(child=match["child"])
+ ) from None
+ raise DataJointError("Delete blocked by FK in unloaded/inaccessible schema.") from None
+ except Exception:
+ if transaction:
+ conn.cancel_transaction()
+ raise
+
+ # Post-check part_integrity="enforce": roll back if a part table
+ # had rows deleted without its master also having rows deleted.
+ if part_integrity == "enforce" and deleted_tables:
+ for table_name in deleted_tables:
+ master = extract_master(table_name)
+ if master and master not in deleted_tables:
+ if transaction:
+ conn.cancel_transaction()
+ raise DataJointError(
+ f"Attempt to delete part table {table_name} before "
+ f"its master {master}. Delete from the master first, "
+ f"or use part_integrity='ignore' or 'cascade'."
+ )
+
+ # Confirm and commit
+ if root_count == 0:
+ if prompt:
+ logger.warning("Nothing to delete.")
+ if transaction:
+ conn.cancel_transaction()
+ elif not transaction:
+ logger.info("Delete completed")
+ else:
+ if not prompt or user_choice("Commit deletes?", default="no") == "yes":
+ if transaction:
+ conn.commit_transaction()
+ if prompt:
+ logger.info("Delete committed.")
+ else:
+ if transaction:
+ conn.cancel_transaction()
+ if prompt:
+ logger.warning("Delete cancelled")
+ root_count = 0
+ return root_count
+
+ def drop_quick(self):
+ """
+ Drops the table without cascading to dependent tables and without user prompt.
+ """
+ if self.is_declared:
+ # Clean up lineage entries for this table
+ from .lineage import delete_table_lineages
+
+ delete_table_lineages(self.connection, self.database, self.table_name)
+
+ # For PostgreSQL, get enum types used by this table before dropping
+ # (we need to query this before the table is dropped)
+ enum_types_to_drop = []
+ adapter = self.connection.adapter
+ if hasattr(adapter, "get_table_enum_types_sql"):
+ try:
+ enum_query = adapter.get_table_enum_types_sql(self.database, self.table_name)
+ result = self.connection.query(enum_query)
+ enum_types_to_drop = [row[0] for row in result.fetchall()]
+ except Exception:
+ pass # Ignore errors - enum cleanup is best-effort
+
+ query = "DROP TABLE %s" % self.full_table_name
+ self.connection.query(query)
+ logger.info("Dropped table %s" % self.full_table_name)
+
+ # For PostgreSQL, clean up enum types after dropping the table
+ if enum_types_to_drop and hasattr(adapter, "drop_enum_type_ddl"):
+ for enum_type in enum_types_to_drop:
+ try:
+ drop_ddl = adapter.drop_enum_type_ddl(enum_type)
+ self.connection.query(drop_ddl)
+ logger.debug("Dropped enum type %s" % enum_type)
+ except Exception:
+ pass # Ignore errors - type may be used by other tables
+ else:
+ logger.info("Nothing to drop: table %s is not declared" % self.full_table_name)
+
+ def drop(self, prompt: bool | None = None, part_integrity: str = "enforce"):
+ """
+ Drop the table and all tables that reference it, recursively.
+
+ Uses graph-driven traversal: builds a dependency diagram and drops
+ in reverse topological order (leaves first).
+
+ With ``safemode=True`` (the default), drop previews all affected
+ tables and row counts and asks for confirmation before proceeding.
+
+ Args:
+ prompt: If `True`, show what will be dropped and ask for confirmation.
+ If `False`, drop without confirmation. Default is `dj.config['safemode']`.
+ part_integrity: Policy for master-part integrity. One of:
+ - ``"enforce"`` (default): Error if parts would be dropped without masters.
+ - ``"ignore"``: Allow dropping parts without masters.
+ """
+ if self.restriction:
+ raise DataJointError(
+ "A table with an applied restriction cannot be dropped. " "Call drop() on the unrestricted Table."
+ )
+ import networkx as nx
+ from .diagram import Diagram
+
+ self.connection.dependencies.load_all_downstream()
+ diagram = Diagram(self)
+ # Expand to include all descendants (cross-schema)
+ descendants = set(nx.descendants(diagram, self.full_table_name)) | {self.full_table_name}
+ diagram.nodes_to_show = descendants
+ diagram._expanded_nodes = set(descendants)
+ conn = self.connection
+ prompt = conn._config["safemode"] if prompt is None else prompt
+
+ table_names = [ft.full_table_name for ft in diagram]
+
+ if part_integrity == "enforce":
+ for name in table_names:
+ master = extract_master(name)
+ if master and master not in table_names:
+ raise DataJointError(
+ "Attempt to drop part table {part} before its " "master {master}. Drop the master first.".format(
+ part=name, master=master
+ )
+ )
+
+ do_drop = True
+ if prompt:
+ for ft in diagram:
+ logger.info("{table} ({count} tuples)".format(table=ft.full_table_name, count=len(ft)))
+ do_drop = user_choice("Proceed?", default="no") == "yes"
+ if do_drop:
+ for ft in reversed(diagram):
+ ft.drop_quick()
+ logger.info("Tables dropped. Restart kernel.")
+
+ def describe(self, context=None, printout=False):
+ """
+ Return the definition string for the query using DataJoint DDL.
+
+ Parameters
+ ----------
+ context : dict, optional
+ The context for foreign key resolution. If None, uses the caller's
+ local and global namespace.
+ printout : bool, optional
+ If True, also log the definition string.
+
+ Returns
+ -------
+ str
+ The definition string for the table in DataJoint DDL format.
+ """
+ if context is None:
+ frame = inspect.currentframe().f_back
+ context = dict(frame.f_globals, **frame.f_locals)
+ del frame
+ if self.full_table_name not in self.connection.dependencies:
+ self.connection.dependencies.load()
+ parents = self.parents(foreign_key_info=True)
+ in_key = True
+ definition = "# " + self.heading.table_status["comment"] + "\n" if self.heading.table_status["comment"] else ""
+ attributes_thus_far = set()
+ attributes_declared = set()
+ indexes = self.heading.indexes.copy() if self.heading.indexes else {}
+ for attr in self.heading.attributes.values():
+ if in_key and not attr.in_key:
+ definition += "---\n"
+ in_key = False
+ attributes_thus_far.add(attr.name)
+ do_include = True
+ for parent_name, fk_props in parents:
+ if attr.name in fk_props["attr_map"]:
+ do_include = False
+ if attributes_thus_far.issuperset(fk_props["attr_map"]):
+ # foreign key properties - collect all options
+ fk_options = []
+
+ # Check if FK is nullable (any FK attribute has nullable=True)
+ is_nullable = any(self.heading.attributes[attr_name].nullable for attr_name in fk_props["attr_map"])
+ if is_nullable:
+ fk_options.append("nullable")
+
+ # Check for index properties (unique, etc.)
+ try:
+ index_props = indexes.pop(tuple(fk_props["attr_map"]))
+ except KeyError:
+ pass
+ else:
+ fk_options.extend(k for k, v in index_props.items() if v)
+
+ # Format options as " [opt1, opt2]" or empty string
+ options_str = " [{}]".format(", ".join(fk_options)) if fk_options else ""
+
+ if not fk_props["aliased"]:
+ # simple foreign key
+ definition += "->{options} {class_name}\n".format(
+ options=options_str,
+ class_name=lookup_class_name(parent_name, context) or parent_name,
+ )
+ else:
+ # projected foreign key
+ definition += "->{options} {class_name}.proj({proj_list})\n".format(
+ options=options_str,
+ class_name=lookup_class_name(parent_name, context) or parent_name,
+ proj_list=",".join(
+ '{}="{}"'.format(attr, ref) for attr, ref in fk_props["attr_map"].items() if ref != attr
+ ),
+ )
+ attributes_declared.update(fk_props["attr_map"])
+ if do_include:
+ attributes_declared.add(attr.name)
+ # Use original_type (core type alias) if available, otherwise use type
+ display_type = attr.original_type or attr.type
+ definition += "%-20s : %-28s %s\n" % (
+ (attr.name if attr.default is None else "%s=%s" % (attr.name, attr.default)),
+ "%s%s" % (display_type, " auto_increment" if attr.autoincrement else ""),
+ "# " + attr.comment if attr.comment else "",
+ )
+ # add remaining indexes
+ for k, v in indexes.items():
+ definition += "{unique}INDEX ({attrs})\n".format(unique="UNIQUE " if v["unique"] else "", attrs=", ".join(k))
+ if printout:
+ logger.info("\n" + definition)
+ return definition
+
+ # --- private helper functions ----
+ def __make_placeholder(self, name, value, ignore_extra_fields=False, row=None):
+ """
+ Return processed value or placeholder for an attribute.
+
+ For a given attribute `name` with `value`, return its processed value or
+ value placeholder as a string to be included in the query and the value,
+ if any, to be submitted for processing by mysql API.
+
+ In the simplified type system:
+ - Codecs handle all custom encoding via type chains
+ - UUID values are converted to bytes
+ - JSON values are serialized
+ - Blob values pass through as bytes
+ - Numeric values are stringified
+
+ Parameters
+ ----------
+ name : str
+ Name of attribute to be inserted.
+ value : any
+ Value of attribute to be inserted.
+ ignore_extra_fields : bool, optional
+ If True, return None for unknown fields.
+ row : dict, optional
+ The full row dict (used for context in codec encoding).
+
+ Returns
+ -------
+ tuple or None
+ A tuple of (name, placeholder, value) or None if the field should be
+ ignored.
+ """
+ if ignore_extra_fields and name not in self.heading:
+ return None
+ attr = self.heading[name]
+
+ # Apply adapter encoding with type chain support
+ if attr.codec:
+ from .codecs import resolve_dtype
+
+ # Skip validation and encoding for None values (nullable columns)
+ if value is None:
+ return name, "DEFAULT", None
+
+ attr.codec.validate(value)
+
+ # Resolve full type chain
+ _, type_chain, resolved_store = resolve_dtype(f"<{attr.codec.name}>", store_name=attr.store)
+
+ # Build context dict for schema-addressed codecs
+ # Include _schema, _table, _field, and primary key values
+ context = {
+ "_schema": self.database,
+ "_table": self.table_name,
+ "_field": name,
+ "_config": self.connection._config,
+ }
+ # Add primary key values from row if available
+ if row is not None:
+ for pk_name in self.primary_key:
+ if pk_name in row:
+ context[pk_name] = row[pk_name]
+
+ # Apply encoders from outermost to innermost
+ for attr_type in type_chain:
+ # Pass store_name to encoders that support it (check via introspection)
+ import inspect
+
+ sig = inspect.signature(attr_type.encode)
+ if "store_name" in sig.parameters:
+ value = attr_type.encode(value, key=context, store_name=resolved_store)
+ else:
+ value = attr_type.encode(value, key=context)
+
+ # Handle NULL values
+ if value is None or (attr.numeric and (value == "" or np.isnan(float(value)))):
+ placeholder, value = "DEFAULT", None
+ else:
+ placeholder = "%s"
+ # UUID - convert to bytes
+ if attr.uuid:
+ if not isinstance(value, uuid.UUID):
+ try:
+ value = uuid.UUID(value)
+ except (AttributeError, ValueError):
+ raise DataJointError(f"badly formed UUID value {value} for attribute `{name}`")
+ value = value.bytes
+ # JSON - serialize to string
+ elif attr.json:
+ value = json.dumps(value)
+ # Numeric - convert to string
+ elif attr.numeric:
+ value = str(int(value) if isinstance(value, (bool, np.bool_)) else value)
+ # Blob - pass through as bytes (use for automatic serialization)
+
+ return name, placeholder, value
+
+ def __make_row_to_insert(self, row, field_list, ignore_extra_fields):
+ """
+ Helper function for insert and update.
+
+ Parameters
+ ----------
+ row : tuple, dict, or numpy.void
+ A row to insert.
+ field_list : list
+ List to be populated with field names from the first row.
+ ignore_extra_fields : bool
+ If True, ignore fields not in the heading.
+
+ Returns
+ -------
+ dict
+ A dict with fields 'names', 'placeholders', 'values'.
+ """
+
+ def check_fields(fields):
+ """
+ Validate that all items in `fields` are valid attributes in the heading.
+
+ Parameters
+ ----------
+ fields : list
+ Field names of a tuple.
+ """
+ if not field_list:
+ if not ignore_extra_fields:
+ for field in fields:
+ if field not in self.heading:
+ raise KeyError("`{0:s}` is not in the table heading".format(field))
+ elif set(field_list) != set(fields).intersection(self.heading.names):
+ raise DataJointError("Attempt to insert rows with different fields.")
+
+ # Convert row to dict for object attribute processing
+ row_dict = None
+ if isinstance(row, np.void): # np.array
+ check_fields(row.dtype.fields)
+ row_dict = {name: row[name] for name in row.dtype.fields}
+ attributes = [
+ self.__make_placeholder(name, row[name], ignore_extra_fields, row=row_dict)
+ for name in self.heading
+ if name in row.dtype.fields
+ ]
+ elif isinstance(row, collections.abc.Mapping): # dict-based
+ check_fields(row)
+ row_dict = dict(row)
+ attributes = [
+ self.__make_placeholder(name, row[name], ignore_extra_fields, row=row_dict)
+ for name in self.heading
+ if name in row
+ ]
+ else: # positional
+ warnings.warn(
+ "Positional inserts (tuples/lists) are deprecated and will be removed in a future version. "
+ "Use dict with explicit field names instead: table.insert1({'field': value, ...})",
+ DeprecationWarning,
+ stacklevel=4, # Point to user's insert()/insert1() call
+ )
+ try:
+ if len(row) != len(self.heading):
+ raise DataJointError(
+ "Invalid insert argument. Incorrect number of attributes: {given} given; {expected} expected".format(
+ given=len(row), expected=len(self.heading)
+ )
+ )
+ except TypeError:
+ raise DataJointError("Datatype %s cannot be inserted" % type(row))
+ else:
+ row_dict = dict(zip(self.heading.names, row))
+ attributes = [
+ self.__make_placeholder(name, value, ignore_extra_fields, row=row_dict)
+ for name, value in zip(self.heading, row)
+ ]
+ if ignore_extra_fields:
+ attributes = [a for a in attributes if a is not None]
+
+ if not attributes:
+ # Check if empty insert is allowed (all attributes have defaults)
+ required_attrs = [
+ attr.name
+ for attr in self.heading.attributes.values()
+ if not (attr.autoincrement or attr.nullable or attr.default is not None)
+ ]
+ if required_attrs:
+ raise DataJointError(f"Cannot insert empty row. The following attributes require values: {required_attrs}")
+ # All attributes have defaults - allow empty insert
+ row_to_insert = {"names": (), "placeholders": (), "values": ()}
+ else:
+ row_to_insert = dict(zip(("names", "placeholders", "values"), zip(*attributes)))
+ if not field_list:
+ # first row sets the composition of the field list
+ field_list.extend(row_to_insert["names"])
+ else:
+ # reorder attributes in row_to_insert to match field_list
+ order = list(row_to_insert["names"].index(field) for field in field_list)
+ row_to_insert["names"] = list(row_to_insert["names"][i] for i in order)
+ row_to_insert["placeholders"] = list(row_to_insert["placeholders"][i] for i in order)
+ row_to_insert["values"] = list(row_to_insert["values"][i] for i in order)
+ return row_to_insert
+
+
+def lookup_class_name(name, context, depth=3):
+ """
+ Find a table's class in the context given its full table name.
+
+ Given a table name in the form `schema_name`.`table_name`, find its class in
+ the context.
+
+ Parameters
+ ----------
+ name : str
+ Full table name in format `schema_name`.`table_name`.
+ context : dict
+ Dictionary representing the namespace.
+ depth : int, optional
+ Search depth into imported modules, helps avoid infinite recursion.
+
+ Returns
+ -------
+ str or None
+ Class name found in the context or None if not found.
+ """
+ # breadth-first search
+ nodes = [dict(context=context, context_name="", depth=depth)]
+ while nodes:
+ node = nodes.pop(0)
+ for member_name, member in node["context"].items():
+ # skip IPython's implicit variables
+ if not member_name.startswith("_"):
+ if inspect.isclass(member) and issubclass(member, Table):
+ if member.full_table_name == name: # found it!
+ return ".".join([node["context_name"], member_name]).lstrip(".")
+ try: # look for part tables
+ parts = member.__dict__
+ except AttributeError:
+ pass # not a UserTable -- cannot have part tables.
+ else:
+ for part in (getattr(member, p) for p in parts if p[0].isupper() and hasattr(member, p)):
+ if inspect.isclass(part) and issubclass(part, Table) and part.full_table_name == name:
+ return ".".join([node["context_name"], member_name, part.__name__]).lstrip(".")
+ elif node["depth"] > 0 and inspect.ismodule(member) and member.__name__ != "datajoint":
+ try:
+ nodes.append(
+ dict(
+ context=dict(inspect.getmembers(member)),
+ context_name=node["context_name"] + "." + member_name,
+ depth=node["depth"] - 1,
+ )
+ )
+ except (ImportError, TypeError):
+ pass # could not inspect module members, skip
+ return None
+
+
+class FreeTable(Table):
+ """
+ A base table without a dedicated class.
+
+ Each instance is associated with a table specified by full_table_name.
+
+ Parameters
+ ----------
+ conn : datajoint.Connection
+ A DataJoint connection object.
+ full_table_name : str
+ Full table name in format `database`.`table_name`.
+ """
+
+ def __init__(self, conn, full_table_name):
+ self.database, self._table_name = conn.adapter.split_full_table_name(full_table_name)
+ self._connection = conn
+ self._support = [full_table_name]
+ self._heading = Heading(
+ table_info=dict(
+ conn=conn,
+ database=self.database,
+ table_name=self.table_name,
+ context=None,
+ )
+ )
+
+ def __repr__(self):
+ return f"FreeTable({self.full_table_name})\n" + super().__repr__()
diff --git a/src/datajoint/types.py b/src/datajoint/types.py
new file mode 100644
index 000000000..72cefee3c
--- /dev/null
+++ b/src/datajoint/types.py
@@ -0,0 +1,60 @@
+"""
+Type definitions for DataJoint.
+
+This module defines type aliases used throughout the DataJoint codebase
+to improve code clarity and enable better static type checking.
+
+Python 3.10+ is required.
+"""
+
+from __future__ import annotations
+
+from typing import Any, TypeAlias
+
+# Primary key types
+PrimaryKey: TypeAlias = dict[str, Any]
+"""A dictionary mapping attribute names to values that uniquely identify an entity."""
+
+PrimaryKeyList: TypeAlias = list[dict[str, Any]]
+"""A list of primary key dictionaries."""
+
+# Row/record types
+Row: TypeAlias = dict[str, Any]
+"""A single row/record as a dictionary mapping attribute names to values."""
+
+RowList: TypeAlias = list[dict[str, Any]]
+"""A list of rows/records."""
+
+# Attribute types
+AttributeName: TypeAlias = str
+"""Name of a table attribute/column."""
+
+AttributeNames: TypeAlias = list[str]
+"""List of attribute/column names."""
+
+# Table and schema names
+TableName: TypeAlias = str
+"""Simple table name (e.g., 'session')."""
+
+FullTableName: TypeAlias = str
+"""Fully qualified table name (e.g., '`schema`.`table`')."""
+
+SchemaName: TypeAlias = str
+"""Database schema name."""
+
+# Foreign key mapping
+ForeignKeyMap: TypeAlias = dict[str, tuple[str, str]]
+"""Mapping of child_attr -> (parent_table, parent_attr) for foreign keys."""
+
+# Restriction types
+Restriction: TypeAlias = str | dict[str, Any] | bool | "QueryExpression" | list | None
+"""Valid restriction types for query operations."""
+
+# Fetch result types
+FetchResult: TypeAlias = list[dict[str, Any]]
+"""Result of a fetch operation as list of dictionaries."""
+
+
+# For avoiding circular imports
+if False: # TYPE_CHECKING equivalent that's always False
+ from .expression import QueryExpression
diff --git a/src/datajoint/user_tables.py b/src/datajoint/user_tables.py
new file mode 100644
index 000000000..514f4eb60
--- /dev/null
+++ b/src/datajoint/user_tables.py
@@ -0,0 +1,290 @@
+"""
+Hosts the table tiers, user tables should be derived from.
+"""
+
+import re
+
+from .autopopulate import AutoPopulate
+from .errors import DataJointError
+from .table import Table
+from .utils import from_camel_case
+
+_base_regexp = r"[a-z][a-z0-9]*(_[a-z][a-z0-9]*)*"
+
+# attributes that trigger instantiation of user classes
+
+
+supported_class_attrs = {
+ "key_source",
+ "describe",
+ "alter",
+ "heading",
+ "populate",
+ "progress",
+ "primary_key",
+ "proj",
+ "aggr",
+ "join",
+ "extend",
+ "to_dicts",
+ "to_pandas",
+ "to_polars",
+ "to_arrow",
+ "to_arrays",
+ "keys",
+ "fetch",
+ "fetch1",
+ "head",
+ "tail",
+ "descendants",
+ "ancestors",
+ "parts",
+ "parents",
+ "children",
+ "insert",
+ "insert1",
+ "insert_dataframe",
+ "update1",
+ "validate",
+ "drop",
+ "drop_quick",
+ "delete",
+ "delete_quick",
+ "staged_insert1",
+}
+
+
+class TableMeta(type):
+ """
+ TableMeta subclasses allow applying some instance methods and properties directly
+ at class level. For example, this allows Table.to_dicts() instead of Table().to_dicts().
+ """
+
+ def __getattribute__(cls, name):
+ # trigger instantiation for supported class attrs
+ return cls().__getattribute__(name) if name in supported_class_attrs else super().__getattribute__(name)
+
+ def __and__(cls, arg):
+ return cls() & arg
+
+ def __xor__(cls, arg):
+ return cls() ^ arg
+
+ def __sub__(cls, arg):
+ return cls() - arg
+
+ def __neg__(cls):
+ return -cls()
+
+ def __mul__(cls, arg):
+ return cls() * arg
+
+ def __matmul__(cls, arg):
+ return cls() @ arg
+
+ def __add__(cls, arg):
+ return cls() + arg
+
+ def __iter__(cls):
+ return iter(cls())
+
+ # Class properties - defined on metaclass to work at class level
+ @property
+ def connection(cls):
+ """The database connection for this table."""
+ return cls._connection
+
+ @property
+ def table_name(cls):
+ """The table name formatted for MySQL."""
+ if cls._prefix is None:
+ raise AttributeError("Class prefix is not defined!")
+ return cls._prefix + from_camel_case(cls.__name__)
+
+ @property
+ def full_table_name(cls):
+ """The fully qualified table name (quoted per backend)."""
+ if cls.database is None:
+ return None
+ return cls._connection.adapter.make_full_table_name(cls.database, cls.table_name)
+
+
+class UserTable(Table, metaclass=TableMeta):
+ """
+ A subclass of UserTable is a dedicated class interfacing a base table.
+ UserTable is initialized by the decorator generated by schema().
+ """
+
+ # set by @schema
+ _connection = None
+ _heading = None
+ _support = None
+
+ # set by subclass
+ tier_regexp = None
+ _prefix = None
+
+ @property
+ def definition(self):
+ """
+ :return: a string containing the table definition using the DataJoint DDL.
+ """
+ raise NotImplementedError('Subclasses of Table must implement the property "definition"')
+
+
+class Manual(UserTable):
+ """
+ Inherit from this class if the table's values are entered manually.
+ """
+
+ _prefix = r""
+ tier_regexp = r"(?P" + _prefix + _base_regexp + ")"
+
+
+class Lookup(UserTable):
+ """
+ Inherit from this class if the table's values are for lookup. This is
+ currently equivalent to defining the table as Manual and serves semantic
+ purposes only.
+ """
+
+ _prefix = "#"
+ tier_regexp = r"(?P" + _prefix + _base_regexp.replace("TIER", "lookup") + ")"
+
+
+class Imported(UserTable, AutoPopulate):
+ """
+ Inherit from this class if the table's values are imported from external data sources.
+ The inherited class must at least provide the function `_make_tuples`.
+ """
+
+ _prefix = "_"
+ tier_regexp = r"(?P" + _prefix + _base_regexp + ")"
+
+
+class Computed(UserTable, AutoPopulate):
+ """
+ Inherit from this class if the table's values are computed from other tables in the schema.
+ The inherited class must at least provide the function `_make_tuples`.
+ """
+
+ _prefix = "__"
+ tier_regexp = r"(?P" + _prefix + _base_regexp + ")"
+
+
+class PartMeta(TableMeta):
+ """Metaclass for Part tables with overridden class properties."""
+
+ @property
+ def table_name(cls):
+ """The table name for a Part is derived from its master table."""
+ return None if cls.master is None else cls.master.table_name + "__" + from_camel_case(cls.__name__)
+
+ @property
+ def full_table_name(cls):
+ """The fully qualified table name (quoted per backend)."""
+ if cls.database is None or cls.table_name is None:
+ return None
+ return cls._connection.adapter.make_full_table_name(cls.database, cls.table_name)
+
+ @property
+ def master(cls):
+ """The master table for this Part table."""
+ return cls._master
+
+
+class Part(UserTable, metaclass=PartMeta):
+ """
+ Inherit from this class if the table's values are details of an entry in another table
+ and if this table is populated by the other table. For example, the entries inheriting from
+ dj.Part could be single entries of a matrix, while the parent table refers to the entire matrix.
+ Part tables are implemented as classes inside classes.
+ """
+
+ _connection = None
+ _master = None
+
+ tier_regexp = (
+ r"(?P"
+ + "|".join([c.tier_regexp for c in (Manual, Lookup, Imported, Computed)])
+ + r"){1,1}"
+ + "__"
+ + r"(?P"
+ + _base_regexp
+ + ")"
+ )
+
+ def delete(self, part_integrity: str = "enforce", **kwargs):
+ """
+ Delete from a Part table.
+
+ Args:
+ part_integrity: Policy for master-part integrity. One of:
+ - ``"enforce"`` (default): Error - delete from master instead.
+ - ``"ignore"``: Allow direct deletion (breaks master-part integrity).
+ - ``"cascade"``: Delete parts AND cascade up to delete master.
+ **kwargs: Additional arguments passed to Table.delete()
+ (transaction, prompt)
+
+ Raises:
+ DataJointError: If part_integrity="enforce" (direct Part deletes prohibited)
+ """
+ if part_integrity == "enforce":
+ raise DataJointError(
+ "Cannot delete from a Part directly. Delete from master instead, "
+ "or use part_integrity='ignore' to break integrity, "
+ "or part_integrity='cascade' to also delete master."
+ )
+ return super().delete(part_integrity=part_integrity, **kwargs)
+
+ def drop(self, part_integrity: str = "enforce"):
+ """
+ Drop a Part table.
+
+ Args:
+ part_integrity: Policy for master-part integrity. One of:
+ - ``"enforce"`` (default): Error - drop master instead.
+ - ``"ignore"``: Allow direct drop (breaks master-part structure).
+ Note: ``"cascade"`` is not supported for drop (too destructive).
+
+ Raises:
+ DataJointError: If part_integrity="enforce" (direct Part drops prohibited)
+ """
+ if part_integrity == "ignore":
+ return super().drop(part_integrity="ignore")
+ elif part_integrity == "enforce":
+ raise DataJointError("Cannot drop a Part directly. Drop master instead, or use part_integrity='ignore' to force.")
+ else:
+ raise ValueError(f"part_integrity for drop must be 'enforce' or 'ignore', got {part_integrity!r}")
+
+ def alter(self, prompt=True, context=None):
+ # without context, use declaration context which maps master keyword to master table
+ super().alter(prompt=prompt, context=context or self.declaration_context)
+
+
+user_table_classes = (Manual, Lookup, Computed, Imported, Part)
+
+
+class _AliasNode:
+ """
+ special class to indicate aliased foreign keys
+ """
+
+ pass
+
+
+def _get_tier(table_name):
+ """given the table name, return the user table class."""
+ # Handle both MySQL backticks and PostgreSQL double quotes
+ if table_name.startswith("`"):
+ # MySQL format: `schema`.`table_name`
+ extracted_name = table_name.split("`")[-2]
+ elif table_name.startswith('"'):
+ # PostgreSQL format: "schema"."table_name"
+ extracted_name = table_name.split('"')[-2]
+ else:
+ return _AliasNode
+ try:
+ return next(tier for tier in user_table_classes if re.fullmatch(tier.tier_regexp, extracted_name))
+ except StopIteration:
+ return None
diff --git a/src/datajoint/utils.py b/src/datajoint/utils.py
new file mode 100644
index 000000000..e36267936
--- /dev/null
+++ b/src/datajoint/utils.py
@@ -0,0 +1,174 @@
+"""General-purpose utilities"""
+
+import re
+import shutil
+import warnings
+from pathlib import Path
+
+from .errors import DataJointError
+
+
+def user_choice(prompt, choices=("yes", "no"), default=None):
+ """
+ Prompt the user for confirmation.
+
+ The default value, if any, is capitalized.
+
+ Parameters
+ ----------
+ prompt : str
+ Information to display to the user.
+ choices : tuple, optional
+ An iterable of possible choices. Default ("yes", "no").
+ default : str, optional
+ Default choice. Default None.
+
+ Returns
+ -------
+ str
+ The user's choice.
+ """
+ assert default is None or default in choices
+ choice_list = ", ".join((choice.title() if choice == default else choice for choice in choices))
+ response = None
+ while response not in choices:
+ response = input(prompt + " [" + choice_list + "]: ")
+ response = response.lower() if response else default
+ return response
+
+
+def is_camel_case(s):
+ """
+ Check if a string is in CamelCase notation.
+
+ Parameters
+ ----------
+ s : str
+ String to check.
+
+ Returns
+ -------
+ bool
+ True if the string is in CamelCase notation, False otherwise.
+
+ Examples
+ --------
+ >>> is_camel_case("TableName")
+ True
+ >>> is_camel_case("table_name")
+ False
+ """
+ return bool(re.match(r"^[A-Z][A-Za-z0-9]*$", s))
+
+
+def to_camel_case(s):
+ """
+ Convert names with underscore (_) separation into camel case names.
+
+ Parameters
+ ----------
+ s : str
+ String in under_score notation.
+
+ Returns
+ -------
+ str
+ String in CamelCase notation.
+
+ Examples
+ --------
+ >>> to_camel_case("table_name")
+ 'TableName'
+ """
+
+ def to_upper(match):
+ return match.group(0)[-1].upper()
+
+ return re.sub(r"(^|[_\W])+[a-zA-Z]", to_upper, s)
+
+
+def from_camel_case(s):
+ """
+ Convert names in camel case into underscore (_) separated names.
+
+ Parameters
+ ----------
+ s : str
+ String in CamelCase notation.
+
+ Returns
+ -------
+ str
+ String in under_score notation.
+
+ Raises
+ ------
+ DataJointError
+ If the string is not in valid CamelCase notation.
+
+ Examples
+ --------
+ >>> from_camel_case("TableName")
+ 'table_name'
+ """
+
+ def convert(match):
+ return ("_" if match.groups()[0] else "") + match.group(0).lower()
+
+ # Handle underscores: warn and remove them
+ if "_" in s:
+ warnings.warn(
+ f"Table class name `{s}` contains underscores. " "CamelCase names without underscores are recommended.",
+ UserWarning,
+ stacklevel=3,
+ )
+ s = s.replace("_", "")
+ if not is_camel_case(s):
+ raise DataJointError("ClassName must be alphanumeric in CamelCase, begin with a capital letter")
+ return re.sub(r"(\B[A-Z])|(\b[A-Z])", convert, s)
+
+
+def safe_write(filepath, blob):
+ """
+ Write data to a file using a two-step process.
+
+ Writes to a temporary file first, then renames to the final path.
+ This ensures atomic writes and prevents partial file corruption.
+
+ Parameters
+ ----------
+ filepath : str or Path
+ Full path to the destination file.
+ blob : bytes
+ Binary data to write.
+ """
+ filepath = Path(filepath)
+ if not filepath.is_file():
+ filepath.parent.mkdir(parents=True, exist_ok=True)
+ temp_file = filepath.with_suffix(filepath.suffix + ".saving")
+ temp_file.write_bytes(blob)
+ temp_file.rename(filepath)
+
+
+def safe_copy(src, dest, overwrite=False):
+ """
+ Copy the contents of src file into dest file as a two-step process.
+
+ Copies to a temporary file first, then renames to the final path.
+ Skips if dest exists already (unless overwrite is True).
+
+ Parameters
+ ----------
+ src : str or Path
+ Source file path.
+ dest : str or Path
+ Destination file path.
+ overwrite : bool, optional
+ If True, overwrite existing destination file. Default False.
+ """
+ src, dest = Path(src), Path(dest)
+ if not (dest.exists() and src.samefile(dest)) and (overwrite or not dest.is_file()):
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ temp_file = dest.with_suffix(dest.suffix + ".copying")
+ shutil.copyfile(str(src), str(temp_file))
+ temp_file.rename(dest)
diff --git a/src/datajoint/version.py b/src/datajoint/version.py
new file mode 100644
index 000000000..c90b5e57f
--- /dev/null
+++ b/src/datajoint/version.py
@@ -0,0 +1,4 @@
+# version bump auto managed by Github Actions:
+# label_prs.yaml(prep), release.yaml(bump), post_release.yaml(edit)
+# manually set this version will be eventually overwritten by the above actions
+__version__ = "2.2.4"
diff --git a/test_requirements.txt b/test_requirements.txt
deleted file mode 100644
index 0ed24a620..000000000
--- a/test_requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-nose
-nose-cov
-coveralls
-faker
diff --git a/tests/__init__.py b/tests/__init__.py
index 6b802e332..e69de29bb 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,202 +0,0 @@
-"""
-Package for testing datajoint. Setup fixture will be run
-to ensure that proper database connection and access privilege
-exists. The content of the test database will be destroyed
-after the test.
-"""
-
-import logging
-from os import environ, remove
-import datajoint as dj
-from distutils.version import LooseVersion
-import os
-from pathlib import Path
-import minio
-import urllib3
-import certifi
-import shutil
-from datajoint.utils import parse_sql
-
-__author__ = 'Edgar Walker, Fabian Sinz, Dimitri Yatsenko, Raphael Guzman'
-
-# turn on verbose logging
-logging.basicConfig(level=logging.DEBUG)
-
-__all__ = ['__author__', 'PREFIX', 'CONN_INFO']
-
-# Connection for testing
-CONN_INFO = dict(
- host=environ.get('DJ_TEST_HOST', 'fakeservices.datajoint.io'),
- user=environ.get('DJ_TEST_USER', 'datajoint'),
- password=environ.get('DJ_TEST_PASSWORD', 'datajoint'))
-
-CONN_INFO_ROOT = dict(
- host=environ.get('DJ_HOST', 'fakeservices.datajoint.io'),
- user=environ.get('DJ_USER', 'root'),
- password=environ.get('DJ_PASS', 'simple'))
-
-S3_CONN_INFO = dict(
- endpoint=environ.get('S3_ENDPOINT', 'fakeservices.datajoint.io'),
- access_key=environ.get('S3_ACCESS_KEY', 'datajoint'),
- secret_key=environ.get('S3_SECRET_KEY', 'datajoint'),
- bucket=environ.get('S3_BUCKET', 'datajoint.test'))
-
-S3_MIGRATE_BUCKET = [path.name for path in Path(
- Path(__file__).resolve().parent,
- 'external-legacy-data', 's3').iterdir()][0]
-
-# Prefix for all databases used during testing
-PREFIX = environ.get('DJ_TEST_DB_PREFIX', 'djtest')
-conn_root = dj.conn(**CONN_INFO_ROOT)
-
-# Initialize httpClient with relevant timeout.
-httpClient = urllib3.PoolManager(
- timeout=30,
- cert_reqs='CERT_REQUIRED',
- ca_certs=certifi.where(),
- retries=urllib3.Retry(
- total=3,
- backoff_factor=0.2,
- status_forcelist=[500, 502, 503, 504]
- )
- )
-
-# Initialize minioClient with an endpoint and access/secret keys.
-minioClient = minio.Minio(
- S3_CONN_INFO['endpoint'],
- access_key=S3_CONN_INFO['access_key'],
- secret_key=S3_CONN_INFO['secret_key'],
- secure=True,
- http_client=httpClient)
-
-
-def setup_package():
- """
- Package-level unit test setup
- Turns off safemode
- """
- dj.config['safemode'] = False
-
- # Create MySQL users
- if LooseVersion(conn_root.query(
- "select @@version;").fetchone()[0]) >= LooseVersion('8.0.0'):
- # create user if necessary on mysql8
- conn_root.query("""
- CREATE USER IF NOT EXISTS 'datajoint'@'%%'
- IDENTIFIED BY 'datajoint';
- """)
- conn_root.query("""
- CREATE USER IF NOT EXISTS 'djview'@'%%'
- IDENTIFIED BY 'djview';
- """)
- conn_root.query("""
- CREATE USER IF NOT EXISTS 'djssl'@'%%'
- IDENTIFIED BY 'djssl'
- REQUIRE SSL;
- """)
- conn_root.query(
- "GRANT ALL PRIVILEGES ON `djtest%%`.* TO 'datajoint'@'%%';")
- conn_root.query(
- "GRANT SELECT ON `djtest%%`.* TO 'djview'@'%%';")
- conn_root.query(
- "GRANT SELECT ON `djtest%%`.* TO 'djssl'@'%%';")
- else:
- # grant permissions. For MySQL 5.7 this also automatically creates user
- # if not exists
- conn_root.query("""
- GRANT ALL PRIVILEGES ON `djtest%%`.* TO 'datajoint'@'%%'
- IDENTIFIED BY 'datajoint';
- """)
- conn_root.query(
- "GRANT SELECT ON `djtest%%`.* TO 'djview'@'%%' IDENTIFIED BY 'djview';"
- )
- conn_root.query("""
- GRANT SELECT ON `djtest%%`.* TO 'djssl'@'%%'
- IDENTIFIED BY 'djssl'
- REQUIRE SSL;
- """)
-
- # Add old MySQL
- source = Path(
- Path(__file__).resolve().parent,
- 'external-legacy-data')
- db_name = "djtest_blob_migrate"
- db_file = "v0_11.sql"
- conn_root.query("""
- CREATE DATABASE IF NOT EXISTS {};
- """.format(db_name))
-
- statements = parse_sql(Path(source,db_file))
- for s in statements:
- conn_root.query(s)
-
- # Add old S3
- source = Path(
- Path(__file__).resolve().parent,
- 'external-legacy-data', 's3')
- region = "us-east-1"
- try:
- minioClient.make_bucket(S3_MIGRATE_BUCKET, location=region)
- except minio.error.S3Error as e:
- if e.code != 'BucketAlreadyOwnedByYou':
- raise e
-
- pathlist = Path(source).glob('**/*')
- for path in pathlist:
- if os.path.isfile(str(path)) and ".sql" not in str(path):
- minioClient.fput_object(
- S3_MIGRATE_BUCKET, str(Path(
- os.path.relpath(str(path), str(Path(source, S3_MIGRATE_BUCKET))))
- .as_posix()), str(path))
- # Add S3
- try:
- minioClient.make_bucket(S3_CONN_INFO['bucket'], location=region)
- except minio.error.S3Error as e:
- if e.code != 'BucketAlreadyOwnedByYou':
- raise e
-
- # Add old File Content
- try:
- shutil.copytree(
- str(Path(Path(__file__).resolve().parent,
- 'external-legacy-data','file','temp')),
- str(Path(os.path.expanduser('~'),'temp')))
- except FileExistsError:
- pass
-
-
-def teardown_package():
- """
- Package-level unit test teardown.
- Removes all databases with name starting with PREFIX.
- To deal with possible foreign key constraints, it will unset
- and then later reset FOREIGN_KEY_CHECKS flag
- """
- conn_root.query('SET FOREIGN_KEY_CHECKS=0')
- cur = conn_root.query('SHOW DATABASES LIKE "{}\_%%"'.format(PREFIX))
- for db in cur.fetchall():
- conn_root.query('DROP DATABASE `{}`'.format(db[0]))
- conn_root.query('SET FOREIGN_KEY_CHECKS=1')
- if os.path.exists("dj_local_conf.json"):
- remove("dj_local_conf.json")
-
- # Remove created users
- conn_root.query('DROP USER `datajoint`')
- conn_root.query('DROP USER `djview`')
- conn_root.query('DROP USER `djssl`')
-
- # Remove old S3
- objs = list(minioClient.list_objects(
- S3_MIGRATE_BUCKET, recursive=True))
- objs = [minioClient.remove_object(S3_MIGRATE_BUCKET,
- o.object_name.encode('utf-8')) for o in objs]
- minioClient.remove_bucket(S3_MIGRATE_BUCKET)
-
- # Remove S3
- objs = list(minioClient.list_objects(S3_CONN_INFO['bucket'], recursive=True))
- objs = [minioClient.remove_object(S3_CONN_INFO['bucket'],
- o.object_name.encode('utf-8')) for o in objs]
- minioClient.remove_bucket(S3_CONN_INFO['bucket'])
-
- # Remove old File Content
- shutil.rmtree(str(Path(os.path.expanduser('~'),'temp')))
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 000000000..8efaab745
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,1018 @@
+"""
+Pytest configuration for DataJoint tests.
+
+Tests are organized by their dependencies:
+- Unit tests: No external dependencies, run with `pytest -m "not requires_mysql"`
+- Integration tests: Require MySQL/MinIO, marked with @pytest.mark.requires_mysql
+
+Containers are automatically started via testcontainers when needed.
+Just run: pytest tests/
+
+To use external containers instead (e.g., docker-compose), set:
+ DJ_USE_EXTERNAL_CONTAINERS=1
+ DJ_HOST=localhost DJ_PORT=3306 S3_ENDPOINT=localhost:9000 pytest
+
+To run only unit tests (no Docker required):
+ pytest -m "not requires_mysql"
+"""
+
+import logging
+import os
+from os import remove
+from typing import Dict, List
+
+import certifi
+import pytest
+import urllib3
+
+import datajoint as dj
+from datajoint.errors import DataJointError
+
+from . import schema, schema_advanced, schema_external, schema_object, schema_simple
+from . import schema_uuid as schema_uuid_module
+from . import schema_type_aliases as schema_type_aliases_module
+
+logger = logging.getLogger(__name__)
+
+
+# =============================================================================
+# Pytest Hooks
+# =============================================================================
+
+
+def pytest_collection_modifyitems(config, items):
+ """Auto-mark integration tests based on their fixtures."""
+ # Tests that use these fixtures require MySQL
+ mysql_fixtures = {
+ "connection_root",
+ "connection_root_bare",
+ "connection_test",
+ "schema_any",
+ "schema_any_fresh",
+ "schema_simp",
+ "schema_adv",
+ "schema_ext",
+ "schema_uuid",
+ "schema_type_aliases",
+ "schema_obj",
+ "db_creds_root",
+ "db_creds_test",
+ }
+ # Tests that use these fixtures require MinIO
+ minio_fixtures = {
+ "minio_client",
+ "s3fs_client",
+ "s3_creds",
+ "stores_config",
+ "mock_stores",
+ }
+ # Tests that use these fixtures are backend-parameterized
+ backend_fixtures = {
+ "backend",
+ "db_creds_by_backend",
+ "connection_by_backend",
+ }
+
+ for item in items:
+ # Get all fixtures this test uses (directly or indirectly)
+ try:
+ fixturenames = set(item.fixturenames)
+ except AttributeError:
+ continue
+
+ # Auto-add marks based on fixture usage
+ if fixturenames & mysql_fixtures:
+ item.add_marker(pytest.mark.requires_mysql)
+ if fixturenames & minio_fixtures:
+ item.add_marker(pytest.mark.requires_minio)
+
+ # Auto-mark backend-parameterized tests
+ if fixturenames & backend_fixtures:
+ # Test will run for both backends - add all backend markers
+ item.add_marker(pytest.mark.mysql)
+ item.add_marker(pytest.mark.postgresql)
+ item.add_marker(pytest.mark.backend_agnostic)
+
+
+# =============================================================================
+# Container Fixtures - Auto-start MySQL and MinIO via testcontainers
+# =============================================================================
+
+# Check if we should use external containers (for CI or manual docker-compose)
+USE_EXTERNAL_CONTAINERS = os.environ.get("DJ_USE_EXTERNAL_CONTAINERS", "").lower() in ("1", "true", "yes")
+
+
+@pytest.fixture(scope="session")
+def mysql_container():
+ """Start MySQL container for the test session (or use external)."""
+ if USE_EXTERNAL_CONTAINERS:
+ # Use external container - return None, credentials come from env
+ logger.info("Using external MySQL container")
+ yield None
+ return
+
+ from testcontainers.mysql import MySqlContainer
+
+ container = MySqlContainer(
+ image="datajoint/mysql:8.0", # Use datajoint image which has SSL configured
+ username="root",
+ password="password",
+ dbname="test",
+ )
+ container.start()
+
+ host = container.get_container_host_ip()
+ port = container.get_exposed_port(3306)
+ logger.info(f"MySQL container started at {host}:{port}")
+
+ yield container
+
+ container.stop()
+ logger.info("MySQL container stopped")
+
+
+@pytest.fixture(scope="session")
+def postgres_container():
+ """Start PostgreSQL container for the test session (or use external)."""
+ if USE_EXTERNAL_CONTAINERS:
+ # Use external container - return None, credentials come from env
+ logger.info("Using external PostgreSQL container")
+ yield None
+ return
+
+ from testcontainers.postgres import PostgresContainer
+
+ container = PostgresContainer(
+ image="postgres:15",
+ username="postgres",
+ password="password",
+ dbname="test",
+ )
+ container.start()
+
+ host = container.get_container_host_ip()
+ port = container.get_exposed_port(5432)
+ logger.info(f"PostgreSQL container started at {host}:{port}")
+
+ yield container
+
+ container.stop()
+ logger.info("PostgreSQL container stopped")
+
+
+@pytest.fixture(scope="session")
+def minio_container():
+ """Start MinIO container for the test session (or use external)."""
+ if USE_EXTERNAL_CONTAINERS:
+ # Use external container - return None, credentials come from env
+ logger.info("Using external MinIO container")
+ yield None
+ return
+
+ from testcontainers.minio import MinioContainer
+
+ container = MinioContainer(
+ image="minio/minio:latest",
+ access_key="datajoint",
+ secret_key="datajoint",
+ )
+ container.start()
+
+ host = container.get_container_host_ip()
+ port = container.get_exposed_port(9000)
+ logger.info(f"MinIO container started at {host}:{port}")
+
+ yield container
+
+ container.stop()
+ logger.info("MinIO container stopped")
+
+
+# =============================================================================
+# Credential Fixtures - Derived from containers or environment
+# =============================================================================
+
+
+@pytest.fixture(scope="session")
+def prefix():
+ return os.environ.get("DJ_TEST_DB_PREFIX", "djtest")
+
+
+@pytest.fixture(scope="session")
+def db_creds_root(mysql_container) -> Dict:
+ """Root database credentials from container or environment."""
+ if mysql_container is not None:
+ # From testcontainer
+ host = mysql_container.get_container_host_ip()
+ port = mysql_container.get_exposed_port(3306)
+ return dict(
+ host=f"{host}:{port}",
+ user="root",
+ password="password",
+ )
+ else:
+ # From environment (external container)
+ host = os.environ.get("DJ_HOST", "localhost")
+ port = os.environ.get("DJ_PORT", "3306")
+ return dict(
+ host=f"{host}:{port}" if port else host,
+ user=os.environ.get("DJ_USER", "root"),
+ password=os.environ.get("DJ_PASS", "password"),
+ )
+
+
+@pytest.fixture(scope="session")
+def db_creds_test(mysql_container) -> Dict:
+ """Test user database credentials from container or environment."""
+ if mysql_container is not None:
+ # From testcontainer
+ host = mysql_container.get_container_host_ip()
+ port = mysql_container.get_exposed_port(3306)
+ return dict(
+ host=f"{host}:{port}",
+ user="datajoint",
+ password="datajoint",
+ )
+ else:
+ # From environment (external container)
+ host = os.environ.get("DJ_HOST", "localhost")
+ port = os.environ.get("DJ_PORT", "3306")
+ return dict(
+ host=f"{host}:{port}" if port else host,
+ user=os.environ.get("DJ_TEST_USER", "datajoint"),
+ password=os.environ.get("DJ_TEST_PASSWORD", "datajoint"),
+ )
+
+
+@pytest.fixture(scope="session")
+def s3_creds(minio_container) -> Dict:
+ """S3/MinIO credentials from container or environment."""
+ if minio_container is not None:
+ # From testcontainer
+ host = minio_container.get_container_host_ip()
+ port = minio_container.get_exposed_port(9000)
+ return dict(
+ endpoint=f"{host}:{port}",
+ access_key="datajoint",
+ secret_key="datajoint",
+ bucket="datajoint.test",
+ )
+ else:
+ # From environment (external container)
+ return dict(
+ endpoint=os.environ.get("S3_ENDPOINT", "localhost:9000"),
+ access_key=os.environ.get("S3_ACCESS_KEY", "datajoint"),
+ secret_key=os.environ.get("S3_SECRET_KEY", "datajoint"),
+ bucket=os.environ.get("S3_BUCKET", "datajoint.test"),
+ )
+
+
+# =============================================================================
+# Backend-Parameterized Fixtures
+# =============================================================================
+
+
+@pytest.fixture(scope="session", params=["mysql", "postgresql"])
+def backend(request):
+ """Parameterize tests to run against both backends."""
+ return request.param
+
+
+@pytest.fixture(scope="session")
+def db_creds_by_backend(backend, mysql_container, postgres_container):
+ """Get root database credentials for the specified backend."""
+ if backend == "mysql":
+ if mysql_container is not None:
+ host = mysql_container.get_container_host_ip()
+ port = mysql_container.get_exposed_port(3306)
+ return {
+ "backend": "mysql",
+ "host": f"{host}:{port}",
+ "user": "root",
+ "password": "password",
+ }
+ else:
+ # External MySQL container
+ host = os.environ.get("DJ_HOST", "localhost")
+ port = os.environ.get("DJ_PORT", "3306")
+ return {
+ "backend": "mysql",
+ "host": f"{host}:{port}" if port else host,
+ "user": os.environ.get("DJ_USER", "root"),
+ "password": os.environ.get("DJ_PASS", "password"),
+ }
+
+ elif backend == "postgresql":
+ if postgres_container is not None:
+ host = postgres_container.get_container_host_ip()
+ port = postgres_container.get_exposed_port(5432)
+ return {
+ "backend": "postgresql",
+ "host": f"{host}:{port}",
+ "user": "postgres",
+ "password": "password",
+ }
+ else:
+ # External PostgreSQL container
+ host = os.environ.get("DJ_PG_HOST", "localhost")
+ port = os.environ.get("DJ_PG_PORT", "5432")
+ return {
+ "backend": "postgresql",
+ "host": f"{host}:{port}" if port else host,
+ "user": os.environ.get("DJ_PG_USER", "postgres"),
+ "password": os.environ.get("DJ_PG_PASS", "password"),
+ }
+
+
+@pytest.fixture(scope="function")
+def connection_by_backend(db_creds_by_backend):
+ """Create connection for the specified backend.
+
+ This fixture is function-scoped to ensure database.backend config
+ is restored after each test, preventing config pollution between tests.
+ """
+ # Save original config to restore after tests
+ original_backend = dj.config.get("database.backend", "mysql")
+ original_host = dj.config.get("database.host")
+ original_port = dj.config.get("database.port")
+
+ # Configure backend
+ dj.config["database.backend"] = db_creds_by_backend["backend"]
+
+ # Parse host:port
+ host_port = db_creds_by_backend["host"]
+ if ":" in host_port:
+ host, port = host_port.rsplit(":", 1)
+ else:
+ host = host_port
+ port = "3306" if db_creds_by_backend["backend"] == "mysql" else "5432"
+
+ dj.config["database.host"] = host
+ dj.config["database.port"] = int(port)
+ dj.config["safemode"] = False
+
+ connection = dj.Connection(
+ host=host_port,
+ user=db_creds_by_backend["user"],
+ password=db_creds_by_backend["password"],
+ )
+
+ yield connection
+
+ # Restore original config
+ connection.close()
+ dj.config["database.backend"] = original_backend
+ if original_host is not None:
+ dj.config["database.host"] = original_host
+ if original_port is not None:
+ dj.config["database.port"] = original_port
+
+
+# =============================================================================
+# DataJoint Configuration
+# =============================================================================
+
+
+@pytest.fixture(scope="session")
+def configure_datajoint(db_creds_root):
+ """Configure DataJoint to use test database.
+
+ This fixture is NOT autouse - it only runs when a test requests
+ a fixture that depends on it (e.g., connection_root_bare).
+ """
+ # Parse host:port from credentials
+ host_port = db_creds_root["host"]
+ if ":" in host_port:
+ host, port = host_port.rsplit(":", 1)
+ else:
+ host, port = host_port, "3306"
+
+ dj.config["database.host"] = host
+ dj.config["database.port"] = int(port)
+ dj.config["safemode"] = False
+
+ logger.info(f"Configured DataJoint to use MySQL at {host}:{port}")
+
+
+# =============================================================================
+# Connection Fixtures
+# =============================================================================
+
+
+@pytest.fixture(scope="session")
+def connection_root_bare(db_creds_root, configure_datajoint):
+ """Bare root connection without user setup."""
+ connection = dj.Connection(**db_creds_root)
+ yield connection
+
+
+@pytest.fixture(scope="session")
+def connection_root(connection_root_bare, prefix):
+ """Root database connection with test users created."""
+ conn_root = connection_root_bare
+
+ # Create MySQL users (MySQL 8.0+ syntax - we only support 8.0+)
+ conn_root.query(
+ """
+ CREATE USER IF NOT EXISTS 'datajoint'@'%%'
+ IDENTIFIED BY 'datajoint';
+ """
+ )
+ conn_root.query(
+ """
+ CREATE USER IF NOT EXISTS 'djview'@'%%'
+ IDENTIFIED BY 'djview';
+ """
+ )
+ conn_root.query(
+ """
+ CREATE USER IF NOT EXISTS 'djssl'@'%%'
+ IDENTIFIED BY 'djssl'
+ REQUIRE SSL;
+ """
+ )
+ conn_root.query("GRANT ALL PRIVILEGES ON `djtest%%`.* TO 'datajoint'@'%%';")
+ conn_root.query("GRANT SELECT ON `djtest%%`.* TO 'djview'@'%%';")
+ conn_root.query("GRANT SELECT ON `djtest%%`.* TO 'djssl'@'%%';")
+
+ yield conn_root
+
+ # Teardown
+ conn_root.query("SET FOREIGN_KEY_CHECKS=0")
+ cur = conn_root.query('SHOW DATABASES LIKE "{}\\_%%"'.format(prefix))
+ for db in cur.fetchall():
+ conn_root.query("DROP DATABASE `{}`".format(db[0]))
+ conn_root.query("SET FOREIGN_KEY_CHECKS=1")
+ if os.path.exists("dj_local_conf.json"):
+ remove("dj_local_conf.json")
+
+ conn_root.query("DROP USER IF EXISTS `datajoint`")
+ conn_root.query("DROP USER IF EXISTS `djview`")
+ conn_root.query("DROP USER IF EXISTS `djssl`")
+ conn_root.close()
+
+
+@pytest.fixture(scope="session")
+def connection_test(connection_root, prefix, db_creds_test):
+ """Test user database connection."""
+ database = f"{prefix}%%"
+ permission = "ALL PRIVILEGES"
+
+ # MySQL 8.0+ syntax
+ connection_root.query(
+ f"""
+ CREATE USER IF NOT EXISTS '{db_creds_test["user"]}'@'%%'
+ IDENTIFIED BY '{db_creds_test["password"]}';
+ """
+ )
+ connection_root.query(
+ f"""
+ GRANT {permission} ON `{database}`.*
+ TO '{db_creds_test["user"]}'@'%%';
+ """
+ )
+
+ connection = dj.Connection(**db_creds_test)
+ yield connection
+ connection_root.query(f"""DROP USER `{db_creds_test["user"]}`""")
+ connection.close()
+
+
+# =============================================================================
+# S3/MinIO Fixtures
+# =============================================================================
+
+
+@pytest.fixture(scope="session")
+def stores_config(s3_creds, tmpdir_factory):
+ """Configure object storage stores for tests."""
+ return {
+ "raw": dict(protocol="file", location=str(tmpdir_factory.mktemp("raw"))),
+ "repo": dict(
+ stage=str(tmpdir_factory.mktemp("repo")),
+ protocol="file",
+ location=str(tmpdir_factory.mktemp("repo")),
+ ),
+ "repo-s3": dict(
+ protocol="s3",
+ endpoint=s3_creds["endpoint"],
+ access_key=s3_creds["access_key"],
+ secret_key=s3_creds["secret_key"],
+ bucket=s3_creds.get("bucket", "datajoint-test"),
+ location="dj/repo",
+ stage=str(tmpdir_factory.mktemp("repo-s3")),
+ secure=False, # MinIO runs without SSL in tests
+ ),
+ "local": dict(protocol="file", location=str(tmpdir_factory.mktemp("local"))),
+ "share": dict(
+ protocol="s3",
+ endpoint=s3_creds["endpoint"],
+ access_key=s3_creds["access_key"],
+ secret_key=s3_creds["secret_key"],
+ bucket=s3_creds.get("bucket", "datajoint-test"),
+ location="dj/store/repo",
+ secure=False, # MinIO runs without SSL in tests
+ ),
+ }
+
+
+@pytest.fixture
+def mock_stores(stores_config):
+ """Configure stores for tests using unified stores system."""
+ # Save original configuration
+ og_stores = dict(dj.config.stores)
+
+ # Set test configuration
+ dj.config.stores.clear()
+ for name, config in stores_config.items():
+ dj.config.stores[name] = config
+
+ yield
+
+ # Restore original configuration
+ dj.config.stores.clear()
+ dj.config.stores.update(og_stores)
+
+
+@pytest.fixture
+def mock_cache(tmpdir_factory):
+ og_cache = dj.config.get("download_path")
+ dj.config["download_path"] = str(tmpdir_factory.mktemp("cache"))
+ yield
+ if og_cache is None:
+ del dj.config["download_path"]
+ else:
+ dj.config["download_path"] = og_cache
+
+
+@pytest.fixture(scope="session")
+def http_client():
+ client = urllib3.PoolManager(
+ timeout=30,
+ cert_reqs="CERT_REQUIRED",
+ ca_certs=certifi.where(),
+ retries=urllib3.Retry(total=3, backoff_factor=0.2, status_forcelist=[500, 502, 503, 504]),
+ )
+ yield client
+
+
+@pytest.fixture(scope="session")
+def s3fs_client(s3_creds):
+ """Initialize s3fs filesystem for MinIO."""
+ import s3fs
+
+ return s3fs.S3FileSystem(
+ endpoint_url=f"http://{s3_creds['endpoint']}",
+ key=s3_creds["access_key"],
+ secret=s3_creds["secret_key"],
+ )
+
+
+@pytest.fixture(scope="session")
+def minio_client(s3_creds, s3fs_client, teardown=False):
+ """S3 filesystem with test bucket created (legacy name for compatibility)."""
+ bucket = s3_creds["bucket"]
+
+ # Create bucket if it doesn't exist
+ try:
+ s3fs_client.mkdir(bucket)
+ except Exception:
+ # Bucket may already exist
+ pass
+
+ yield s3fs_client
+
+ if not teardown:
+ return
+ # Clean up objects and bucket
+ try:
+ files = s3fs_client.ls(bucket, detail=False)
+ for f in files:
+ s3fs_client.rm(f)
+ s3fs_client.rmdir(bucket)
+ except Exception:
+ pass
+
+
+# =============================================================================
+# Cleanup Fixtures
+# =============================================================================
+
+
+@pytest.fixture
+def clean_autopopulate(experiment, trial, ephys):
+ """Cleanup fixture for autopopulate tests."""
+ yield
+ ephys.delete()
+ trial.delete()
+ experiment.delete()
+
+
+@pytest.fixture
+def clean_jobs(schema_any):
+ """Cleanup fixture for jobs tests."""
+ # schema.jobs returns a list of Job objects for existing job tables
+ for job in schema_any.jobs:
+ try:
+ job.delete()
+ except DataJointError:
+ pass
+ yield
+
+
+@pytest.fixture
+def clean_test_tables(test, test_extra, test_no_extra):
+ """Cleanup fixture for relation tests."""
+ if not test:
+ test.insert(test.contents, skip_duplicates=True)
+ yield
+ test.delete()
+ test.insert(test.contents, skip_duplicates=True)
+ test_extra.delete()
+ test_no_extra.delete()
+
+
+# =============================================================================
+# Schema Fixtures
+# =============================================================================
+
+
+@pytest.fixture(scope="module")
+def schema_any(connection_test, prefix):
+ schema_any = dj.Schema(prefix + "_test1", schema.LOCALS_ANY, connection=connection_test)
+ assert schema.LOCALS_ANY, "LOCALS_ANY is empty"
+ # Clean up any existing job tables (schema.jobs returns a list)
+ for job in schema_any.jobs:
+ try:
+ job.delete()
+ except DataJointError:
+ pass
+ # Allow native PK fields for legacy test tables (Experiment, Trial)
+ original_value = dj.config.jobs.allow_new_pk_fields_in_computed_tables
+ dj.config.jobs.allow_new_pk_fields_in_computed_tables = True
+ schema_any(schema.TTest)
+ schema_any(schema.TTest2)
+ schema_any(schema.TTest3)
+ schema_any(schema.NullableNumbers)
+ schema_any(schema.TTestExtra)
+ schema_any(schema.TTestNoExtra)
+ schema_any(schema.Auto)
+ schema_any(schema.User)
+ schema_any(schema.Subject)
+ schema_any(schema.Language)
+ schema_any(schema.Experiment)
+ schema_any(schema.Trial)
+ schema_any(schema.Ephys)
+ schema_any(schema.Image)
+ schema_any(schema.UberTrash)
+ schema_any(schema.UnterTrash)
+ schema_any(schema.SimpleSource)
+ schema_any(schema.SigIntTable)
+ schema_any(schema.SigTermTable)
+ schema_any(schema.DjExceptionName)
+ schema_any(schema.ErrorClass)
+ schema_any(schema.DecimalPrimaryKey)
+ schema_any(schema.IndexRich)
+ schema_any(schema.ThingA)
+ schema_any(schema.ThingB)
+ schema_any(schema.ThingC)
+ schema_any(schema.ThingD)
+ schema_any(schema.ThingE)
+ schema_any(schema.Parent)
+ schema_any(schema.Child)
+ schema_any(schema.ComplexParent)
+ schema_any(schema.ComplexChild)
+ schema_any(schema.SubjectA)
+ schema_any(schema.SessionA)
+ schema_any(schema.SessionStatusA)
+ schema_any(schema.SessionDateA)
+ schema_any(schema.Stimulus)
+ schema_any(schema.Longblob)
+ # Restore original config value after all tables are declared
+ dj.config.jobs.allow_new_pk_fields_in_computed_tables = original_value
+ yield schema_any
+ # Clean up job tables before dropping schema (if schema still exists)
+ if schema_any.exists:
+ for job in schema_any.jobs:
+ try:
+ job.delete()
+ except DataJointError:
+ pass
+ schema_any.drop()
+
+
+@pytest.fixture
+def schema_any_fresh(connection_test, prefix):
+ """Function-scoped schema_any for tests that need fresh schema state."""
+ schema_any = dj.Schema(prefix + "_test1_fresh", schema.LOCALS_ANY, connection=connection_test)
+ assert schema.LOCALS_ANY, "LOCALS_ANY is empty"
+ # Clean up any existing job tables
+ for job in schema_any.jobs:
+ try:
+ job.delete()
+ except DataJointError:
+ pass
+ # Allow native PK fields for legacy test tables (Experiment, Trial)
+ original_value = dj.config.jobs.allow_new_pk_fields_in_computed_tables
+ dj.config.jobs.allow_new_pk_fields_in_computed_tables = True
+ schema_any(schema.TTest)
+ schema_any(schema.TTest2)
+ schema_any(schema.TTest3)
+ schema_any(schema.NullableNumbers)
+ schema_any(schema.TTestExtra)
+ schema_any(schema.TTestNoExtra)
+ schema_any(schema.Auto)
+ schema_any(schema.User)
+ schema_any(schema.Subject)
+ schema_any(schema.Language)
+ schema_any(schema.Experiment)
+ schema_any(schema.Trial)
+ schema_any(schema.Ephys)
+ schema_any(schema.Image)
+ schema_any(schema.UberTrash)
+ schema_any(schema.UnterTrash)
+ schema_any(schema.SimpleSource)
+ schema_any(schema.SigIntTable)
+ schema_any(schema.SigTermTable)
+ schema_any(schema.DjExceptionName)
+ schema_any(schema.ErrorClass)
+ schema_any(schema.DecimalPrimaryKey)
+ schema_any(schema.IndexRich)
+ schema_any(schema.ThingA)
+ schema_any(schema.ThingB)
+ schema_any(schema.ThingC)
+ schema_any(schema.ThingD)
+ schema_any(schema.ThingE)
+ schema_any(schema.Parent)
+ schema_any(schema.Child)
+ schema_any(schema.ComplexParent)
+ schema_any(schema.ComplexChild)
+ schema_any(schema.SubjectA)
+ schema_any(schema.SessionA)
+ schema_any(schema.SessionStatusA)
+ schema_any(schema.SessionDateA)
+ schema_any(schema.Stimulus)
+ schema_any(schema.Longblob)
+ # Restore original config value after all tables are declared
+ dj.config.jobs.allow_new_pk_fields_in_computed_tables = original_value
+ yield schema_any
+ # Clean up job tables before dropping schema (if schema still exists)
+ if schema_any.exists:
+ for job in schema_any.jobs:
+ try:
+ job.delete()
+ except DataJointError:
+ pass
+ schema_any.drop()
+
+
+@pytest.fixture
+def thing_tables(schema_any):
+ a = schema.ThingA()
+ b = schema.ThingB()
+ c = schema.ThingC()
+ d = schema.ThingD()
+ e = schema.ThingE()
+
+ c.delete_quick()
+ b.delete_quick()
+ a.delete_quick()
+
+ a.insert(dict(a=a) for a in range(7))
+ b.insert1(dict(b1=1, b2=1, b3=100))
+ b.insert1(dict(b1=1, b2=2, b3=100))
+
+ yield a, b, c, d, e
+
+
+@pytest.fixture(scope="module")
+def schema_simp(connection_test, prefix):
+ schema = dj.Schema(prefix + "_relational", schema_simple.LOCALS_SIMPLE, connection=connection_test)
+ schema(schema_simple.SelectPK)
+ schema(schema_simple.KeyPK)
+ schema(schema_simple.IJ)
+ schema(schema_simple.JI)
+ schema(schema_simple.A)
+ schema(schema_simple.B)
+ schema(schema_simple.L)
+ schema(schema_simple.D)
+ schema(schema_simple.E)
+ schema(schema_simple.F)
+ schema(schema_simple.F)
+ schema(schema_simple.G)
+ schema(schema_simple.DataA)
+ schema(schema_simple.DataB)
+ schema(schema_simple.Website)
+ schema(schema_simple.Profile)
+ schema(schema_simple.Website)
+ schema(schema_simple.TTestUpdate)
+ schema(schema_simple.ArgmaxTest)
+ schema(schema_simple.ReservedWord)
+ schema(schema_simple.OutfitLaunch)
+ yield schema
+ schema.drop()
+
+
+@pytest.fixture(scope="module")
+def schema_adv(connection_test, prefix):
+ schema = dj.Schema(
+ prefix + "_advanced",
+ schema_advanced.LOCALS_ADVANCED,
+ connection=connection_test,
+ )
+ schema(schema_advanced.Person)
+ schema(schema_advanced.Parent)
+ schema(schema_advanced.Subject)
+ schema(schema_advanced.Prep)
+ schema(schema_advanced.Slice)
+ schema(schema_advanced.Cell)
+ schema(schema_advanced.InputCell)
+ schema(schema_advanced.LocalSynapse)
+ schema(schema_advanced.GlobalSynapse)
+ yield schema
+ schema.drop()
+
+
+@pytest.fixture
+def schema_ext(connection_test, mock_stores, mock_cache, prefix):
+ schema = dj.Schema(
+ prefix + "_extern",
+ context=schema_external.LOCALS_EXTERNAL,
+ connection=connection_test,
+ )
+ schema(schema_external.Simple)
+ schema(schema_external.SimpleRemote)
+ schema(schema_external.Seed)
+ schema(schema_external.Dimension)
+ schema(schema_external.Image)
+ schema(schema_external.Attach)
+ schema(schema_external.Filepath)
+ schema(schema_external.FilepathS3)
+ yield schema
+ schema.drop()
+
+
+@pytest.fixture(scope="module")
+def schema_uuid(connection_test, prefix):
+ schema = dj.Schema(
+ prefix + "_test1",
+ context=schema_uuid_module.LOCALS_UUID,
+ connection=connection_test,
+ )
+ schema(schema_uuid_module.Basic)
+ schema(schema_uuid_module.Topic)
+ schema(schema_uuid_module.Item)
+ yield schema
+ schema.drop()
+
+
+@pytest.fixture(scope="module")
+def schema_type_aliases(connection_test, prefix):
+ """Schema for testing numeric type aliases."""
+ schema = dj.Schema(
+ prefix + "_type_aliases",
+ context=schema_type_aliases_module.LOCALS_TYPE_ALIASES,
+ connection=connection_test,
+ )
+ schema(schema_type_aliases_module.TypeAliasTable)
+ schema(schema_type_aliases_module.TypeAliasPrimaryKey)
+ schema(schema_type_aliases_module.TypeAliasNullable)
+ yield schema
+ schema.drop()
+
+
+# =============================================================================
+# Table Fixtures
+# =============================================================================
+
+
+@pytest.fixture
+def test(schema_any):
+ yield schema.TTest()
+
+
+@pytest.fixture
+def test2(schema_any):
+ yield schema.TTest2()
+
+
+@pytest.fixture
+def test_extra(schema_any):
+ yield schema.TTestExtra()
+
+
+@pytest.fixture
+def test_no_extra(schema_any):
+ yield schema.TTestNoExtra()
+
+
+@pytest.fixture
+def user(schema_any):
+ return schema.User()
+
+
+@pytest.fixture
+def lang(schema_any):
+ yield schema.Language()
+
+
+@pytest.fixture
+def languages(lang) -> List:
+ og_contents = lang.contents
+ languages = og_contents.copy()
+ yield languages
+ lang.contents = og_contents
+
+
+@pytest.fixture
+def subject(schema_any):
+ yield schema.Subject()
+
+
+@pytest.fixture
+def experiment(schema_any):
+ return schema.Experiment()
+
+
+@pytest.fixture
+def ephys(schema_any):
+ return schema.Ephys()
+
+
+@pytest.fixture
+def img(schema_any):
+ return schema.Image()
+
+
+@pytest.fixture
+def trial(schema_any):
+ return schema.Trial()
+
+
+@pytest.fixture
+def channel(schema_any):
+ return schema.Ephys.Channel()
+
+
+@pytest.fixture
+def trash(schema_any):
+ return schema.UberTrash()
+
+
+# =============================================================================
+# Object Storage Fixtures
+# =============================================================================
+
+
+@pytest.fixture
+def object_storage_config(tmpdir_factory):
+ """Create object storage configuration for testing."""
+ base_location = str(tmpdir_factory.mktemp("object_storage"))
+ # Location now includes project context
+ location = f"{base_location}/test_project"
+ # Create the directory (StorageBackend validates it exists)
+ from pathlib import Path
+
+ Path(location).mkdir(parents=True, exist_ok=True)
+ return {
+ "protocol": "file",
+ "location": location,
+ "token_length": 8,
+ }
+
+
+@pytest.fixture
+def mock_object_storage(object_storage_config):
+ """Mock object storage configuration in datajoint config using unified stores."""
+ # Save original values
+ original_stores = dict(dj.config.stores)
+
+ # Configure default store for tests
+ dj.config.stores["default"] = "local"
+ dj.config.stores["local"] = {
+ "protocol": object_storage_config["protocol"],
+ "location": object_storage_config["location"],
+ "token_length": object_storage_config.get("token_length", 8),
+ }
+
+ yield object_storage_config
+
+ # Restore original values
+ dj.config.stores.clear()
+ dj.config.stores.update(original_stores)
+
+
+@pytest.fixture
+def schema_obj(connection_test, prefix, mock_object_storage):
+ """Schema for object type tests."""
+ schema = dj.Schema(
+ prefix + "_object",
+ context=schema_object.LOCALS_OBJECT,
+ connection=connection_test,
+ )
+ schema(schema_object.ObjectFile)
+ schema(schema_object.ObjectFolder)
+ schema(schema_object.ObjectMultiple)
+ schema(schema_object.ObjectWithOther)
+ yield schema
+ schema.drop()
diff --git a/tests/external-legacy-data/file/temp/datajoint.migrate/djtest_blob_migrate/_Fhi2GUBB0fgxcSP2q-isgncIUTdgGK7ivHiySAU_94local b/tests/external-legacy-data/file/temp/datajoint.migrate/djtest_blob_migrate/_Fhi2GUBB0fgxcSP2q-isgncIUTdgGK7ivHiySAU_94local
deleted file mode 100644
index 11a25ad89..000000000
Binary files a/tests/external-legacy-data/file/temp/datajoint.migrate/djtest_blob_migrate/_Fhi2GUBB0fgxcSP2q-isgncIUTdgGK7ivHiySAU_94local and /dev/null differ
diff --git a/tests/external-legacy-data/file/temp/datajoint.migrate/djtest_blob_migrate/e46pnXQW9GaCKbL3WxV1crGHeGqcE0OLInM_TTwAFfwlocal b/tests/external-legacy-data/file/temp/datajoint.migrate/djtest_blob_migrate/e46pnXQW9GaCKbL3WxV1crGHeGqcE0OLInM_TTwAFfwlocal
deleted file mode 100644
index 8a745d07f..000000000
Binary files a/tests/external-legacy-data/file/temp/datajoint.migrate/djtest_blob_migrate/e46pnXQW9GaCKbL3WxV1crGHeGqcE0OLInM_TTwAFfwlocal and /dev/null differ
diff --git a/tests/external-legacy-data/s3/datajoint.migrate/maps/djtest_blob_migrate/FoRROa2LWM6_wx0RIQ0J-LVvgm256cqDQfJa066HoTEshared b/tests/external-legacy-data/s3/datajoint.migrate/maps/djtest_blob_migrate/FoRROa2LWM6_wx0RIQ0J-LVvgm256cqDQfJa066HoTEshared
deleted file mode 100644
index 38da73099..000000000
Binary files a/tests/external-legacy-data/s3/datajoint.migrate/maps/djtest_blob_migrate/FoRROa2LWM6_wx0RIQ0J-LVvgm256cqDQfJa066HoTEshared and /dev/null differ
diff --git a/tests/external-legacy-data/s3/datajoint.migrate/maps/djtest_blob_migrate/NmWj002gtKUkt9GIBwzn6Iw3x6h7ovlX_FfELbfjwRQshared b/tests/external-legacy-data/s3/datajoint.migrate/maps/djtest_blob_migrate/NmWj002gtKUkt9GIBwzn6Iw3x6h7ovlX_FfELbfjwRQshared
deleted file mode 100644
index 8acc341af..000000000
Binary files a/tests/external-legacy-data/s3/datajoint.migrate/maps/djtest_blob_migrate/NmWj002gtKUkt9GIBwzn6Iw3x6h7ovlX_FfELbfjwRQshared and /dev/null differ
diff --git a/tests/external-legacy-data/s3/datajoint.migrate/maps/djtest_blob_migrate/Ue9c89gKVZD7xPOcHd5Lz6mARJQ50xT1G5cTTX4h0L0shared b/tests/external-legacy-data/s3/datajoint.migrate/maps/djtest_blob_migrate/Ue9c89gKVZD7xPOcHd5Lz6mARJQ50xT1G5cTTX4h0L0shared
deleted file mode 100644
index cfba570e4..000000000
Binary files a/tests/external-legacy-data/s3/datajoint.migrate/maps/djtest_blob_migrate/Ue9c89gKVZD7xPOcHd5Lz6mARJQ50xT1G5cTTX4h0L0shared and /dev/null differ
diff --git a/tests/external-legacy-data/s3/datajoint.migrate/store/djtest_blob_migrate/_3A03zPqfVhbn0rhlOJYGNivFJ4uqYuHaeQBA-V8PKA b/tests/external-legacy-data/s3/datajoint.migrate/store/djtest_blob_migrate/_3A03zPqfVhbn0rhlOJYGNivFJ4uqYuHaeQBA-V8PKA
deleted file mode 100644
index d21049aa6..000000000
Binary files a/tests/external-legacy-data/s3/datajoint.migrate/store/djtest_blob_migrate/_3A03zPqfVhbn0rhlOJYGNivFJ4uqYuHaeQBA-V8PKA and /dev/null differ
diff --git a/tests/external-legacy-data/s3/datajoint.migrate/store/djtest_blob_migrate/_Fhi2GUBB0fgxcSP2q-isgncIUTdgGK7ivHiySAU_94 b/tests/external-legacy-data/s3/datajoint.migrate/store/djtest_blob_migrate/_Fhi2GUBB0fgxcSP2q-isgncIUTdgGK7ivHiySAU_94
deleted file mode 100644
index 11a25ad89..000000000
Binary files a/tests/external-legacy-data/s3/datajoint.migrate/store/djtest_blob_migrate/_Fhi2GUBB0fgxcSP2q-isgncIUTdgGK7ivHiySAU_94 and /dev/null differ
diff --git a/tests/external-legacy-data/v0_11.sql b/tests/external-legacy-data/v0_11.sql
deleted file mode 100644
index a666ec484..000000000
--- a/tests/external-legacy-data/v0_11.sql
+++ /dev/null
@@ -1,138 +0,0 @@
-USE djtest_blob_migrate;
--- MySQL dump 10.13 Distrib 5.7.26, for Linux (x86_64)
---
--- Host: localhost Database: djtest_blob_migrate
--- ------------------------------------------------------
--- Server version 5.7.26
-
-/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
-/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
-/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
-/*!40101 SET NAMES utf8 */;
-/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
-/*!40103 SET TIME_ZONE='+00:00' */;
-/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
-/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
-/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
-/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
-
---
--- Table structure for table `~external`
---
-
-DROP TABLE IF EXISTS `~external`;
-/*!40101 SET @saved_cs_client = @@character_set_client */;
-/*!40101 SET character_set_client = utf8 */;
-CREATE TABLE `~external` (
- `hash` char(51) NOT NULL COMMENT 'the hash of stored object + store name',
- `size` bigint(20) unsigned NOT NULL COMMENT 'size of object in bytes',
- `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'automatic timestamp',
- PRIMARY KEY (`hash`)
-) ENGINE=InnoDB DEFAULT CHARSET=latin1 COMMENT='external storage tracking';
-/*!40101 SET character_set_client = @saved_cs_client */;
-
---
--- Dumping data for table `~external`
---
-
-LOCK TABLES `~external` WRITE;
-/*!40000 ALTER TABLE `~external` DISABLE KEYS */;
-INSERT INTO `~external` VALUES ('e46pnXQW9GaCKbL3WxV1crGHeGqcE0OLInM_TTwAFfwlocal',237,'2019-07-31 17:55:01'),('FoRROa2LWM6_wx0RIQ0J-LVvgm256cqDQfJa066HoTEshared',37,'2019-07-31 17:55:01'),('NmWj002gtKUkt9GIBwzn6Iw3x6h7ovlX_FfELbfjwRQshared',53,'2019-07-31 17:55:01'),('Ue9c89gKVZD7xPOcHd5Lz6mARJQ50xT1G5cTTX4h0L0shared',53,'2019-07-31 17:55:01'),('_3A03zPqfVhbn0rhlOJYGNivFJ4uqYuHaeQBA-V8PKA',237,'2019-07-31 17:55:01'),('_Fhi2GUBB0fgxcSP2q-isgncIUTdgGK7ivHiySAU_94',40,'2019-07-31 17:55:01'),('_Fhi2GUBB0fgxcSP2q-isgncIUTdgGK7ivHiySAU_94local',40,'2019-07-31 17:55:01');
-/*!40000 ALTER TABLE `~external` ENABLE KEYS */;
-UNLOCK TABLES;
-
---
--- Table structure for table `~log`
---
-
-DROP TABLE IF EXISTS `~log`;
-/*!40101 SET @saved_cs_client = @@character_set_client */;
-/*!40101 SET character_set_client = utf8 */;
-CREATE TABLE `~log` (
- `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
- `version` varchar(12) NOT NULL COMMENT 'datajoint version',
- `user` varchar(255) NOT NULL COMMENT 'user@host',
- `host` varchar(255) NOT NULL DEFAULT '' COMMENT 'system hostname',
- `event` varchar(255) NOT NULL DEFAULT '' COMMENT 'custom message',
- PRIMARY KEY (`timestamp`)
-) ENGINE=InnoDB DEFAULT CHARSET=latin1 COMMENT='event logging table for `djtest_blob_migrate`';
-/*!40101 SET character_set_client = @saved_cs_client */;
-
---
--- Dumping data for table `~log`
---
-
-LOCK TABLES `~log` WRITE;
-/*!40000 ALTER TABLE `~log` DISABLE KEYS */;
-INSERT INTO `~log` VALUES ('2019-07-31 17:54:49','0.11.1py','root@172.168.1.4','297df05ab17c','Declared `djtest_blob_migrate`.`~log`'),('2019-07-31 17:54:54','0.11.1py','root@172.168.1.4','297df05ab17c','Declared `djtest_blob_migrate`.`~external`'),('2019-07-31 17:54:55','0.11.1py','root@172.168.1.4','297df05ab17c','Declared `djtest_blob_migrate`.`b`');
-/*!40000 ALTER TABLE `~log` ENABLE KEYS */;
-UNLOCK TABLES;
-
---
--- Table structure for table `a`
---
-
-DROP TABLE IF EXISTS `a`;
-/*!40101 SET @saved_cs_client = @@character_set_client */;
-/*!40101 SET character_set_client = utf8 */;
-CREATE TABLE `a` (
- `id` int(11) NOT NULL,
- `blob_external` char(51) NOT NULL COMMENT ':external:uses S3',
- `blob_share` char(51) NOT NULL COMMENT ':external-shared:uses S3',
- PRIMARY KEY (`id`),
- KEY `blob_external` (`blob_external`),
- KEY `blob_share` (`blob_share`),
- CONSTRAINT `a_ibfk_1` FOREIGN KEY (`blob_external`) REFERENCES `~external` (`hash`),
- CONSTRAINT `a_ibfk_2` FOREIGN KEY (`blob_share`) REFERENCES `~external` (`hash`)
-) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-/*!40101 SET character_set_client = @saved_cs_client */;
-
---
--- Dumping data for table `a`
---
-
-LOCK TABLES `a` WRITE;
-/*!40000 ALTER TABLE `a` DISABLE KEYS */;
-INSERT INTO `a` VALUES (0,'_3A03zPqfVhbn0rhlOJYGNivFJ4uqYuHaeQBA-V8PKA','NmWj002gtKUkt9GIBwzn6Iw3x6h7ovlX_FfELbfjwRQshared'),(1,'_Fhi2GUBB0fgxcSP2q-isgncIUTdgGK7ivHiySAU_94','FoRROa2LWM6_wx0RIQ0J-LVvgm256cqDQfJa066HoTEshared');
-/*!40000 ALTER TABLE `a` ENABLE KEYS */;
-UNLOCK TABLES;
-
---
--- Table structure for table `b`
---
-
-DROP TABLE IF EXISTS `b`;
-/*!40101 SET @saved_cs_client = @@character_set_client */;
-/*!40101 SET character_set_client = utf8 */;
-CREATE TABLE `b` (
- `id` int(11) NOT NULL,
- `blob_local` char(51) NOT NULL COMMENT ':external-local:uses files',
- `blob_share` char(51) NOT NULL COMMENT ':external-shared:uses S3',
- PRIMARY KEY (`id`),
- KEY `blob_local` (`blob_local`),
- KEY `blob_share` (`blob_share`),
- CONSTRAINT `b_ibfk_1` FOREIGN KEY (`blob_local`) REFERENCES `~external` (`hash`),
- CONSTRAINT `b_ibfk_2` FOREIGN KEY (`blob_share`) REFERENCES `~external` (`hash`)
-) ENGINE=InnoDB DEFAULT CHARSET=latin1;
-/*!40101 SET character_set_client = @saved_cs_client */;
-
---
--- Dumping data for table `b`
---
-
-LOCK TABLES `b` WRITE;
-/*!40000 ALTER TABLE `b` DISABLE KEYS */;
-INSERT INTO `b` VALUES (0,'e46pnXQW9GaCKbL3WxV1crGHeGqcE0OLInM_TTwAFfwlocal','Ue9c89gKVZD7xPOcHd5Lz6mARJQ50xT1G5cTTX4h0L0shared'),(1,'_Fhi2GUBB0fgxcSP2q-isgncIUTdgGK7ivHiySAU_94local','FoRROa2LWM6_wx0RIQ0J-LVvgm256cqDQfJa066HoTEshared');
-/*!40000 ALTER TABLE `b` ENABLE KEYS */;
-UNLOCK TABLES;
-/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
-
-/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
-/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
-/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
-/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
-/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
-/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
-/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-
--- Dump completed on 2019-07-31 18:16:40
diff --git a/docs-parts/definition/03-Table-Definition_lang4.rst b/tests/integration/__init__.py
similarity index 100%
rename from docs-parts/definition/03-Table-Definition_lang4.rst
rename to tests/integration/__init__.py
diff --git a/tests/data/Course.csv b/tests/integration/data/Course.csv
similarity index 100%
rename from tests/data/Course.csv
rename to tests/integration/data/Course.csv
diff --git a/tests/data/CurrentTerm.csv b/tests/integration/data/CurrentTerm.csv
similarity index 100%
rename from tests/data/CurrentTerm.csv
rename to tests/integration/data/CurrentTerm.csv
diff --git a/tests/data/Department.csv b/tests/integration/data/Department.csv
similarity index 100%
rename from tests/data/Department.csv
rename to tests/integration/data/Department.csv
diff --git a/tests/data/Enroll.csv b/tests/integration/data/Enroll.csv
similarity index 100%
rename from tests/data/Enroll.csv
rename to tests/integration/data/Enroll.csv
diff --git a/tests/data/Grade.csv b/tests/integration/data/Grade.csv
similarity index 100%
rename from tests/data/Grade.csv
rename to tests/integration/data/Grade.csv
diff --git a/tests/data/Section.csv b/tests/integration/data/Section.csv
similarity index 100%
rename from tests/data/Section.csv
rename to tests/integration/data/Section.csv
diff --git a/tests/data/Student.csv b/tests/integration/data/Student.csv
similarity index 100%
rename from tests/data/Student.csv
rename to tests/integration/data/Student.csv
diff --git a/tests/data/StudentMajor.csv b/tests/integration/data/StudentMajor.csv
similarity index 100%
rename from tests/data/StudentMajor.csv
rename to tests/integration/data/StudentMajor.csv
diff --git a/tests/data/Term.csv b/tests/integration/data/Term.csv
similarity index 100%
rename from tests/data/Term.csv
rename to tests/integration/data/Term.csv
diff --git a/tests/integration/test_aggr_regressions.py b/tests/integration/test_aggr_regressions.py
new file mode 100644
index 000000000..cf4f920b0
--- /dev/null
+++ b/tests/integration/test_aggr_regressions.py
@@ -0,0 +1,249 @@
+"""
+Regression tests for issues 386, 449, 484, and 558 — all related to processing complex aggregations and projections.
+"""
+
+import pytest
+
+import datajoint as dj
+
+from tests.schema_aggr_regress import LOCALS_AGGR_REGRESS, A, B, Q, R, S, X
+from tests.schema_uuid import Item, Topic
+
+
+@pytest.fixture(scope="function")
+def schema_aggr_reg(connection_test, prefix):
+ schema = dj.Schema(
+ prefix + "_aggr_regress",
+ context=LOCALS_AGGR_REGRESS,
+ connection=connection_test,
+ )
+ schema(R)
+ schema(Q)
+ schema(S)
+ yield schema
+ schema.drop()
+
+
+@pytest.fixture(scope="function")
+def schema_aggr_reg_with_abx(connection_test, prefix):
+ schema = dj.Schema(
+ prefix + "_aggr_regress_with_abx",
+ context=LOCALS_AGGR_REGRESS,
+ connection=connection_test,
+ )
+ schema(R)
+ schema(Q)
+ schema(S)
+ schema(A)
+ schema(B)
+ schema(X)
+ yield schema
+ schema.drop()
+
+
+def test_issue386(schema_aggr_reg):
+ """
+ --------------- ISSUE 386 -------------------
+ Issue 386 resulted from the loss of aggregated attributes when the aggregation was used as the restrictor
+ Q & (R.aggr(S, n='count(*)') & 'n=2')
+ Error: Unknown column 'n' in HAVING
+ """
+ result = R.aggr(S, n="count(*)") & "n=10"
+ result = Q & result
+ result.to_dicts()
+
+
+def test_issue449(schema_aggr_reg):
+ """
+ ---------------- ISSUE 449 ------------------
+ Issue 449 arises from incorrect group by attributes after joining with a dj.U()
+ Note: dj.U() * table pattern is no longer supported in 2.0, use dj.U() & table instead
+ """
+ result = dj.U("n") & R.aggr(S, n="max(s)")
+ result.to_dicts()
+
+
+def test_issue484(schema_aggr_reg):
+ """
+ ---------------- ISSUE 484 -----------------
+ Issue 484
+ """
+ q = dj.U().aggr(S, n="max(s)")
+ q.to_arrays("n")
+ q.fetch1("n")
+ q = dj.U().aggr(S, n="avg(s)")
+ result = dj.U().aggr(q, m="max(n)")
+ result.to_dicts()
+
+
+def test_union_join(schema_aggr_reg_with_abx):
+ """
+ This test fails if it runs after TestIssue558.
+
+ https://github.com/datajoint/datajoint-python/issues/930
+ """
+ A.insert(zip([100, 200, 300, 400, 500, 600]))
+ B.insert([(100, 11), (200, 22), (300, 33), (400, 44)])
+ q1 = B & "id < 300"
+ q2 = B & "id > 300"
+
+ expected_data = [
+ {"id": 0, "id2": 5},
+ {"id": 1, "id2": 6},
+ {"id": 2, "id2": 7},
+ {"id": 3, "id2": 8},
+ {"id": 4, "id2": 9},
+ {"id": 100, "id2": 11},
+ {"id": 200, "id2": 22},
+ {"id": 400, "id2": 44},
+ ]
+
+ assert ((q1 + q2) * A).to_dicts() == expected_data
+
+
+class TestIssue558:
+ """
+ --------------- ISSUE 558 ------------------
+ Issue 558 resulted from the fact that DataJoint saves subqueries and often combines a restriction followed
+ by a projection into a single SELECT statement, which in several unusual cases produces unexpected results.
+ """
+
+ def test_issue558_part1(self, schema_aggr_reg_with_abx):
+ q = (A - B).proj(id2="3")
+ assert len(A - B) == len(q)
+
+ def test_issue558_part2(self, schema_aggr_reg_with_abx):
+ d = dict(id=3, id2=5)
+ assert len(X & d) == len((X & d).proj(id2="3"))
+
+
+def test_left_join_invalid_raises_error(schema_uuid):
+ """Left join requires A → B. Topic ↛ Item, so this should raise an error."""
+ from datajoint.errors import DataJointError
+
+ # Clean up from previous tests
+ Item().delete_quick()
+ Topic().delete_quick()
+
+ Topic().add("jeff")
+ Item.populate()
+ with pytest.raises(DataJointError) as exc_info:
+ Topic.join(Item, left=True)
+ assert "left operand to determine" in str(exc_info.value).lower()
+
+
+def test_left_join_valid(schema_uuid):
+ """Left join where A → B: Item → Topic (topic_id is in Item)."""
+ # Clean up from previous tests
+ Item().delete_quick()
+ Topic().delete_quick()
+
+ Topic().add("jeff")
+ Item.populate()
+ Topic().add("jeff2") # Topic without Items
+ # Item.join(Topic, left=True) is valid because Item → Topic
+ q = Item.join(Topic, left=True)
+ qf = q.to_arrays()
+ assert len(q) == len(qf)
+ # All Items should have matching Topics since they were populated from Topics
+ assert len(q) == len(Item())
+
+
+def test_extend_valid(schema_uuid):
+ """extend() is an alias for join(left=True) when A → B."""
+ # Clean up from previous tests
+ Item().delete_quick()
+ Topic().delete_quick()
+
+ Topic().add("alice")
+ Item.populate()
+ # Item → Topic (topic_id is in Item), so extend is valid
+ q_extend = Item.extend(Topic)
+ q_left_join = Item.join(Topic, left=True)
+ # Should produce identical results
+ assert len(q_extend) == len(q_left_join)
+ assert set(q_extend.heading.names) == set(q_left_join.heading.names)
+ assert q_extend.primary_key == q_left_join.primary_key
+
+
+def test_extend_invalid_raises_error(schema_uuid):
+ """extend() requires A → B. Topic ↛ Item, so this should raise an error."""
+ from datajoint.errors import DataJointError
+
+ # Clean up from previous tests
+ Item().delete_quick()
+ Topic().delete_quick()
+
+ Topic().add("bob")
+ Item.populate()
+ # Topic ↛ Item (item_id not in Topic), so extend should fail
+ with pytest.raises(DataJointError) as exc_info:
+ Topic.extend(Item)
+ assert "left operand to determine" in str(exc_info.value).lower()
+
+
+class TestBoolMethod:
+ """
+ Tests for __bool__ method on Aggregation and Union (issue #1234).
+
+ bool(query) should return True if query has rows, False if empty.
+ """
+
+ def test_aggregation_bool_with_results(self, schema_aggr_reg_with_abx):
+ """Aggregation with results should be truthy."""
+ A.insert([(1,), (2,), (3,)])
+ B.insert([(1, 10), (1, 20), (2, 30)])
+ aggr = A.aggr(B, count="count(id2)")
+ assert bool(aggr) is True
+ assert len(aggr) > 0
+
+ def test_aggregation_bool_empty(self, schema_aggr_reg_with_abx):
+ """Aggregation with no results should be falsy."""
+ A.insert([(1,), (2,), (3,)])
+ B.insert([(1, 10), (1, 20), (2, 30)])
+ # Restrict to non-existent entry
+ aggr = (A & "id=999").aggr(B, count="count(id2)")
+ assert bool(aggr) is False
+ assert len(aggr) == 0
+
+ def test_aggregation_bool_matches_len(self, schema_aggr_reg_with_abx):
+ """bool(aggr) should equal len(aggr) > 0."""
+ A.insert([(10,), (20,)])
+ B.insert([(10, 100)])
+ # With results
+ aggr_has = A.aggr(B, count="count(id2)")
+ assert bool(aggr_has) == (len(aggr_has) > 0)
+ # Without results
+ aggr_empty = (A & "id=999").aggr(B, count="count(id2)")
+ assert bool(aggr_empty) == (len(aggr_empty) > 0)
+
+ def test_union_bool_with_results(self, schema_aggr_reg_with_abx):
+ """Union with results should be truthy."""
+ A.insert([(100,), (200,)])
+ B.insert([(100, 1), (200, 2)])
+ q1 = B & "id=100"
+ q2 = B & "id=200"
+ union = q1 + q2
+ assert bool(union) is True
+ assert len(union) > 0
+
+ def test_union_bool_empty(self, schema_aggr_reg_with_abx):
+ """Union with no results should be falsy."""
+ A.insert([(100,), (200,)])
+ B.insert([(100, 1), (200, 2)])
+ q1 = B & "id=999"
+ q2 = B & "id=998"
+ union = q1 + q2
+ assert bool(union) is False
+ assert len(union) == 0
+
+ def test_union_bool_matches_len(self, schema_aggr_reg_with_abx):
+ """bool(union) should equal len(union) > 0."""
+ A.insert([(100,), (200,)])
+ B.insert([(100, 1)])
+ # With results
+ union_has = (B & "id=100") + (B & "id=100")
+ assert bool(union_has) == (len(union_has) > 0)
+ # Without results
+ union_empty = (B & "id=999") + (B & "id=998")
+ assert bool(union_empty) == (len(union_empty) > 0)
diff --git a/tests/integration/test_alter.py b/tests/integration/test_alter.py
new file mode 100644
index 000000000..fbf074332
--- /dev/null
+++ b/tests/integration/test_alter.py
@@ -0,0 +1,54 @@
+import re
+
+import pytest
+
+
+from tests import schema as schema_any_module
+from tests.schema_alter import LOCALS_ALTER, Experiment, Parent
+
+COMBINED_CONTEXT = {
+ **schema_any_module.LOCALS_ANY,
+ **LOCALS_ALTER,
+}
+
+
+@pytest.fixture
+def schema_alter(connection_test, schema_any_fresh):
+ # Overwrite Experiment and Parent nodes using fresh schema
+ schema_any_fresh(Experiment, context=LOCALS_ALTER)
+ schema_any_fresh(Parent, context=LOCALS_ALTER)
+ yield schema_any_fresh
+ schema_any_fresh.drop()
+
+
+class TestAlter:
+ def verify_alter(self, schema_alter, table, attribute_sql):
+ definition_original = schema_alter.connection.query(f"SHOW CREATE TABLE {table.full_table_name}").fetchone()[1]
+ table.definition = table.definition_new
+ table.alter(prompt=False)
+ definition_new = schema_alter.connection.query(f"SHOW CREATE TABLE {table.full_table_name}").fetchone()[1]
+ assert re.sub(f"{attribute_sql},\n ", "", definition_new) == definition_original
+
+ def test_alter(self, schema_alter):
+ original = schema_alter.connection.query("SHOW CREATE TABLE " + Experiment.full_table_name).fetchone()[1]
+ Experiment.definition = Experiment.definition1
+ Experiment.alter(prompt=False, context=COMBINED_CONTEXT)
+ altered = schema_alter.connection.query("SHOW CREATE TABLE " + Experiment.full_table_name).fetchone()[1]
+ assert original != altered
+ Experiment.definition = Experiment.original_definition
+ Experiment().alter(prompt=False, context=COMBINED_CONTEXT)
+ restored = schema_alter.connection.query("SHOW CREATE TABLE " + Experiment.full_table_name).fetchone()[1]
+ assert altered != restored
+ assert original == restored
+
+ def test_alter_part(self, schema_alter):
+ """
+ https://github.com/datajoint/datajoint-python/issues/936
+ """
+ # Regex includes optional COMMENT for type annotations
+ self.verify_alter(schema_alter, table=Parent.Child, attribute_sql=r"`child_id` .* DEFAULT NULL[^,]*")
+ self.verify_alter(
+ schema_alter,
+ table=Parent.Grandchild,
+ attribute_sql=r"`grandchild_id` .* DEFAULT NULL[^,]*",
+ )
diff --git a/tests/integration/test_attach.py b/tests/integration/test_attach.py
new file mode 100644
index 000000000..f7ad953fe
--- /dev/null
+++ b/tests/integration/test_attach.py
@@ -0,0 +1,71 @@
+import os
+from pathlib import Path
+
+
+from tests.schema_external import Attach
+
+
+def test_attach_attributes(schema_ext, minio_client, tmpdir_factory):
+ """Test saving files in attachments"""
+ import datajoint as dj
+
+ # create a mock file
+ table = Attach()
+ source_folder = tmpdir_factory.mktemp("source")
+ for i in range(2):
+ attach1 = Path(source_folder, "attach1.img")
+ data1 = os.urandom(100)
+ with attach1.open("wb") as f:
+ f.write(data1)
+ attach2 = Path(source_folder, "attach2.txt")
+ data2 = os.urandom(200)
+ with attach2.open("wb") as f:
+ f.write(data2)
+ table.insert1(dict(attach=i, img=attach1, txt=attach2))
+
+ download_folder = Path(tmpdir_factory.mktemp("download"))
+ keys = table.keys(order_by="KEY")
+
+ with dj.config.override(download_path=str(download_folder)):
+ path1, path2 = table.to_arrays("img", "txt", order_by="KEY")
+
+ # verify that different attachment are renamed if their filenames collide
+ assert path1[0] != path2[0]
+ assert path1[0] != path1[1]
+ assert Path(path1[0]).parent == download_folder
+ with Path(path1[-1]).open("rb") as f:
+ check1 = f.read()
+ with Path(path2[-1]).open("rb") as f:
+ check2 = f.read()
+ assert data1 == check1
+ assert data2 == check2
+
+ # verify that existing files are not duplicated if their filename matches issue #592
+ p1, p2 = (Attach & keys[0]).fetch1("img", "txt")
+ assert p1 == path1[0]
+ assert p2 == path2[0]
+
+
+def test_return_string(schema_ext, minio_client, tmpdir_factory):
+ """Test returning string on fetch"""
+ import datajoint as dj
+
+ # create a mock file
+ table = Attach()
+ source_folder = tmpdir_factory.mktemp("source")
+
+ attach1 = Path(source_folder, "attach1.img")
+ data1 = os.urandom(100)
+ with attach1.open("wb") as f:
+ f.write(data1)
+ attach2 = Path(source_folder, "attach2.txt")
+ data2 = os.urandom(200)
+ with attach2.open("wb") as f:
+ f.write(data2)
+ table.insert1(dict(attach=2, img=attach1, txt=attach2))
+
+ download_folder = Path(tmpdir_factory.mktemp("download"))
+ with dj.config.override(download_path=str(download_folder)):
+ path1, path2 = table.to_arrays("img", "txt", order_by="KEY")
+
+ assert isinstance(path1[0], str)
diff --git a/tests/integration/test_autopopulate.py b/tests/integration/test_autopopulate.py
new file mode 100644
index 000000000..02ba69d6b
--- /dev/null
+++ b/tests/integration/test_autopopulate.py
@@ -0,0 +1,397 @@
+import platform
+import pytest
+
+import datajoint as dj
+from datajoint import DataJointError
+
+
+def test_populate(clean_autopopulate, trial, subject, experiment, ephys, channel):
+ # test simple populate
+ assert subject, "root tables are empty"
+ assert not experiment, "table already filled?"
+ experiment.populate()
+ assert len(experiment) == len(subject) * experiment.fake_experiments_per_subject
+
+ # test restricted populate
+ assert not trial, "table already filled?"
+ restriction = subject.proj(animal="subject_id").keys()[0]
+ d = trial.connection.dependencies
+ d.load()
+ trial.populate(restriction)
+ assert trial, "table was not populated"
+ key_source = trial.key_source
+ assert len(key_source & trial) == len(key_source & restriction)
+ assert len(key_source - trial) == len(key_source - restriction)
+
+ # test subtable populate
+ assert not ephys
+ assert not channel
+ ephys.populate()
+ assert ephys
+ assert channel
+
+
+def test_populate_with_success_count(clean_autopopulate, subject, experiment, trial):
+ # test simple populate
+ assert subject, "root tables are empty"
+ assert not experiment, "table already filled?"
+ ret = experiment.populate()
+ success_count = ret["success_count"]
+ assert len(experiment.key_source & experiment) == success_count
+
+ # test restricted populate
+ assert not trial, "table already filled?"
+ restriction = subject.proj(animal="subject_id").keys()[0]
+ d = trial.connection.dependencies
+ d.load()
+ ret = trial.populate(restriction, suppress_errors=True)
+ success_count = ret["success_count"]
+ assert len(trial.key_source & trial) == success_count
+
+
+def test_populate_max_calls(clean_autopopulate, subject, experiment, trial):
+ # test populate with max_calls limit
+ assert subject, "root tables are empty"
+ assert not experiment, "table already filled?"
+ n = 3
+ total_keys = len(experiment.key_source)
+ assert total_keys > n
+ ret = experiment.populate(max_calls=n)
+ assert n == ret["success_count"]
+
+
+def test_populate_exclude_error_and_ignore_jobs(clean_autopopulate, subject, experiment):
+ # test that error and ignore jobs are excluded from populate
+ assert subject, "root tables are empty"
+ assert not experiment, "table already filled?"
+
+ # Refresh jobs to create pending entries
+ # Use delay=-1 to ensure jobs are immediately schedulable (avoids race condition with CURRENT_TIMESTAMP(3))
+ experiment.jobs.refresh(delay=-1)
+
+ keys = experiment.jobs.pending.keys(limit=2)
+ for idx, key in enumerate(keys):
+ if idx == 0:
+ experiment.jobs.ignore(key)
+ else:
+ # Create an error job by first reserving then setting error
+ experiment.jobs.reserve(key)
+ experiment.jobs.error(key, "test error")
+
+ # Populate should skip error and ignore jobs
+ experiment.populate(reserve_jobs=True, refresh=False)
+ assert len(experiment.key_source & experiment) == len(experiment.key_source) - 2
+
+
+def test_allow_direct_insert(clean_autopopulate, subject, experiment):
+ assert subject, "root tables are empty"
+ key = subject.keys(limit=1)[0]
+ key["experiment_id"] = 1000
+ key["experiment_date"] = "2018-10-30"
+ experiment.insert1(key, allow_direct_insert=True)
+
+
+@pytest.mark.skipif(
+ platform.system() == "Darwin",
+ reason="multiprocessing with spawn method (macOS default) cannot pickle thread locks",
+)
+@pytest.mark.parametrize("processes", [None, 2])
+def test_multi_processing(clean_autopopulate, subject, experiment, processes):
+ assert subject, "root tables are empty"
+ assert not experiment, "table already filled?"
+ experiment.populate(processes=processes)
+ assert len(experiment) == len(subject) * experiment.fake_experiments_per_subject
+
+
+def test_allow_insert(clean_autopopulate, subject, experiment):
+ assert subject, "root tables are empty"
+ key = subject.keys()[0]
+ key["experiment_id"] = 1001
+ key["experiment_date"] = "2018-10-30"
+ with pytest.raises(DataJointError):
+ experiment.insert1(key)
+
+
+def test_populate_antijoin_with_secondary_attrs(clean_autopopulate, subject, experiment):
+ """Test that populate correctly computes pending keys via antijoin.
+
+ Verifies that partial populate + antijoin gives correct pending counts.
+ Note: Experiment.make() inserts fake_experiments_per_subject rows per key.
+ """
+ assert subject, "root tables are empty"
+ assert not experiment, "table already filled?"
+
+ total_keys = len(experiment.key_source)
+ assert total_keys > 0
+
+ # Partially populate (2 keys from key_source)
+ experiment.populate(max_calls=2)
+ assert len(experiment) == 2 * experiment.fake_experiments_per_subject
+
+ # key_source - target must return only unpopulated keys
+ pending = experiment.key_source - experiment
+ assert len(pending) == total_keys - 2, f"Antijoin returned {len(pending)} pending keys, expected {total_keys - 2}."
+
+ # Verify progress() reports correct counts
+ remaining, total = experiment.progress()
+ assert total == total_keys
+ assert remaining == total_keys - 2
+
+ # Populate the rest and verify antijoin returns 0
+ experiment.populate()
+ pending_after = experiment.key_source - experiment
+ assert len(pending_after) == 0, f"Antijoin returned {len(pending_after)} pending keys after full populate, expected 0."
+
+
+def test_populate_antijoin_overlapping_attrs(prefix, connection_test):
+ """Regression test: antijoin with overlapping secondary attribute names.
+
+ This reproduces the bug where `key_source - self` returns ALL keys instead
+ of just unpopulated ones. The condition is:
+
+ 1. key_source returns secondary attributes (e.g., num_samples, quality)
+ 2. The target table has secondary attributes with the SAME NAMES
+ 3. The VALUES differ between source and target after populate
+
+ Without .proj() on the target, SQL matches on ALL common column names
+ (including secondary attrs), so different values mean no match, and all
+ keys appear "pending" even after populate.
+
+ Real-world example: LightningPoseOutput (key_source) has num_frames,
+ quality, processing_datetime as secondary attrs. InitialContainer (target)
+ also has those same-named columns with different values.
+ """
+ test_schema = dj.Schema(f"{prefix}_antijoin_overlap", connection=connection_test)
+
+ @test_schema
+ class Sensor(dj.Lookup):
+ definition = """
+ sensor_id : int32
+ ---
+ num_samples : int32
+ quality : decimal(4,2)
+ """
+ contents = [
+ (1, 100, 0.95),
+ (2, 200, 0.87),
+ (3, 150, 0.92),
+ (4, 175, 0.89),
+ ]
+
+ @test_schema
+ class ProcessedSensor(dj.Computed):
+ definition = """
+ -> Sensor
+ ---
+ num_samples : int32 # same name as Sensor's secondary attr
+ quality : decimal(4,2) # same name as Sensor's secondary attr
+ result : decimal(8,2)
+ """
+
+ @property
+ def key_source(self):
+ return Sensor() # returns sensor_id + num_samples + quality
+
+ def make(self, key):
+ # Fetch source data (key only contains PK after projection)
+ source = (Sensor() & key).fetch1()
+ # Values intentionally differ from source — this is what triggers
+ # the bug: the antijoin tries to match on num_samples and quality
+ # too, and since values differ, no match is found.
+ self.insert1(
+ dict(
+ sensor_id=key["sensor_id"],
+ num_samples=source["num_samples"] * 2,
+ quality=float(source["quality"]) + 0.05,
+ result=float(source["num_samples"]) * float(source["quality"]),
+ )
+ )
+
+ try:
+ # Partially populate (2 out of 4)
+ ProcessedSensor().populate(max_calls=2)
+ assert len(ProcessedSensor()) == 2
+
+ total_keys = len(ProcessedSensor().key_source)
+ assert total_keys == 4
+
+ # The critical test: populate() must correctly identify remaining keys.
+ # Before the fix, populate() used `key_source - self` which matched on
+ # num_samples and quality too, returning all 4 keys as "pending".
+ ProcessedSensor().populate()
+ assert len(ProcessedSensor()) == 4, (
+ f"After full populate, expected 4 entries but got {len(ProcessedSensor())}. "
+ f"populate() likely re-processed already-completed keys."
+ )
+
+ # Verify progress reports 0 remaining
+ remaining, total = ProcessedSensor().progress()
+ assert remaining == 0, f"Expected 0 remaining, got {remaining}"
+ assert total == 4
+
+ # Verify antijoin with .proj() is correct
+ pending = ProcessedSensor().key_source - ProcessedSensor().proj()
+ assert len(pending) == 0
+ finally:
+ test_schema.drop(prompt=False)
+
+
+def test_load_dependencies(prefix, connection_test):
+ schema = dj.Schema(f"{prefix}_load_dependencies_populate", connection=connection_test)
+
+ @schema
+ class ImageSource(dj.Lookup):
+ definition = """
+ image_source_id: int
+ """
+ contents = [(0,)]
+
+ @schema
+ class Image(dj.Imported):
+ definition = """
+ -> ImageSource
+ ---
+ image_data:
+ """
+
+ def make(self, key):
+ self.insert1(dict(key, image_data=dict()))
+
+ Image.populate()
+
+ @schema
+ class Crop(dj.Computed):
+ definition = """
+ -> Image
+ ---
+ crop_image:
+ """
+
+ def make(self, key):
+ self.insert1(dict(key, crop_image=dict()))
+
+ Crop.populate()
+
+
+def test_make_kwargs_regular(prefix, connection_test):
+ """Test that make_kwargs are passed to regular make method."""
+ schema = dj.Schema(f"{prefix}_make_kwargs_regular", connection=connection_test)
+
+ @schema
+ class Source(dj.Lookup):
+ definition = """
+ source_id: int
+ """
+ contents = [(1,), (2,)]
+
+ @schema
+ class Computed(dj.Computed):
+ definition = """
+ -> Source
+ ---
+ multiplier: int
+ result: int
+ """
+
+ def make(self, key, multiplier=1):
+ self.insert1(dict(key, multiplier=multiplier, result=key["source_id"] * multiplier))
+
+ # Test without make_kwargs
+ Computed.populate(Source & "source_id = 1")
+ assert (Computed & "source_id = 1").fetch1("result") == 1
+
+ # Test with make_kwargs
+ Computed.populate(Source & "source_id = 2", make_kwargs={"multiplier": 10})
+ assert (Computed & "source_id = 2").fetch1("multiplier") == 10
+ assert (Computed & "source_id = 2").fetch1("result") == 20
+
+
+def test_make_kwargs_tripartite(prefix, connection_test):
+ """Test that make_kwargs are passed to make_fetch in tripartite pattern (issue #1350)."""
+ schema = dj.Schema(f"{prefix}_make_kwargs_tripartite", connection=connection_test)
+
+ @schema
+ class Source(dj.Lookup):
+ definition = """
+ source_id: int
+ ---
+ value: int
+ """
+ contents = [(1, 100), (2, 200)]
+
+ @schema
+ class TripartiteComputed(dj.Computed):
+ definition = """
+ -> Source
+ ---
+ scale: int
+ result: int
+ """
+
+ def make_fetch(self, key, scale=1):
+ """Fetch data with optional scale parameter."""
+ value = (Source & key).fetch1("value")
+ return (value, scale)
+
+ def make_compute(self, key, value, scale):
+ """Compute result using fetched value and scale."""
+ return (value * scale, scale)
+
+ def make_insert(self, key, result, scale):
+ """Insert computed result."""
+ self.insert1(dict(key, scale=scale, result=result))
+
+ # Test without make_kwargs (scale defaults to 1)
+ TripartiteComputed.populate(Source & "source_id = 1")
+ row = (TripartiteComputed & "source_id = 1").fetch1()
+ assert row["scale"] == 1
+ assert row["result"] == 100 # 100 * 1
+
+ # Test with make_kwargs (scale = 5)
+ TripartiteComputed.populate(Source & "source_id = 2", make_kwargs={"scale": 5})
+ row = (TripartiteComputed & "source_id = 2").fetch1()
+ assert row["scale"] == 5
+ assert row["result"] == 1000 # 200 * 5
+
+
+def test_populate_reserve_jobs_respects_restrictions(clean_autopopulate, subject, experiment):
+ """Regression test for #1413: populate() with reserve_jobs=True must honour restrictions.
+
+ Previously _populate_distributed() refreshed the job queue with the
+ restriction but then fetched *all* pending jobs, ignoring the restriction
+ and processing every pending key.
+ """
+ assert subject, "subject table is empty"
+ assert not experiment, "experiment table already has rows"
+
+ # Clear any stale jobs from previous tests (success/error entries would
+ # prevent refresh() from re-adding them as pending).
+ experiment.jobs.delete_quick()
+
+ # Refresh the full job queue (no restriction) so that all subjects have
+ # pending jobs — this simulates the real-world scenario where workers share
+ # a single job queue but each worker restricts to its own subset.
+ experiment.jobs.refresh(delay=-1)
+ total_pending = len(experiment.jobs.pending)
+ assert total_pending > 0, "job refresh produced no pending entries"
+
+ # Pick one subject to use as the restriction.
+ first_subject_id = subject.keys(order_by="subject_id ASC", limit=1)[0]["subject_id"]
+ restriction = {"subject_id": first_subject_id}
+
+ # Populate only for the restricted subject. refresh=False so we use the
+ # existing queue populated above. The bug was that this call would process
+ # ALL pending jobs instead of only those matching the restriction.
+ experiment.populate(restriction, reserve_jobs=True, refresh=False)
+
+ # Only rows for the restricted subject should exist.
+ assert len(experiment) > 0, "no rows were populated"
+ assert len(experiment - restriction) == 0, (
+ "populate(reserve_jobs=True) processed keys outside the restriction "
+ f"({len(experiment - restriction)} extra rows found)"
+ )
+
+ # Rows for all other subjects must still be absent.
+ other_subjects = subject - restriction
+ if other_subjects:
+ assert len(experiment & other_subjects.proj()) == 0, "rows for unrestricted subjects were incorrectly populated"
diff --git a/tests/integration/test_blob.py b/tests/integration/test_blob.py
new file mode 100644
index 000000000..d2d047aab
--- /dev/null
+++ b/tests/integration/test_blob.py
@@ -0,0 +1,244 @@
+import timeit
+import uuid
+from datetime import datetime
+from decimal import Decimal
+
+import numpy as np
+import pytest
+from numpy.testing import assert_array_equal
+from pytest import approx
+
+import datajoint as dj
+from datajoint.blob import pack, unpack
+
+from tests.schema import Longblob
+
+
+@pytest.fixture
+def enable_feature_32bit_dims():
+ dj.blob.use_32bit_dims = True
+ yield
+ dj.blob.use_32bit_dims = False
+
+
+def test_pack():
+ for x in (
+ 32,
+ -3.7e-2,
+ np.float64(3e31),
+ -np.inf,
+ np.array(-3).astype(np.uint8),
+ np.array(-1).astype(np.uint8),
+ np.int16(-33),
+ np.array(-33).astype(np.uint16),
+ np.int32(-3),
+ np.array(-1).astype(np.uint32),
+ np.int64(373),
+ np.array(-3).astype(np.uint64),
+ ):
+ assert x == approx(unpack(pack(x)), rel=1e-6), "Scalars don't match!"
+
+ x = np.nan
+ assert np.isnan(unpack(pack(x))), "nan scalar did not match!"
+
+ x = np.random.randn(8, 10)
+ assert_array_equal(x, unpack(pack(x)), "Arrays do not match!")
+
+ x = np.random.randn(10)
+ assert_array_equal(x, unpack(pack(x)), "Arrays do not match!")
+
+ x = 7j
+ assert x == unpack(pack(x)), "Complex scalar does not match"
+
+ x = np.float32(np.random.randn(3, 4, 5))
+ assert_array_equal(x, unpack(pack(x)), "Arrays do not match!")
+
+ x = np.int16(np.random.randn(1, 2, 3))
+ assert_array_equal(x, unpack(pack(x)), "Arrays do not match!")
+
+ x = None
+ assert unpack(pack(x)) is None, "None did not match"
+
+ x = -255
+ y = unpack(pack(x))
+ assert x == y and isinstance(y, int) and not isinstance(y, np.ndarray), "Scalar int did not match"
+
+ x = -25523987234234287910987234987098245697129798713407812347
+ y = unpack(pack(x))
+ assert x == y and isinstance(y, int) and not isinstance(y, np.ndarray), "Unbounded int did not match"
+
+ x = 7.0
+ y = unpack(pack(x))
+ assert x == y and isinstance(y, float) and not isinstance(y, np.ndarray), "Scalar float did not match"
+
+ x = 7j
+ y = unpack(pack(x))
+ assert x == y and isinstance(y, complex) and not isinstance(y, np.ndarray), "Complex scalar did not match"
+
+ x = True
+ assert unpack(pack(x)) is True, "Scalar bool did not match"
+
+ x = [None]
+ assert [None] == unpack(pack(x))
+
+ x = {
+ "name": "Anonymous",
+ "age": 15,
+ 99: datetime.now(),
+ "range": [110, 190],
+ (11, 12): None,
+ }
+ y = unpack(pack(x))
+ assert x == y, "Dict do not match!"
+ assert not isinstance(["range"][0], np.ndarray), "Scalar int was coerced into array."
+
+ x = uuid.uuid4()
+ assert x == unpack(pack(x)), "UUID did not match"
+
+ x = Decimal("-112122121.000003000")
+ assert x == unpack(pack(x)), "Decimal did not pack/unpack correctly"
+
+ x = [1, datetime.now(), {1: "one", "two": 2}, (1, 2)]
+ assert x == unpack(pack(x)), "List did not pack/unpack correctly"
+
+ x = (1, datetime.now(), {1: "one", "two": 2}, (uuid.uuid4(), 2))
+ assert x == unpack(pack(x)), "Tuple did not pack/unpack correctly"
+
+ x = (
+ 1,
+ {datetime.now().date(): "today", "now": datetime.now().date()},
+ {"yes!": [1, 2, np.array((3, 4))]},
+ )
+ y = unpack(pack(x))
+ assert x[1] == y[1]
+ assert_array_equal(x[2]["yes!"][2], y[2]["yes!"][2])
+
+ x = {"elephant"}
+ assert x == unpack(pack(x)), "Set did not pack/unpack correctly"
+
+ x = tuple(range(10))
+ assert x == unpack(pack(range(10))), "Iterator did not pack/unpack correctly"
+
+ x = Decimal("1.24")
+ assert x == approx(unpack(pack(x))), "Decimal object did not pack/unpack correctly"
+
+ x = datetime.now()
+ assert x == unpack(pack(x)), "Datetime object did not pack/unpack correctly"
+
+ x = np.bool_(True)
+ assert x == unpack(pack(x)), "Numpy bool object did not pack/unpack correctly"
+
+ x = "test"
+ assert x == unpack(pack(x)), "String object did not pack/unpack correctly"
+
+ x = np.array(["yes"])
+ assert x == unpack(pack(x)), "Numpy string array object did not pack/unpack correctly"
+
+ x = np.datetime64("1998").astype("datetime64[us]")
+ assert x == unpack(pack(x))
+
+
+def test_recarrays():
+ x = np.array([(1.0, 2), (3.0, 4)], dtype=[("x", float), ("y", int)])
+ assert_array_equal(x, unpack(pack(x)))
+
+ x = x.view(np.recarray)
+ assert_array_equal(x, unpack(pack(x)))
+
+ x = np.array([(3, 4)], dtype=[("tmp0", float), ("tmp1", "O")]).view(np.recarray)
+ assert_array_equal(x, unpack(pack(x)))
+
+
+def test_object_arrays():
+ x = np.array(((1, 2, 3), True), dtype="object")
+ assert_array_equal(x, unpack(pack(x)), "Object array did not serialize correctly")
+
+
+def test_complex():
+ z = np.random.randn(8, 10) + 1j * np.random.randn(8, 10)
+ assert_array_equal(z, unpack(pack(z)), "Arrays do not match!")
+
+ z = np.random.randn(10) + 1j * np.random.randn(10)
+ assert_array_equal(z, unpack(pack(z)), "Arrays do not match!")
+
+ x = np.float32(np.random.randn(3, 4, 5)) + 1j * np.float32(np.random.randn(3, 4, 5))
+ assert_array_equal(x, unpack(pack(x)), "Arrays do not match!")
+
+ x = np.int16(np.random.randn(1, 2, 3)) + 1j * np.int16(np.random.randn(1, 2, 3))
+ assert_array_equal(x, unpack(pack(x)), "Arrays do not match!")
+
+
+def test_insert_longblob(schema_any):
+ insert_dj_blob = {"id": 1, "data": [1, 2, 3]}
+ Longblob.insert1(insert_dj_blob)
+ assert (Longblob & "id=1").fetch1() == insert_dj_blob
+ (Longblob & "id=1").delete()
+
+ query_mym_blob = {"id": 1, "data": np.array([1, 2, 3])}
+ Longblob.insert1(query_mym_blob)
+ assert_array_equal((Longblob & "id=1").fetch1()["data"], query_mym_blob["data"])
+ (Longblob & "id=1").delete()
+
+
+def test_insert_longblob_32bit(schema_any, enable_feature_32bit_dims):
+ query_32_blob = (
+ "INSERT INTO djtest_test1.longblob (id, data) VALUES (1, "
+ "X'6D596D00530200000001000000010000000400000068697473007369646573007461736B73007374"
+ "616765004D000000410200000001000000070000000600000000000000000000000000F8FF00000000"
+ "0000F03F000000000000F03F0000000000000000000000000000F03F00000000000000000000000000"
+ "00F8FF230000004102000000010000000700000004000000000000006C006C006C006C00720072006C"
+ "0023000000410200000001000000070000000400000000000000640064006400640064006400640025"
+ "00000041020000000100000008000000040000000000000053007400610067006500200031003000')"
+ )
+ schema_any.connection.query(query_32_blob).fetchall()
+ fetched = (Longblob & "id=1").fetch1()
+ expected = {
+ "id": 1,
+ "data": np.rec.array(
+ [
+ [
+ (
+ np.array([[np.nan, 1.0, 1.0, 0.0, 1.0, 0.0, np.nan]]),
+ np.array(["llllrrl"], dtype="
+ """
+
+
+def insert_blobs(schema):
+ """
+ This function inserts blobs resulting from the following datajoint-matlab code:
+
+ self.insert({
+ 1 'simple string' 'character string'
+ 2 '1D vector' 1:15:180
+ 3 'string array' {'string1' 'string2'}
+ 4 'struct array' struct('a', {1,2}, 'b', {struct('c', magic(3)), struct('C', magic(5))})
+ 5 '3D double array' reshape(1:24, [2,3,4])
+ 6 '3D uint8 array' reshape(uint8(1:24), [2,3,4])
+ 7 '3D complex array' fftn(reshape(1:24, [2,3,4]))
+ })
+
+ and then dumped using the command
+ mysqldump -u username -p --hex-blob test_schema blob_table > blob.sql
+ """
+
+ schema.connection.query(
+ """
+ INSERT INTO {table_name} (`id`, `comment`, `blob`) VALUES
+ (1,'simple string',0x6D596D00410200000000000000010000000000000010000000000000000400000000000000630068006100720061006300740065007200200073007400720069006E006700), # noqa: E501
+ (2,'1D vector',0x6D596D0041020000000000000001000000000000000C000000000000000600000000000000000000000000F03F00000000000030400000000000003F4000000000000047400000000000804E4000000000000053400000000000C056400000000000805A400000000000405E4000000000000061400000000000E062400000000000C06440), # noqa: E501
+ (3,'string array',0x6D596D00430200000000000000010000000000000002000000000000002F0000000000000041020000000000000001000000000000000700000000000000040000000000000073007400720069006E00670031002F0000000000000041020000000000000001000000000000000700000000000000040000000000000073007400720069006E0067003200), # noqa: E501
+ (4,'struct array',0x6D596D005302000000000000000100000000000000020000000000000002000000610062002900000000000000410200000000000000010000000000000001000000000000000600000000000000000000000000F03F9000000000000000530200000000000000010000000000000001000000000000000100000063006900000000000000410200000000000000030000000000000003000000000000000600000000000000000000000000204000000000000008400000000000001040000000000000F03F0000000000001440000000000000224000000000000018400000000000001C40000000000000004029000000000000004102000000000000000100000000000000010000000000000006000000000000000000000000000040100100000000000053020000000000000001000000000000000100000000000000010000004300E9000000000000004102000000000000000500000000000000050000000000000006000000000000000000000000003140000000000000374000000000000010400000000000002440000000000000264000000000000038400000000000001440000000000000184000000000000028400000000000003240000000000000F03F0000000000001C400000000000002A400000000000003340000000000000394000000000000020400000000000002C400000000000003440000000000000354000000000000000400000000000002E400000000000003040000000000000364000000000000008400000000000002240), # noqa: E501
+ (5,'3D double array',0x6D596D004103000000000000000200000000000000030000000000000004000000000000000600000000000000000000000000F03F000000000000004000000000000008400000000000001040000000000000144000000000000018400000000000001C40000000000000204000000000000022400000000000002440000000000000264000000000000028400000000000002A400000000000002C400000000000002E40000000000000304000000000000031400000000000003240000000000000334000000000000034400000000000003540000000000000364000000000000037400000000000003840), # noqa: E501
+ (6,'3D uint8 array',0x6D596D0041030000000000000002000000000000000300000000000000040000000000000009000000000000000102030405060708090A0B0C0D0E0F101112131415161718), # noqa: E501
+ (7,'3D complex array',0x6D596D0041030000000000000002000000000000000300000000000000040000000000000006000000010000000000000000C0724000000000000028C000000000000038C0000000000000000000000000000038C0000000000000000000000000000052C00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000052C00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000052C00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000AA4C58E87AB62B400000000000000000AA4C58E87AB62BC0000000000000008000000000000052400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000008000000000000052C000000000000000800000000000000080000000000000008000000000000000800000000000000080 # noqa: E501
+ );
+ """.format(table_name=Blob.full_table_name)
+ )
+
+
+@pytest.fixture
+def schema_blob(connection_test, prefix):
+ schema = dj.Schema(prefix + "_test1", dict(Blob=Blob), connection=connection_test)
+ schema(Blob)
+ yield schema
+ schema.drop()
+
+
+@pytest.fixture
+def schema_blob_pop(schema_blob):
+ assert not dj.config["safemode"], "safemode must be disabled"
+ Blob().delete()
+ insert_blobs(schema_blob)
+ return schema_blob
+
+
+def test_complex_matlab_blobs(schema_blob_pop):
+ """
+ test correct de-serialization of various blob types
+ """
+ blobs = Blob().to_arrays("blob", order_by="KEY")
+
+ blob = blobs[0] # 'simple string' 'character string'
+ assert blob[0] == "character string"
+
+ blob = blobs[1] # '1D vector' 1:15:180
+ assert_array_equal(blob, np.r_[1:180:15][None, :])
+ assert_array_equal(blob, unpack(pack(blob)))
+
+ blob = blobs[2] # 'string array' {'string1' 'string2'}
+ assert isinstance(blob, dj.MatCell)
+ assert_array_equal(blob, np.array([["string1", "string2"]]))
+ assert_array_equal(blob, unpack(pack(blob)))
+
+ blob = blobs[3] # 'struct array' struct('a', {1,2}, 'b', {struct('c', magic(3)), struct('C', magic(5))})
+ assert isinstance(blob, dj.MatStruct)
+ assert tuple(blob.dtype.names) == ("a", "b")
+ assert_array_equal(blob.a[0, 0], np.array([[1.0]]))
+ assert_array_equal(blob.a[0, 1], np.array([[2.0]]))
+ assert isinstance(blob.b[0, 1], dj.MatStruct)
+ assert tuple(blob.b[0, 1].C[0, 0].shape) == (5, 5)
+ b = unpack(pack(blob))
+ assert_array_equal(b[0, 0].b[0, 0].c, blob[0, 0].b[0, 0].c)
+ assert_array_equal(b[0, 1].b[0, 0].C, blob[0, 1].b[0, 0].C)
+
+ blob = blobs[4] # '3D double array' reshape(1:24, [2,3,4])
+ assert_array_equal(blob, np.r_[1:25].reshape((2, 3, 4), order="F"))
+ assert blob.dtype == "float64"
+ assert_array_equal(blob, unpack(pack(blob)))
+
+ blob = blobs[5] # reshape(uint8(1:24), [2,3,4])
+ assert np.array_equal(blob, np.r_[1:25].reshape((2, 3, 4), order="F"))
+ assert blob.dtype == "uint8"
+ assert_array_equal(blob, unpack(pack(blob)))
+
+ blob = blobs[6] # fftn(reshape(1:24, [2,3,4]))
+ assert tuple(blob.shape) == (2, 3, 4)
+ assert blob.dtype == "complex128"
+ assert_array_equal(blob, unpack(pack(blob)))
+
+
+def test_complex_matlab_squeeze(schema_blob_pop):
+ """
+ test correct de-serialization of various blob types
+ """
+ blob = (Blob & "id=1").fetch1("blob", squeeze=True) # 'simple string' 'character string'
+ assert blob == "character string"
+
+ blob = (Blob & "id=2").fetch1("blob", squeeze=True) # '1D vector' 1:15:180
+ assert_array_equal(blob, np.r_[1:180:15])
+
+ blob = (Blob & "id=3").fetch1("blob", squeeze=True) # 'string array' {'string1' 'string2'}
+ assert isinstance(blob, dj.MatCell)
+ assert_array_equal(blob, np.array(["string1", "string2"]))
+
+ blob = (Blob & "id=4").fetch1(
+ "blob", squeeze=True
+ ) # 'struct array' struct('a', {1,2}, 'b', {struct('c', magic(3)), struct('C', magic(5))})
+ assert isinstance(blob, dj.MatStruct)
+ assert tuple(blob.dtype.names) == ("a", "b")
+ assert_array_equal(
+ blob.a,
+ np.array(
+ [
+ 1.0,
+ 2,
+ ]
+ ),
+ )
+ assert isinstance(blob[1].b, dj.MatStruct)
+ assert tuple(blob[1].b.C.item().shape) == (5, 5)
+
+ blob = (Blob & "id=5").fetch1("blob", squeeze=True) # '3D double array' reshape(1:24, [2,3,4])
+ assert np.array_equal(blob, np.r_[1:25].reshape((2, 3, 4), order="F"))
+ assert blob.dtype == "float64"
+
+ blob = (Blob & "id=6").fetch1("blob", squeeze=True) # reshape(uint8(1:24), [2,3,4])
+ assert np.array_equal(blob, np.r_[1:25].reshape((2, 3, 4), order="F"))
+ assert blob.dtype == "uint8"
+
+ blob = (Blob & "id=7").fetch1("blob", squeeze=True) # fftn(reshape(1:24, [2,3,4]))
+ assert tuple(blob.shape) == (2, 3, 4)
+ assert blob.dtype == "complex128"
+
+
+def test_iter(schema_blob_pop):
+ """
+ test iterator over the entity set
+ """
+ from_iter = {d["id"]: d for d in Blob()}
+ assert len(from_iter) == len(Blob())
+ assert from_iter[1]["blob"] == "character string"
+
+
+def test_cell_array_with_nested_arrays():
+ """
+ Test unpacking MATLAB cell arrays containing arrays of different sizes.
+ Regression test for issue #1098.
+ """
+ # Create a cell array with nested arrays of different sizes (ragged)
+ cell = np.empty(2, dtype=object)
+ cell[0] = np.array([1, 2, 3])
+ cell[1] = np.array([4, 5, 6, 7, 8])
+ cell = cell.reshape((1, 2)).view(dj.MatCell)
+
+ # Pack and unpack
+ packed = pack(cell)
+ unpacked = unpack(packed)
+
+ # Should preserve structure
+ assert isinstance(unpacked, dj.MatCell)
+ assert unpacked.shape == (1, 2)
+ assert_array_equal(unpacked[0, 0], np.array([1, 2, 3]))
+ assert_array_equal(unpacked[0, 1], np.array([4, 5, 6, 7, 8]))
+
+
+def test_cell_array_with_empty_elements():
+ """
+ Test unpacking MATLAB cell arrays containing empty arrays.
+ Regression test for issue #1056.
+ """
+ # Create a cell array with empty elements: {[], [], []}
+ cell = np.empty(3, dtype=object)
+ cell[0] = np.array([])
+ cell[1] = np.array([])
+ cell[2] = np.array([])
+ cell = cell.reshape((3, 1)).view(dj.MatCell)
+
+ # Pack and unpack
+ packed = pack(cell)
+ unpacked = unpack(packed)
+
+ # Should preserve structure
+ assert isinstance(unpacked, dj.MatCell)
+ assert unpacked.shape == (3, 1)
+ for i in range(3):
+ assert unpacked[i, 0].size == 0
+
+
+def test_cell_array_mixed_empty_nonempty():
+ """
+ Test unpacking MATLAB cell arrays with mixed empty and non-empty elements.
+ """
+ # Create a cell array: {[1,2], [], [3,4,5]}
+ cell = np.empty(3, dtype=object)
+ cell[0] = np.array([1, 2])
+ cell[1] = np.array([])
+ cell[2] = np.array([3, 4, 5])
+ cell = cell.reshape((3, 1)).view(dj.MatCell)
+
+ # Pack and unpack
+ packed = pack(cell)
+ unpacked = unpack(packed)
+
+ # Should preserve structure
+ assert isinstance(unpacked, dj.MatCell)
+ assert unpacked.shape == (3, 1)
+ assert_array_equal(unpacked[0, 0], np.array([1, 2]))
+ assert unpacked[1, 0].size == 0
+ assert_array_equal(unpacked[2, 0], np.array([3, 4, 5]))
diff --git a/tests/integration/test_cascade_delete.py b/tests/integration/test_cascade_delete.py
new file mode 100644
index 000000000..3bc3dc73b
--- /dev/null
+++ b/tests/integration/test_cascade_delete.py
@@ -0,0 +1,294 @@
+"""
+Integration tests for cascade delete on multiple backends.
+"""
+
+import pytest
+
+import datajoint as dj
+
+
+@pytest.fixture(scope="function")
+def schema_by_backend(connection_by_backend, db_creds_by_backend, request):
+ """Create a schema for cascade delete tests."""
+ backend = db_creds_by_backend["backend"]
+ # Use unique schema name for each test
+ import time
+
+ test_id = str(int(time.time() * 1000))[-8:] # Last 8 digits of timestamp
+ schema_name = f"djtest_cascade_{backend}_{test_id}"[:64] # Limit length
+
+ # Drop schema if exists (cleanup from any previous failed runs)
+ if connection_by_backend.is_connected:
+ try:
+ connection_by_backend.query(
+ f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}"
+ )
+ except Exception:
+ pass # Ignore errors during cleanup
+
+ # Create fresh schema
+ schema = dj.Schema(schema_name, connection=connection_by_backend)
+
+ yield schema
+
+ # Cleanup after test
+ if connection_by_backend.is_connected:
+ try:
+ connection_by_backend.query(
+ f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}"
+ )
+ except Exception:
+ pass # Ignore errors during cleanup
+
+
+def test_simple_cascade_delete(schema_by_backend):
+ """Test basic cascade delete with foreign keys."""
+
+ @schema_by_backend
+ class Parent(dj.Manual):
+ definition = """
+ parent_id : int
+ ---
+ name : varchar(255)
+ """
+
+ @schema_by_backend
+ class Child(dj.Manual):
+ definition = """
+ -> Parent
+ child_id : int
+ ---
+ data : varchar(255)
+ """
+
+ # Insert test data
+ Parent.insert1((1, "Parent1"))
+ Parent.insert1((2, "Parent2"))
+ Child.insert1((1, 1, "Child1-1"))
+ Child.insert1((1, 2, "Child1-2"))
+ Child.insert1((2, 1, "Child2-1"))
+
+ assert len(Parent()) == 2
+ assert len(Child()) == 3
+
+ # Delete parent with cascade
+ (Parent & {"parent_id": 1}).delete()
+
+ # Check cascade worked
+ assert len(Parent()) == 1
+ assert len(Child()) == 1
+
+ # Verify remaining data (using to_dicts for DJ 2.0)
+ remaining = Child().to_dicts()
+ assert len(remaining) == 1
+ assert remaining[0]["parent_id"] == 2
+ assert remaining[0]["child_id"] == 1
+ assert remaining[0]["data"] == "Child2-1"
+
+
+def test_multi_level_cascade_delete(schema_by_backend):
+ """Test cascade delete through multiple levels of foreign keys."""
+
+ @schema_by_backend
+ class GrandParent(dj.Manual):
+ definition = """
+ gp_id : int
+ ---
+ name : varchar(255)
+ """
+
+ @schema_by_backend
+ class Parent(dj.Manual):
+ definition = """
+ -> GrandParent
+ parent_id : int
+ ---
+ name : varchar(255)
+ """
+
+ @schema_by_backend
+ class Child(dj.Manual):
+ definition = """
+ -> Parent
+ child_id : int
+ ---
+ data : varchar(255)
+ """
+
+ # Insert test data
+ GrandParent.insert1((1, "GP1"))
+ Parent.insert1((1, 1, "P1"))
+ Parent.insert1((1, 2, "P2"))
+ Child.insert1((1, 1, 1, "C1"))
+ Child.insert1((1, 1, 2, "C2"))
+ Child.insert1((1, 2, 1, "C3"))
+
+ assert len(GrandParent()) == 1
+ assert len(Parent()) == 2
+ assert len(Child()) == 3
+
+ # Delete grandparent - should cascade through parent to child
+ (GrandParent & {"gp_id": 1}).delete()
+
+ # Check everything is deleted
+ assert len(GrandParent()) == 0
+ assert len(Parent()) == 0
+ assert len(Child()) == 0
+
+ # Verify all tables are empty
+ assert len(GrandParent().to_dicts()) == 0
+ assert len(Parent().to_dicts()) == 0
+ assert len(Child().to_dicts()) == 0
+
+
+def test_cascade_delete_with_renamed_attrs(schema_by_backend):
+ """Test cascade delete when foreign key renames attributes."""
+
+ @schema_by_backend
+ class Animal(dj.Manual):
+ definition = """
+ animal_id : int
+ ---
+ species : varchar(255)
+ """
+
+ @schema_by_backend
+ class Observation(dj.Manual):
+ definition = """
+ obs_id : int
+ ---
+ -> Animal.proj(subject_id='animal_id')
+ measurement : float
+ """
+
+ # Insert test data
+ Animal.insert1((1, "Mouse"))
+ Animal.insert1((2, "Rat"))
+ Observation.insert1((1, 1, 10.5))
+ Observation.insert1((2, 1, 11.2))
+ Observation.insert1((3, 2, 15.3))
+
+ assert len(Animal()) == 2
+ assert len(Observation()) == 3
+
+ # Delete animal - should cascade to observations
+ (Animal & {"animal_id": 1}).delete()
+
+ # Check cascade worked
+ assert len(Animal()) == 1
+ assert len(Observation()) == 1
+
+ # Verify remaining data
+ remaining_animals = Animal().to_dicts()
+ assert len(remaining_animals) == 1
+ assert remaining_animals[0]["animal_id"] == 2
+
+ remaining_obs = Observation().to_dicts()
+ assert len(remaining_obs) == 1
+ assert remaining_obs[0]["obs_id"] == 3
+ assert remaining_obs[0]["subject_id"] == 2
+ assert remaining_obs[0]["measurement"] == 15.3
+
+
+def test_delete_preview_with_counts(schema_by_backend):
+ """Diagram.cascade().counts() previews affected rows without deleting."""
+
+ @schema_by_backend
+ class Parent(dj.Manual):
+ definition = """
+ parent_id : int
+ ---
+ name : varchar(255)
+ """
+
+ @schema_by_backend
+ class Child(dj.Manual):
+ definition = """
+ -> Parent
+ child_id : int
+ ---
+ data : varchar(255)
+ """
+
+ Parent.insert1((1, "P1"))
+ Parent.insert1((2, "P2"))
+ Child.insert1((1, 1, "C1-1"))
+ Child.insert1((1, 2, "C1-2"))
+ Child.insert1((2, 1, "C2-1"))
+
+ # Preview restricted cascade via Diagram
+ counts = dj.Diagram.cascade(Parent & {"parent_id": 1}).counts()
+
+ assert isinstance(counts, dict)
+ assert counts[Parent.full_table_name] == 1
+ assert counts[Child.full_table_name] == 2
+
+ # Data must still be intact
+ assert len(Parent()) == 2
+ assert len(Child()) == 3
+
+
+def test_cascade_discovers_downstream_schema(connection_by_backend, db_creds_by_backend):
+ """Cascade delete discovers and includes tables in unloaded downstream schemas."""
+ import time
+
+ backend = db_creds_by_backend["backend"]
+ test_id = str(int(time.time() * 1000))[-8:]
+
+ upstream_name = f"djtest_upstream_{backend}_{test_id}"[:64]
+ downstream_name = f"djtest_downstream_{backend}_{test_id}"[:64]
+
+ qi = connection_by_backend.adapter.quote_identifier
+
+ # Clean up any previous runs
+ for name in (downstream_name, upstream_name):
+ try:
+ connection_by_backend.query(f"DROP DATABASE IF EXISTS {qi(name)}")
+ except Exception:
+ pass
+
+ # Create upstream schema and table
+ upstream = dj.Schema(upstream_name, connection=connection_by_backend)
+
+ @upstream
+ class Parent(dj.Manual):
+ definition = """
+ parent_id : int
+ ---
+ name : varchar(100)
+ """
+
+ # Create downstream schema with FK to upstream — separate schema object
+ downstream = dj.Schema(downstream_name, connection=connection_by_backend)
+
+ @downstream
+ class Child(dj.Manual):
+ definition = """
+ -> Parent
+ child_id : int
+ ---
+ data : varchar(100)
+ """
+
+ # Insert data
+ Parent.insert1(dict(parent_id=1, name="Alice"))
+ Child.insert1(dict(parent_id=1, child_id=1, data="row1"))
+ Child.insert1(dict(parent_id=1, child_id=2, data="row2"))
+
+ # Verify cascade preview discovers the downstream schema
+ counts = dj.Diagram.cascade(Parent & "parent_id=1").counts()
+ assert Parent.full_table_name in counts
+ assert Child.full_table_name in counts
+ assert counts[Child.full_table_name] == 2
+
+ # Verify actual delete cascades across schemas
+ (Parent & "parent_id=1").delete()
+ assert len(Parent()) == 0
+ assert len(Child()) == 0
+
+ # Clean up
+ for name in (downstream_name, upstream_name):
+ try:
+ connection_by_backend.query(f"DROP DATABASE IF EXISTS {qi(name)}")
+ except Exception:
+ pass
diff --git a/tests/integration/test_cascading_delete.py b/tests/integration/test_cascading_delete.py
new file mode 100644
index 000000000..28f175bea
--- /dev/null
+++ b/tests/integration/test_cascading_delete.py
@@ -0,0 +1,148 @@
+import pytest
+
+import datajoint as dj
+
+from tests.schema import ComplexChild, ComplexParent
+from tests.schema_simple import A, B, D, E, G, L, Profile, Website
+
+
+@pytest.fixture
+def schema_simp_pop(schema_simp):
+ # Clean up tables first to ensure fresh state with module-scoped schema
+ # Delete in reverse dependency order
+ Profile().delete()
+ Website().delete()
+ G().delete()
+ E().delete()
+ D().delete()
+ B().delete()
+ L().delete()
+ A().delete()
+
+ A().insert(A.contents, skip_duplicates=True)
+ L().insert(L.contents, skip_duplicates=True)
+ B().populate()
+ D().populate()
+ E().populate()
+ G().populate()
+ yield schema_simp
+
+
+def test_delete_tree(schema_simp_pop):
+ assert not dj.config["safemode"], "safemode must be off for testing"
+ assert L() and A() and B() and B.C() and D() and E() and E.F(), "schema is not populated"
+ A().delete()
+ assert not A() or B() or B.C() or D() or E() or E.F(), "incomplete delete"
+
+
+def test_stepwise_delete(schema_simp_pop):
+ assert not dj.config["safemode"], "safemode must be off for testing"
+ assert L() and A() and B() and B.C(), "schema population failed"
+ B.C().delete(part_integrity="ignore")
+ assert not B.C(), "failed to delete child tables"
+ B().delete()
+ assert not B(), "failed to delete from the parent table following child table deletion"
+
+
+def test_delete_tree_restricted(schema_simp_pop):
+ assert not dj.config["safemode"], "safemode must be off for testing"
+ assert L() and A() and B() and B.C() and D() and E() and E.F(), "schema is not populated"
+ cond = "cond_in_a"
+ rel = A() & cond
+ rest = dict(
+ A=len(A()) - len(rel),
+ B=len(B() - rel),
+ C=len(B.C() - rel),
+ D=len(D() - rel),
+ E=len(E() - rel),
+ F=len(E.F() - rel),
+ )
+ rel.delete()
+ assert not (rel or B() & rel or B.C() & rel or D() & rel or E() & rel or (E.F() & rel)), "incomplete delete"
+ assert len(A()) == rest["A"], "invalid delete restriction"
+ assert len(B()) == rest["B"], "invalid delete restriction"
+ assert len(B.C()) == rest["C"], "invalid delete restriction"
+ assert len(D()) == rest["D"], "invalid delete restriction"
+ assert len(E()) == rest["E"], "invalid delete restriction"
+ assert len(E.F()) == rest["F"], "invalid delete restriction"
+
+
+def test_delete_lookup(schema_simp_pop):
+ assert not dj.config["safemode"], "safemode must be off for testing"
+ assert bool(L() and A() and B() and B.C() and D() and E() and E.F()), "schema is not populated"
+ L().delete()
+ assert not bool(L() or D() or E() or E.F()), "incomplete delete"
+ A().delete() # delete all is necessary because delete L deletes from subtables.
+
+
+def test_delete_lookup_restricted(schema_simp_pop):
+ assert not dj.config["safemode"], "safemode must be off for testing"
+ assert L() and A() and B() and B.C() and D() and E() and E.F(), "schema is not populated"
+ rel = L() & "cond_in_l"
+ original_count = len(L())
+ deleted_count = len(rel)
+ rel.delete()
+ assert len(L()) == original_count - deleted_count
+
+
+def test_delete_complex_keys(schema_any):
+ """
+ https://github.com/datajoint/datajoint-python/issues/883
+ https://github.com/datajoint/datajoint-python/issues/886
+ """
+ assert not dj.config["safemode"], "safemode must be off for testing"
+ parent_key_count = 8
+ child_key_count = 1
+ restriction = dict(
+ {"parent_id_{}".format(i + 1): i for i in range(parent_key_count)},
+ **{"child_id_{}".format(i + 1): (i + parent_key_count) for i in range(child_key_count)},
+ )
+ assert len(ComplexParent & restriction) == 1, "Parent record missing"
+ assert len(ComplexChild & restriction) == 1, "Child record missing"
+ (ComplexParent & restriction).delete()
+ assert len(ComplexParent & restriction) == 0, "Parent record was not deleted"
+ assert len(ComplexChild & restriction) == 0, "Child record was not deleted"
+
+
+def test_delete_master(schema_simp_pop):
+ Profile().populate_random()
+ Profile().delete()
+
+
+def test_delete_parts_error(schema_simp_pop):
+ """test issue #151"""
+ with pytest.raises(dj.DataJointError):
+ Profile().populate_random()
+ Website().delete(part_integrity="enforce")
+
+
+def test_delete_parts(schema_simp_pop):
+ """test issue #151"""
+ Profile().populate_random()
+ Website().delete(part_integrity="cascade")
+
+
+def test_delete_parts_complex(schema_simp_pop):
+ """test issue #151 with complex master/part. PR #1158."""
+ prev_len = len(G())
+ (A() & "id_a=1").delete(part_integrity="cascade")
+ assert prev_len - len(G()) == 16, "Failed to delete parts"
+
+
+def test_drop_part(schema_simp_pop):
+ """test issue #374"""
+ with pytest.raises(dj.DataJointError):
+ Website().drop()
+
+
+def test_delete_1159(thing_tables):
+ tbl_a, tbl_c, tbl_c, tbl_d, tbl_e = thing_tables
+
+ tbl_c.insert([dict(a=i) for i in range(6)])
+ tbl_d.insert([dict(a=i, d=i) for i in range(5)])
+ tbl_e.insert([dict(d=i) for i in range(4)])
+
+ (tbl_a & "a=3").delete()
+
+ assert len(tbl_a) == 6, "Failed to cascade restriction attributes"
+ assert len(tbl_e) == 3, "Failed to cascade restriction attributes"
diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py
new file mode 100644
index 000000000..1f8144f0f
--- /dev/null
+++ b/tests/integration/test_cli.py
@@ -0,0 +1,127 @@
+"""
+Collection of test cases to test the dj cli
+"""
+
+import subprocess
+import sys
+
+import pytest
+
+import datajoint as dj
+
+
+def test_cli_version(capsys):
+ with pytest.raises(SystemExit) as pytest_wrapped_e:
+ dj.cli(args=["-V"])
+ assert pytest_wrapped_e.type is SystemExit
+ assert pytest_wrapped_e.value.code == 0
+
+ captured_output = capsys.readouterr().out
+ assert captured_output == f"{dj.__name__} {dj.__version__}\n"
+
+
+def test_cli_help(capsys):
+ with pytest.raises(SystemExit) as pytest_wrapped_e:
+ dj.cli(args=["--help"])
+ assert pytest_wrapped_e.type is SystemExit
+ assert pytest_wrapped_e.value.code == 0
+
+ captured_output = capsys.readouterr().out
+ assert captured_output.strip()
+
+
+def test_cli_config():
+ process = subprocess.Popen(
+ [sys.executable, "-m", "datajoint.cli"],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+
+ process.stdin.write("dj.config\n")
+ process.stdin.flush()
+
+ stdout, stderr = process.communicate()
+ cleaned = stdout.strip(" >\t\n\r")
+ # Config now uses pydantic format: Config(database=DatabaseSettings(host=..., user=..., ...))
+ for key in ("host=", "user=", "password="):
+ assert key in cleaned, f"Key {key} not found in config from stdout: {cleaned}"
+
+
+def test_cli_args():
+ process = subprocess.Popen(
+ [sys.executable, "-m", "datajoint.cli", "-u", "test_user", "-p", "test_pass", "--host", "test_host"],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+
+ process.stdin.write("dj.config['database.user']\n")
+ process.stdin.write("dj.config['database.password']\n")
+ process.stdin.write("dj.config['database.host']\n")
+ process.stdin.flush()
+
+ stdout, stderr = process.communicate()
+ assert "test_user" in stdout
+ assert "test_pass" in stdout
+ assert "test_host" in stdout
+
+
+def test_cli_schemas(prefix, connection_root, db_creds_root):
+ schema = dj.Schema(prefix + "_cli", locals(), connection=connection_root)
+
+ @schema
+ class IJ(dj.Lookup):
+ definition = """ # tests restrictions
+ i : int
+ j : int
+ """
+ contents = list(dict(i=i, j=j + 2) for i in range(3) for j in range(3))
+
+ # Pass credentials via CLI args to avoid prompting for username
+ process = subprocess.Popen(
+ [
+ sys.executable,
+ "-m",
+ "datajoint.cli",
+ "-u",
+ db_creds_root["user"],
+ "-p",
+ db_creds_root["password"],
+ "--host",
+ db_creds_root["host"],
+ "-s",
+ f"{prefix}_cli:test_schema",
+ ],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ )
+
+ process.stdin.write("test_schema.__dict__['__name__']\n")
+ process.stdin.write("test_schema.__dict__['schema']\n")
+ process.stdin.write("test_schema.IJ.to_dicts()\n")
+ process.stdin.flush()
+
+ stdout, stderr = process.communicate()
+ fetch_res = [
+ {"i": 0, "j": 2},
+ {"i": 0, "j": 3},
+ {"i": 0, "j": 4},
+ {"i": 1, "j": 2},
+ {"i": 1, "j": 3},
+ {"i": 1, "j": 4},
+ {"i": 2, "j": 2},
+ {"i": 2, "j": 3},
+ {"i": 2, "j": 4},
+ ]
+
+ cleaned = stdout.strip(" >\t\n\r")
+ for key in (
+ "test_schema",
+ f"Schema `{prefix}_cli`",
+ ):
+ assert key in cleaned, f"Key {key} not found in stdout: {cleaned}"
diff --git a/tests/integration/test_codec_chaining.py b/tests/integration/test_codec_chaining.py
new file mode 100644
index 000000000..defbd428f
--- /dev/null
+++ b/tests/integration/test_codec_chaining.py
@@ -0,0 +1,368 @@
+"""
+Tests for codec chaining (composition).
+
+This tests the → → json composition pattern
+and similar codec chains.
+"""
+
+from datajoint.codecs import (
+ Codec,
+ _codec_registry,
+ resolve_dtype,
+)
+
+
+class TestCodecChainResolution:
+ """Tests for resolving codec chains."""
+
+ def setup_method(self):
+ """Clear test codecs from registry before each test."""
+ for name in list(_codec_registry.keys()):
+ if name.startswith("test_"):
+ del _codec_registry[name]
+
+ def teardown_method(self):
+ """Clean up test codecs after each test."""
+ for name in list(_codec_registry.keys()):
+ if name.startswith("test_"):
+ del _codec_registry[name]
+
+ def test_single_codec_chain(self):
+ """Test resolving a single-codec chain."""
+
+ class TestSingle(Codec):
+ name = "test_single"
+
+ def get_dtype(self, is_external: bool) -> str:
+ return "varchar(100)"
+
+ def encode(self, value, *, key=None, store_name=None):
+ return str(value)
+
+ def decode(self, stored, *, key=None):
+ return stored
+
+ final_dtype, chain, store = resolve_dtype("")
+
+ assert final_dtype == "varchar(100)"
+ assert len(chain) == 1
+ assert chain[0].name == "test_single"
+ assert store is None
+
+ def test_two_codec_chain(self):
+ """Test resolving a two-codec chain."""
+
+ class TestInner(Codec):
+ name = "test_inner"
+
+ def get_dtype(self, is_external: bool) -> str:
+ return "bytes"
+
+ def encode(self, value, *, key=None, store_name=None):
+ return value
+
+ def decode(self, stored, *, key=None):
+ return stored
+
+ class TestOuter(Codec):
+ name = "test_outer"
+
+ def get_dtype(self, is_external: bool) -> str:
+ return ""
+
+ def encode(self, value, *, key=None, store_name=None):
+ return value
+
+ def decode(self, stored, *, key=None):
+ return stored
+
+ final_dtype, chain, store = resolve_dtype("")
+
+ assert final_dtype == "bytes"
+ assert len(chain) == 2
+ assert chain[0].name == "test_outer"
+ assert chain[1].name == "test_inner"
+
+ def test_three_codec_chain(self):
+ """Test resolving a three-codec chain."""
+
+ class TestBase(Codec):
+ name = "test_base"
+
+ def get_dtype(self, is_external: bool) -> str:
+ return "json"
+
+ def encode(self, value, *, key=None, store_name=None):
+ return value
+
+ def decode(self, stored, *, key=None):
+ return stored
+
+ class TestMiddle(Codec):
+ name = "test_middle"
+
+ def get_dtype(self, is_external: bool) -> str:
+ return ""
+
+ def encode(self, value, *, key=None, store_name=None):
+ return value
+
+ def decode(self, stored, *, key=None):
+ return stored
+
+ class TestTop(Codec):
+ name = "test_top"
+
+ def get_dtype(self, is_external: bool) -> str:
+ return ""
+
+ def encode(self, value, *, key=None, store_name=None):
+ return value
+
+ def decode(self, stored, *, key=None):
+ return stored
+
+ final_dtype, chain, store = resolve_dtype("")
+
+ assert final_dtype == "json"
+ assert len(chain) == 3
+ assert chain[0].name == "test_top"
+ assert chain[1].name == "test_middle"
+ assert chain[2].name == "test_base"
+
+
+class TestCodecChainEncodeDecode:
+ """Tests for encode/decode through codec chains."""
+
+ def setup_method(self):
+ """Clear test codecs from registry before each test."""
+ for name in list(_codec_registry.keys()):
+ if name.startswith("test_"):
+ del _codec_registry[name]
+
+ def teardown_method(self):
+ """Clean up test codecs after each test."""
+ for name in list(_codec_registry.keys()):
+ if name.startswith("test_"):
+ del _codec_registry[name]
+
+ def test_encode_order(self):
+ """Test that encode is applied outer → inner."""
+ encode_order = []
+
+ class TestInnerEnc(Codec):
+ name = "test_inner_enc"
+
+ def get_dtype(self, is_external: bool) -> str:
+ return "bytes"
+
+ def encode(self, value, *, key=None, store_name=None):
+ encode_order.append("inner")
+ return value + b"_inner"
+
+ def decode(self, stored, *, key=None):
+ return stored
+
+ class TestOuterEnc(Codec):
+ name = "test_outer_enc"
+
+ def get_dtype(self, is_external: bool) -> str:
+ return ""
+
+ def encode(self, value, *, key=None, store_name=None):
+ encode_order.append("outer")
+ return value + b"_outer"
+
+ def decode(self, stored, *, key=None):
+ return stored
+
+ _, chain, _ = resolve_dtype("")
+
+ # Apply encode in order: outer first, then inner
+ value = b"start"
+ for codec in chain:
+ value = codec.encode(value)
+
+ assert encode_order == ["outer", "inner"]
+ assert value == b"start_outer_inner"
+
+ def test_decode_order(self):
+ """Test that decode is applied inner → outer (reverse of encode)."""
+ decode_order = []
+
+ class TestInnerDec(Codec):
+ name = "test_inner_dec"
+
+ def get_dtype(self, is_external: bool) -> str:
+ return "bytes"
+
+ def encode(self, value, *, key=None, store_name=None):
+ return value
+
+ def decode(self, stored, *, key=None):
+ decode_order.append("inner")
+ return stored.replace(b"_inner", b"")
+
+ class TestOuterDec(Codec):
+ name = "test_outer_dec"
+
+ def get_dtype(self, is_external: bool) -> str:
+ return ""
+
+ def encode(self, value, *, key=None, store_name=None):
+ return value
+
+ def decode(self, stored, *, key=None):
+ decode_order.append("outer")
+ return stored.replace(b"_outer", b"")
+
+ _, chain, _ = resolve_dtype("")
+
+ # Apply decode in reverse order: inner first, then outer
+ value = b"start_outer_inner"
+ for codec in reversed(chain):
+ value = codec.decode(value)
+
+ assert decode_order == ["inner", "outer"]
+ assert value == b"start"
+
+ def test_roundtrip(self):
+ """Test encode/decode roundtrip through a codec chain."""
+
+ class TestInnerRt(Codec):
+ name = "test_inner_rt"
+
+ def get_dtype(self, is_external: bool) -> str:
+ return "bytes"
+
+ def encode(self, value, *, key=None, store_name=None):
+ # Compress (just add prefix for testing)
+ return b"COMPRESSED:" + value
+
+ def decode(self, stored, *, key=None):
+ # Decompress
+ return stored.replace(b"COMPRESSED:", b"")
+
+ class TestOuterRt(Codec):
+ name = "test_outer_rt"
+
+ def get_dtype(self, is_external: bool) -> str:
+ return ""
+
+ def encode(self, value, *, key=None, store_name=None):
+ # Serialize (just encode string for testing)
+ return str(value).encode("utf-8")
+
+ def decode(self, stored, *, key=None):
+ # Deserialize
+ return stored.decode("utf-8")
+
+ _, chain, _ = resolve_dtype("")
+
+ # Original value
+ original = "test data"
+
+ # Encode: outer → inner
+ encoded = original
+ for codec in chain:
+ encoded = codec.encode(encoded)
+
+ assert encoded == b"COMPRESSED:test data"
+
+ # Decode: inner → outer (reversed)
+ decoded = encoded
+ for codec in reversed(chain):
+ decoded = codec.decode(decoded)
+
+ assert decoded == original
+
+
+class TestBuiltinCodecChains:
+ """Tests for built-in codec chains."""
+
+ def test_blob_internal_resolves_to_bytes(self):
+ """Test that (internal) → bytes."""
+ final_dtype, chain, _ = resolve_dtype("")
+
+ assert final_dtype == "bytes"
+ assert len(chain) == 1
+ assert chain[0].name == "blob"
+
+ def test_blob_external_resolves_to_json(self):
+ """Test that → → json."""
+ final_dtype, chain, store = resolve_dtype("")
+
+ assert final_dtype == "json"
+ assert len(chain) == 2
+ assert chain[0].name == "blob"
+ assert chain[1].name == "hash"
+ assert store == "store"
+
+ def test_attach_internal_resolves_to_bytes(self):
+ """Test that (internal) → bytes."""
+ final_dtype, chain, _ = resolve_dtype("")
+
+ assert final_dtype == "bytes"
+ assert len(chain) == 1
+ assert chain[0].name == "attach"
+
+ def test_attach_external_resolves_to_json(self):
+ """Test that → → json."""
+ final_dtype, chain, store = resolve_dtype("")
+
+ assert final_dtype == "json"
+ assert len(chain) == 2
+ assert chain[0].name == "attach"
+ assert chain[1].name == "hash"
+ assert store == "store"
+
+ def test_hash_external_resolves_to_json(self):
+ """Test that → json (external only)."""
+ final_dtype, chain, store = resolve_dtype("")
+
+ assert final_dtype == "json"
+ assert len(chain) == 1
+ assert chain[0].name == "hash"
+ assert store == "store"
+
+ def test_object_external_resolves_to_json(self):
+ """Test that → json (external only)."""
+ final_dtype, chain, store = resolve_dtype("")
+
+ assert final_dtype == "json"
+ assert len(chain) == 1
+ assert chain[0].name == "object"
+ assert store == "store"
+
+ def test_filepath_external_resolves_to_json(self):
+ """Test that → json (external only)."""
+ final_dtype, chain, store = resolve_dtype("")
+
+ assert final_dtype == "json"
+ assert len(chain) == 1
+ assert chain[0].name == "filepath"
+ assert store == "store"
+
+
+class TestStoreNameParsing:
+ """Tests for store name parsing in codec specs."""
+
+ def test_codec_with_store(self):
+ """Test parsing codec with store name."""
+ final_dtype, chain, store = resolve_dtype("")
+
+ assert final_dtype == "json"
+ assert store == "mystore"
+
+ def test_codec_without_store(self):
+ """Test parsing codec without store name."""
+ final_dtype, chain, store = resolve_dtype("")
+
+ assert store is None
+
+ def test_filepath_with_store(self):
+ """Test parsing filepath with store name."""
+ final_dtype, chain, store = resolve_dtype("")
+
+ assert final_dtype == "json"
+ assert store == "s3store"
diff --git a/tests/integration/test_codecs.py b/tests/integration/test_codecs.py
new file mode 100644
index 000000000..22365e841
--- /dev/null
+++ b/tests/integration/test_codecs.py
@@ -0,0 +1,129 @@
+"""
+Tests for custom codecs.
+
+These tests verify the Codec system for custom data types.
+"""
+
+from itertools import zip_longest
+
+import networkx as nx
+import pytest
+
+import datajoint as dj
+
+from tests import schema_codecs
+from tests.schema_codecs import Connectivity, Layout
+
+
+@pytest.fixture
+def schema_name(prefix):
+ return prefix + "_test_codecs"
+
+
+@pytest.fixture
+def schema_codec(
+ connection_test,
+ s3_creds,
+ tmpdir,
+ schema_name,
+):
+ dj.config["stores"] = {"repo-s3": dict(s3_creds, protocol="s3", location="codecs/repo", stage=str(tmpdir))}
+ # Codecs are auto-registered via __init_subclass__ in schema_codecs
+ context = {**schema_codecs.LOCALS_CODECS}
+ schema = dj.Schema(schema_name, context=context, connection=connection_test)
+ schema(schema_codecs.Connectivity)
+ schema(schema_codecs.Layout)
+ yield schema
+ schema.drop()
+
+
+@pytest.fixture
+def local_schema(schema_codec, schema_name):
+ """Fixture for testing generated classes"""
+ local_schema = dj.Schema(schema_name, connection=schema_codec.connection)
+ local_schema.make_classes()
+ yield local_schema
+ # Don't drop - schema_codec fixture handles cleanup
+
+
+@pytest.fixture
+def schema_virtual_module(schema_codec, schema_name):
+ """Fixture for testing virtual modules"""
+ # Codecs are registered globally, no need to add_objects
+ schema_virtual_module = dj.VirtualModule("virtual_module", schema_name, connection=schema_codec.connection)
+ return schema_virtual_module
+
+
+def test_codec_graph(schema_codec):
+ """Test basic codec encode/decode with graph type."""
+ c = Connectivity()
+ graphs = [
+ nx.lollipop_graph(4, 2),
+ nx.star_graph(5),
+ nx.barbell_graph(3, 1),
+ nx.cycle_graph(5),
+ ]
+ c.insert((i, g) for i, g in enumerate(graphs))
+ returned_graphs = c.to_arrays("conn_graph", order_by="connid")
+ for g1, g2 in zip(graphs, returned_graphs):
+ assert isinstance(g2, nx.Graph)
+ assert len(g1.edges) == len(g2.edges)
+ assert 0 == len(nx.symmetric_difference(g1, g2).edges)
+ c.delete()
+
+
+def test_codec_chained(schema_codec, minio_client):
+ """Test codec chaining (layout -> blob)."""
+ c = Connectivity()
+ c.delete()
+ c.insert1((0, nx.lollipop_graph(4, 2)))
+
+ layout = nx.spring_layout(c.fetch1("conn_graph"))
+ # make json friendly
+ layout = {str(k): [round(r, ndigits=4) for r in v] for k, v in layout.items()}
+ t = Layout()
+ t.insert1((0, layout))
+ result = t.fetch1("layout")
+ assert result == layout
+ t.delete()
+ c.delete()
+
+
+def test_codec_spawned(local_schema):
+ """Test codecs work with spawned classes."""
+ c = Connectivity() # a spawned class
+ graphs = [
+ nx.lollipop_graph(4, 2),
+ nx.star_graph(5),
+ nx.barbell_graph(3, 1),
+ nx.cycle_graph(5),
+ ]
+ c.insert((i, g) for i, g in enumerate(graphs))
+ returned_graphs = c.to_arrays("conn_graph", order_by="connid")
+ for g1, g2 in zip(graphs, returned_graphs):
+ assert isinstance(g2, nx.Graph)
+ assert len(g1.edges) == len(g2.edges)
+ assert 0 == len(nx.symmetric_difference(g1, g2).edges)
+ c.delete()
+
+
+def test_codec_virtual_module(schema_virtual_module):
+ """Test codecs work with virtual modules."""
+ c = schema_virtual_module.Connectivity()
+ graphs = [
+ nx.lollipop_graph(4, 2),
+ nx.star_graph(5),
+ nx.barbell_graph(3, 1),
+ nx.cycle_graph(5),
+ ]
+ c.insert((i, g) for i, g in enumerate(graphs))
+ c.insert1({"connid": 100}) # test work with NULLs
+ returned_graphs = c.to_arrays("conn_graph", order_by="connid")
+ for g1, g2 in zip_longest(graphs, returned_graphs):
+ if g1 is None:
+ assert g2 is None
+ else:
+ assert isinstance(g2, nx.Graph)
+ assert len(g1.edges) == len(g2.edges)
+ assert 0 == len(nx.symmetric_difference(g1, g2).edges)
+ c.delete()
diff --git a/tests/integration/test_connection.py b/tests/integration/test_connection.py
new file mode 100644
index 000000000..ff3940587
--- /dev/null
+++ b/tests/integration/test_connection.py
@@ -0,0 +1,138 @@
+"""
+Collection of test cases to test connection module.
+"""
+
+import numpy as np
+import pytest
+
+import datajoint as dj
+from datajoint import DataJointError
+
+
+class Subjects(dj.Manual):
+ definition = """
+ #Basic subject
+ subject_id : int # unique subject id
+ ---
+ real_id : varchar(40) # real-world name
+ species = "mouse" : enum('mouse', 'monkey', 'human') # species
+ """
+
+
+@pytest.fixture
+def schema_tx(connection_test, prefix):
+ schema = dj.Schema(
+ prefix + "_transactions",
+ context=dict(Subjects=Subjects),
+ connection=connection_test,
+ )
+ schema(Subjects)
+ yield schema
+ schema.drop()
+
+
+def test_dj_conn(db_creds_root):
+ """
+ Should be able to establish a connection as root user
+ """
+ c = dj.conn(**db_creds_root)
+ assert c.is_connected
+
+
+def test_dj_connection_class(connection_test):
+ """
+ Should be able to establish a connection as test user
+ """
+ assert connection_test.is_connected
+
+
+def test_connection_context_manager(db_creds_test):
+ """
+ Connection should support context manager protocol for automatic cleanup.
+ """
+ # Test basic context manager usage
+ with dj.Connection(**db_creds_test) as conn:
+ assert conn.is_connected
+ # Verify we can use the connection
+ result = conn.query("SELECT 1").fetchone()
+ assert result[0] == 1
+
+ # Connection should be closed after exiting context
+ assert not conn.is_connected
+
+
+def test_connection_context_manager_exception(db_creds_test):
+ """
+ Connection should close even when exception is raised inside context.
+ """
+ conn = None
+ with pytest.raises(ValueError):
+ with dj.Connection(**db_creds_test) as conn:
+ assert conn.is_connected
+ raise ValueError("Test exception")
+
+ # Connection should still be closed after exception
+ assert conn is not None
+ assert not conn.is_connected
+
+
+def test_persistent_dj_conn(db_creds_root):
+ """
+ conn() method should provide persistent connection across calls.
+ Setting reset=True should create a new persistent connection.
+ """
+ c1 = dj.conn(**db_creds_root)
+ c2 = dj.conn()
+ c3 = dj.conn(**db_creds_root)
+ c4 = dj.conn(reset=True, **db_creds_root)
+ c5 = dj.conn(**db_creds_root)
+ assert c1 is c2
+ assert c1 is c3
+ assert c1 is not c4
+ assert c4 is c5
+
+
+def test_repr(db_creds_root):
+ c1 = dj.conn(**db_creds_root)
+ assert "disconnected" not in repr(c1) and "connected" in repr(c1)
+
+
+def test_active(connection_test):
+ with connection_test.transaction as conn:
+ assert conn.in_transaction, "Transaction is not active"
+
+
+def test_transaction_rollback(schema_tx, connection_test):
+ """Test transaction cancellation using a with statement"""
+ tmp = np.array(
+ [(1, "Peter", "mouse"), (2, "Klara", "monkey")],
+ Subjects.heading.as_dtype,
+ )
+
+ Subjects.delete()
+ with connection_test.transaction:
+ Subjects.insert1(tmp[0])
+ try:
+ with connection_test.transaction:
+ Subjects.insert1(tmp[1])
+ raise DataJointError("Testing rollback")
+ except DataJointError:
+ pass
+ assert len(Subjects()) == 1, "Length is not 1. Expected because rollback should have happened."
+
+ assert len(Subjects & "subject_id = 2") == 0, "Length is not 0. Expected because rollback should have happened."
+
+
+def test_cancel(schema_tx, connection_test):
+ """Tests cancelling a transaction explicitly"""
+ tmp = np.array(
+ [(1, "Peter", "mouse"), (2, "Klara", "monkey")],
+ Subjects().heading.as_dtype,
+ )
+ Subjects().delete_quick()
+ Subjects.insert1(tmp[0])
+ connection_test.start_transaction()
+ Subjects.insert1(tmp[1])
+ connection_test.cancel_transaction()
+ assert len(Subjects()) == 1, "Length is not 1. Expected because rollback should have happened."
+ assert len(Subjects & "subject_id = 2") == 0, "Length is not 0. Expected because rollback should have happened."
diff --git a/tests/integration/test_declare.py b/tests/integration/test_declare.py
new file mode 100644
index 000000000..19e711e96
--- /dev/null
+++ b/tests/integration/test_declare.py
@@ -0,0 +1,472 @@
+import inspect
+
+import pytest
+
+import datajoint as dj
+from datajoint.declare import declare
+
+from tests.schema import (
+ Auto,
+ Ephys,
+ Experiment,
+ IndexRich,
+ Subject,
+ TTest,
+ TTest2,
+ ThingA, # noqa: F401 - needed in globals for foreign key resolution
+ ThingB, # noqa: F401 - needed in globals for foreign key resolution
+ ThingC,
+ Trial,
+ User,
+)
+
+
+def test_schema_decorator(schema_any):
+ assert issubclass(Subject, dj.Lookup)
+ assert not issubclass(Subject, dj.Part)
+
+
+def test_class_help(schema_any):
+ help(TTest)
+ help(TTest2)
+ assert TTest.definition in TTest.__doc__
+ assert TTest.definition in TTest2.__doc__
+
+
+def test_instance_help(schema_any):
+ help(TTest())
+ help(TTest2())
+ assert TTest().definition in TTest().__doc__
+ assert TTest2().definition in TTest2().__doc__
+
+
+def test_describe(schema_any):
+ """real_definition should match original definition"""
+ rel = Experiment()
+ context = inspect.currentframe().f_globals
+ adapter = rel.connection.adapter
+ s1 = declare(rel.full_table_name, rel.definition, context, adapter)
+ s2 = declare(rel.full_table_name, rel.describe(), context, adapter)
+ assert s1[0] == s2[0] # Compare SQL only (declare now returns tuple)
+
+
+def test_describe_indexes(schema_any):
+ """real_definition should match original definition"""
+ rel = IndexRich()
+ context = inspect.currentframe().f_globals
+ adapter = rel.connection.adapter
+ s1 = declare(rel.full_table_name, rel.definition, context, adapter)
+ s2 = declare(rel.full_table_name, rel.describe(), context, adapter)
+ assert s1[0] == s2[0] # Compare SQL only (declare now returns tuple)
+
+
+def test_describe_dependencies(schema_any):
+ """real_definition should match original definition"""
+ rel = ThingC()
+ context = inspect.currentframe().f_globals
+ adapter = rel.connection.adapter
+ s1 = declare(rel.full_table_name, rel.definition, context, adapter)
+ s2 = declare(rel.full_table_name, rel.describe(), context, adapter)
+ assert s1[0] == s2[0] # Compare SQL only (declare now returns tuple)
+
+
+def test_part(schema_any):
+ """
+ Lookup and part with the same name. See issue #365
+ """
+ local_schema = dj.Schema(schema_any.database, connection=schema_any.connection)
+
+ @local_schema
+ class Type(dj.Lookup):
+ definition = """
+ type : varchar(255)
+ """
+ contents = zip(("Type1", "Type2", "Type3"))
+
+ @local_schema
+ class TypeMaster(dj.Manual):
+ definition = """
+ master_id : int
+ """
+
+ class Type(dj.Part):
+ definition = """
+ -> TypeMaster
+ -> Type
+ """
+
+
+def test_attributes(schema_any):
+ """
+ Test attribute declarations
+ """
+ auto = Auto()
+ subject = Subject()
+ experiment = Experiment()
+ trial = Trial()
+ ephys = Ephys()
+ channel = Ephys.Channel()
+
+ assert auto.heading.names == ["id", "name"]
+ assert auto.heading.attributes["id"].numeric
+
+ # test attribute declarations
+ assert subject.heading.names == [
+ "subject_id",
+ "real_id",
+ "species",
+ "date_of_birth",
+ "subject_notes",
+ ]
+ assert subject.primary_key == ["subject_id"]
+ assert subject.heading.attributes["subject_id"].numeric
+ assert not subject.heading.attributes["real_id"].numeric
+
+ assert experiment.heading.names == [
+ "subject_id",
+ "experiment_id",
+ "experiment_date",
+ "username",
+ "data_path",
+ "notes",
+ "entry_time",
+ ]
+ assert experiment.primary_key == ["subject_id", "experiment_id"]
+
+ assert trial.heading.names == [ # tests issue #516
+ "animal",
+ "experiment_id",
+ "trial_id",
+ "start_time",
+ ]
+ assert trial.primary_key == ["animal", "experiment_id", "trial_id"]
+
+ assert ephys.heading.names == [
+ "animal",
+ "experiment_id",
+ "trial_id",
+ "sampling_frequency",
+ "duration",
+ ]
+ assert ephys.primary_key == ["animal", "experiment_id", "trial_id"]
+
+ assert channel.heading.names == [
+ "animal",
+ "experiment_id",
+ "trial_id",
+ "channel",
+ "voltage",
+ "current",
+ ]
+ assert channel.primary_key == ["animal", "experiment_id", "trial_id", "channel"]
+ assert channel.heading.attributes["voltage"].is_blob
+
+
+def test_dependencies(schema_any):
+ user = User()
+ subject = Subject()
+ experiment = Experiment()
+ trial = Trial()
+ ephys = Ephys()
+ channel = Ephys.Channel()
+
+ assert experiment.full_table_name in user.children(primary=False)
+ assert set(experiment.parents(primary=False)) == {user.full_table_name}
+ assert experiment.full_table_name in user.children(primary=False)
+ assert set(experiment.parents(primary=False)) == {user.full_table_name}
+ assert set(s.full_table_name for s in experiment.parents(primary=False, as_objects=True)) == {user.full_table_name}
+
+ assert experiment.full_table_name in subject.descendants()
+ assert experiment.full_table_name in {s.full_table_name for s in subject.descendants(as_objects=True)}
+ assert subject.full_table_name in experiment.ancestors()
+ assert subject.full_table_name in {s.full_table_name for s in experiment.ancestors(as_objects=True)}
+
+ assert trial.full_table_name in experiment.descendants()
+ assert trial.full_table_name in {s.full_table_name for s in experiment.descendants(as_objects=True)}
+ assert experiment.full_table_name in trial.ancestors()
+ assert experiment.full_table_name in {s.full_table_name for s in trial.ancestors(as_objects=True)}
+
+ assert set(trial.children(primary=True)) == {
+ ephys.full_table_name,
+ trial.Condition.full_table_name,
+ }
+ assert set(trial.parts()) == {trial.Condition.full_table_name}
+ assert set(s.full_table_name for s in trial.parts(as_objects=True)) == {trial.Condition.full_table_name}
+ assert set(ephys.parents(primary=True)) == {trial.full_table_name}
+ assert set(s.full_table_name for s in ephys.parents(primary=True, as_objects=True)) == {trial.full_table_name}
+ assert set(ephys.children(primary=True)) == {channel.full_table_name}
+ assert set(s.full_table_name for s in ephys.children(primary=True, as_objects=True)) == {channel.full_table_name}
+ assert set(channel.parents(primary=True)) == {ephys.full_table_name}
+ assert set(s.full_table_name for s in channel.parents(primary=True, as_objects=True)) == {ephys.full_table_name}
+
+
+def test_descendants_only_contain_part_table(schema_any):
+ """issue #927"""
+
+ class A(dj.Manual):
+ definition = """
+ a: int
+ """
+
+ class B(dj.Manual):
+ definition = """
+ -> A
+ b: int
+ """
+
+ class Master(dj.Manual):
+ definition = """
+ table_master: int
+ """
+
+ class Part(dj.Part):
+ definition = """
+ -> master
+ -> B
+ """
+
+ context = dict(A=A, B=B, Master=Master)
+ schema_any(A, context=context)
+ schema_any(B, context=context)
+ schema_any(Master, context=context)
+ assert A.descendants() == [
+ "`djtest_test1`.`a`",
+ "`djtest_test1`.`b`",
+ "`djtest_test1`.`master__part`",
+ ]
+
+
+def test_bad_attribute_name(schema_any):
+ class BadName(dj.Manual):
+ definition = """
+ Bad_name : int
+ """
+
+ with pytest.raises(dj.DataJointError):
+ schema_any(BadName)
+
+
+def test_bad_fk_rename(schema_any_fresh):
+ """issue #381"""
+
+ class A(dj.Manual):
+ definition = """
+ a : int
+ """
+
+ class B(dj.Manual):
+ definition = """
+ b -> A # invalid, the new syntax is (b) -> A
+ """
+
+ schema_any_fresh(A)
+ with pytest.raises(dj.DataJointError):
+ schema_any_fresh(B)
+
+
+def test_primary_nullable_foreign_key(schema_any):
+ class Q(dj.Manual):
+ definition = """
+ -> [nullable] Experiment
+ """
+
+ with pytest.raises(dj.DataJointError):
+ schema_any(Q)
+
+
+def test_invalid_foreign_key_option(schema_any):
+ class R(dj.Manual):
+ definition = """
+ -> Experiment
+ ----
+ -> [optional] User
+ """
+
+ with pytest.raises(dj.DataJointError):
+ schema_any(R)
+
+
+def test_unsupported_datatype(schema_any):
+ class Q(dj.Manual):
+ definition = """
+ experiment : int
+ ---
+ description : completely_invalid_type_xyz
+ """
+
+ with pytest.raises(dj.DataJointError):
+ schema_any(Q)
+
+
+def test_int_datatype(schema_any):
+ @schema_any
+ class Owner(dj.Manual):
+ definition = """
+ ownerid : int
+ ---
+ car_count : integer
+ """
+
+
+def test_unsupported_int_datatype(schema_any):
+ class Driver(dj.Manual):
+ definition = """
+ driverid : tinyint
+ ---
+ car_count : tinyinteger
+ """
+
+ with pytest.raises(dj.DataJointError):
+ schema_any(Driver)
+
+
+def test_long_table_name(schema_any):
+ """
+ test issue #205 -- reject table names over 64 characters in length
+ """
+
+ class WhyWouldAnyoneCreateATableNameThisLong(dj.Manual):
+ definition = """
+ master : int
+ """
+
+ class WithSuchALongPartNameThatItCrashesMySQL(dj.Part):
+ definition = """
+ -> (master)
+ """
+
+ with pytest.raises(dj.DataJointError):
+ schema_any(WhyWouldAnyoneCreateATableNameThisLong)
+
+
+def test_index_attribute_name(schema_any):
+ """Attributes named 'index' should not be misclassified as index declarations (#1411)."""
+
+ class IndexAttribute(dj.Manual):
+ definition = """
+ index : int
+ ---
+ index_value : float
+ """
+
+ schema_any(IndexAttribute)
+ assert "index" in IndexAttribute.heading.attributes
+ assert "index_value" in IndexAttribute.heading.attributes
+ IndexAttribute.drop()
+
+
+def test_table_name_with_underscores(schema_any):
+ """
+ Test issue #1150 -- Table names with underscores should produce a warning but still work.
+ Strict CamelCase is recommended.
+ """
+
+ class TableNoUnderscores(dj.Manual):
+ definition = """
+ id : int
+ """
+
+ class Table_With_Underscores(dj.Manual):
+ definition = """
+ id : int
+ """
+
+ schema_any(TableNoUnderscores)
+ # Underscores now produce a warning instead of an error (legacy support)
+ with pytest.warns(UserWarning, match="contains underscores"):
+ schema_any(Table_With_Underscores)
+ # Verify the table was created successfully
+ assert Table_With_Underscores.is_declared
+
+
+class TestSingletonTables:
+ """Tests for singleton tables (empty primary keys)."""
+
+ def test_singleton_declaration(self, schema_any):
+ """Singleton table creates correctly with hidden _singleton attribute."""
+
+ @schema_any
+ class Config(dj.Lookup):
+ definition = """
+ # Global configuration
+ ---
+ setting : varchar(100)
+ """
+
+ # Access attributes first to trigger lazy loading from database
+ visible_attrs = Config.heading.attributes
+ all_attrs = Config.heading._attributes
+
+ # Table should exist and have _singleton as hidden PK
+ assert "_singleton" in all_attrs
+ assert "_singleton" not in visible_attrs
+ assert Config.heading.primary_key == [] # Visible PK is empty for singleton
+
+ def test_singleton_insert_and_fetch(self, schema_any):
+ """Insert and fetch work without specifying _singleton."""
+
+ @schema_any
+ class Settings(dj.Lookup):
+ definition = """
+ ---
+ value : int32
+ """
+
+ # Insert without specifying _singleton
+ Settings.insert1({"value": 42})
+
+ # Fetch should work
+ result = Settings.fetch1()
+ assert result["value"] == 42
+ assert "_singleton" not in result # Hidden attribute excluded
+
+ def test_singleton_uniqueness(self, schema_any):
+ """Second insert raises DuplicateError."""
+
+ @schema_any
+ class SingleValue(dj.Lookup):
+ definition = """
+ ---
+ data : varchar(50)
+ """
+
+ SingleValue.insert1({"data": "first"})
+
+ # Second insert should fail
+ with pytest.raises(dj.errors.DuplicateError):
+ SingleValue.insert1({"data": "second"})
+
+ def test_singleton_with_multiple_attributes(self, schema_any):
+ """Singleton table with multiple secondary attributes."""
+
+ @schema_any
+ class PipelineConfig(dj.Lookup):
+ definition = """
+ # Pipeline configuration singleton
+ ---
+ version : varchar(20)
+ max_workers : int32
+ debug_mode : bool
+ """
+
+ PipelineConfig.insert1({"version": "1.0.0", "max_workers": 4, "debug_mode": False})
+
+ result = PipelineConfig.fetch1()
+ assert result["version"] == "1.0.0"
+ assert result["max_workers"] == 4
+ assert result["debug_mode"] == 0 # bool stored as tinyint
+
+ def test_singleton_describe(self, schema_any):
+ """Describe should show the singleton nature."""
+
+ @schema_any
+ class Metadata(dj.Lookup):
+ definition = """
+ ---
+ info : varchar(255)
+ """
+
+ description = Metadata.describe()
+ # Description should show just the secondary attribute
+ assert "info" in description
+ # _singleton is hidden, implementation detail
diff --git a/tests/integration/test_dependencies.py b/tests/integration/test_dependencies.py
new file mode 100644
index 000000000..7d9c5dd6e
--- /dev/null
+++ b/tests/integration/test_dependencies.py
@@ -0,0 +1,52 @@
+from pytest import raises
+
+from datajoint import errors
+
+
+def test_nullable_dependency(thing_tables):
+ """test nullable unique foreign key"""
+ # Thing C has a nullable dependency on B whose primary key is composite
+ _, _, c, _, _ = thing_tables
+
+ # missing foreign key attributes = ok
+ c.insert1(dict(a=0))
+ c.insert1(dict(a=1, b1=33))
+ c.insert1(dict(a=2, b2=77))
+
+ # unique foreign key attributes = ok
+ c.insert1(dict(a=3, b1=1, b2=1))
+ c.insert1(dict(a=4, b1=1, b2=2))
+
+ assert len(c) == len(c.to_arrays()) == 5
+
+
+def test_topo_sort():
+ import networkx as nx
+
+ import datajoint as dj
+
+ graph = nx.DiGraph(
+ [
+ ("`a`.`a`", "`a`.`m`"),
+ ("`a`.`a`", "`a`.`z`"),
+ ("`a`.`m`", "`a`.`m__part`"),
+ ("`a`.`z`", "`a`.`m__part`"),
+ ]
+ )
+ assert dj.dependencies.topo_sort(graph) == [
+ "`a`.`a`",
+ "`a`.`z`",
+ "`a`.`m`",
+ "`a`.`m__part`",
+ ]
+
+
+def test_unique_dependency(thing_tables):
+ """test nullable unique foreign key"""
+ # Thing C has a nullable dependency on B whose primary key is composite
+ _, _, c, _, _ = thing_tables
+
+ c.insert1(dict(a=0, b1=1, b2=1))
+ # duplicate foreign key attributes = not ok
+ with raises(errors.DuplicateError):
+ c.insert1(dict(a=1, b1=1, b2=1))
diff --git a/tests/integration/test_erd.py b/tests/integration/test_erd.py
new file mode 100644
index 000000000..d746bf49e
--- /dev/null
+++ b/tests/integration/test_erd.py
@@ -0,0 +1,158 @@
+import pytest as _pytest
+
+import datajoint as dj
+
+from tests.schema_simple import LOCALS_SIMPLE, A, B, D, E, G, L, Profile, Website
+
+
+def test_decorator(schema_simp):
+ assert issubclass(A, dj.Lookup)
+ assert not issubclass(A, dj.Part)
+ assert B.database == schema_simp.database
+ assert issubclass(B.C, dj.Part)
+ assert B.C.database == schema_simp.database
+ assert B.C.master is B and E.F.master is E
+
+
+def test_dependencies(schema_simp):
+ deps = schema_simp.connection.dependencies
+ deps.load()
+ assert all(cls.full_table_name in deps for cls in (A, B, B.C, D, E, E.F, L))
+ assert set(A().children()) == set([B.full_table_name, D.full_table_name])
+ assert set(D().parents(primary=True)) == set([A.full_table_name])
+ assert set(D().parents(primary=False)) == set([L.full_table_name])
+ assert set(deps.descendants(L.full_table_name)).issubset(cls.full_table_name for cls in (L, D, E, E.F, E.G, E.H, E.M, G))
+
+
+def test_erd(schema_simp):
+ assert dj.diagram.diagram_active, "Failed to import networkx and pydot"
+ erd = dj.Diagram(schema_simp, context=LOCALS_SIMPLE)
+ graph = erd._make_graph()
+ assert set(cls.__name__ for cls in (A, B, D, E, L)).issubset(graph.nodes())
+
+
+def test_diagram_algebra(schema_simp):
+ """Test Diagram algebra operations (+, -, *)."""
+ diag0 = dj.Diagram(B)
+ diag1 = diag0 + 3
+ diag2 = dj.Diagram(E) - 3
+ diag3 = diag1 * diag2
+ diag4 = (diag0 + E).add_parts() - B - E
+ assert diag0.nodes_to_show == set(cls.full_table_name for cls in [B])
+ assert diag1.nodes_to_show == set(cls.full_table_name for cls in (B, B.C, E, E.F, E.G, E.H, E.M, G))
+ assert diag2.nodes_to_show == set(cls.full_table_name for cls in (A, B, D, E, L))
+ assert diag3.nodes_to_show == set(cls.full_table_name for cls in (B, E))
+ assert diag4.nodes_to_show == set(cls.full_table_name for cls in (B.C, E.F, E.G, E.H, E.M))
+
+
+def test_repr_svg(schema_adv):
+ erd = dj.Diagram(schema_adv, context=dict())
+ svg = erd._repr_svg_()
+ assert svg.startswith("