## 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.
+or with Conda:
-Since this is a big change, we chose to disable full blob support by default as a temporary precaution, which will be removed in version 0.13.
-
-You may enable it 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` (default),
-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.2019-09-26T19-42-35Z
-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)
-* 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` |
+## Example Pipeline
+
-### Launch Docker Terminal
-* Shell into `datajoint-python_app_1` i.e. `docker exec -it datajoint-python_app_1 sh`
+**Cite DataJoint:** [Yatsenko et al., 2026](https://arxiv.org/abs/2602.16585) — RRID: [SCR_014543](https://scicrunch.org/resolver/SCR_014543)
+## Resources
-### 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`
+- **[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`
\ No newline at end of file
+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/__init__.py b/datajoint/__init__.py
deleted file mode 100644
index 6d5031fde..000000000
--- a/datajoint/__init__.py
+++ /dev/null
@@ -1,48 +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__ = "February 7, 2019"
-__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']
-
-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 .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 1ecd2f8a3..000000000
--- a/datajoint/admin.py
+++ /dev/null
@@ -1,96 +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 = 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 = 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 efa95014e..000000000
--- a/datajoint/attribute_adapter.py
+++ /dev/null
@@ -1,52 +0,0 @@
-import re
-from .errors import DataJointError, _support_adapted_types
-
-
-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 object: 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]
- except KeyError:
- raise DataJointError(
- "Attribute adapter '{adapter_name}' is not defined.".format(adapter_name=adapter_name)) from None
- 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 22f945bd7..000000000
--- a/datajoint/autopopulate.py
+++ /dev/null
@@ -1,203 +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
-from .table import FreeTable
-import signal
-
-# noinspection PyExceptionInherit,PyCallingNonCallable
-
-logger = logging.getLogger(__name__)
-
-
-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 relation whose primary key values are passed, sequentially, to the
- ``make`` method when populate() is called.
- The default value is the join of the parent relations.
- Users may override to change the granularity or the scope of populate() calls.
- """
- def parent_gen(self):
- if self.target.full_table_name not in self.connection.dependencies:
- self.connection.dependencies.load()
- for parent_name, fk_props in self.target.parents(primary=True).items():
- if not parent_name.isdigit(): # simple foreign key
- yield FreeTable(self.connection, parent_name).proj()
- else:
- grandparent = list(self.connection.dependencies.in_edges(parent_name))[0][0]
- yield FreeTable(self.connection, grandparent).proj(**{
- attr: ref for attr, ref in fk_props['attr_map'].items() if ref != attr})
-
- if self._key_source is None:
- parents = parent_gen(self)
- try:
- self._key_source = next(parents)
- except StopIteration:
- raise DataJointError('A relation must have primary dependencies for auto-populate to work') from None
- for q in parents:
- self._key_source *= q
- return self._key_source
-
- def make(self, key):
- """
- Derived classes must implement method `make` that fetches data from tables that are
- above them in the dependency hierarchy, restricting by the given key, computes dependent
- attributes, and inserts the new tuples into self.
- """
- raise NotImplementedError('Subclasses of AutoPopulate must implement the method `make`')
-
- @property
- def target(self):
- """
- relation to be populated.
- Typically, AutoPopulate are mixed into a Relation object 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
- """
- 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')
- # check if target lacks any attributes from the primary key of key_source
- try:
- 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):
- """
- rel.populate() calls rel.make(key) for every primary key in self.key_source
- for which there is not already a tuple in rel.
- :param restrictions: a list of restrictions each restrict (rel.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, reserves job to populate in asynchronous fashion
- :param order: "original"|"reverse"|"random" - the order of execution
- :param display_progress: if True, report progress_bar
- :param limit: if not None, checks at most that many keys
- :param max_calls: if not None, populates at max that many keys
- """
- 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))
- error_list = [] if suppress_errors else None
- jobs = self.connection.schemas[self.target.database].jobs if reserve_jobs else None
-
- # define and setup 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)
-
- call_count = 0
- logger.info('Found %d keys to populate' % len(keys))
-
- make = self._make_tuples if hasattr(self, '_make_tuples') else self.make
-
- for key in (tqdm(keys) if display_progress else keys):
- if max_calls is not None and call_count >= max_calls:
- break
- if not reserve_jobs 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 reserve_jobs:
- jobs.complete(self.target.table_name, self._job_key(key))
- else:
- logger.info('Populating: ' + str(key))
- call_count += 1
- self.__class__._allow_insert = True
- try:
- make(dict(key))
- 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 reserve_jobs:
- # 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)
- error_list.append((key, error if return_exception_objects else error_message))
- else:
- self.connection.commit_transaction()
- if reserve_jobs:
- jobs.complete(self.target.table_name, self._job_key(key))
- finally:
- self.__class__._allow_insert = False
-
- # place back the original signal handler
- if reserve_jobs:
- signal.signal(signal.SIGTERM, old_handler)
- return error_list
-
- def progress(self, *restrictions, display=True):
- """
- report progress of populating the table
- :return: remaining, total -- 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 04750ba00..000000000
--- a/datajoint/blob.py
+++ /dev/null
@@ -1,467 +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 .utils import OrderedDict
-from .settings import config
-
-
-mxClassID = OrderedDict((
- # 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
-
-
-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('uint64')
- 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, (np.bool, np.bool_)):
- 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('uint64'))
- shape = self.read_value('uint64', 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 OrderedDict((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('uint64'))) for _ in range(n_fields))
- for __ in range(n_elem)]
- data = np.array(raw_data, dtype=list(zip(field_names, repeat(np.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='uint64', count=1):
- 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/connection.py b/datajoint/connection.py
deleted file mode 100644
index 09ff16a64..000000000
--- a/datajoint/connection.py
+++ /dev/null
@@ -1,291 +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
-
-from .settings import config
-from . import errors
-from .dependencies import Dependencies
-
-# client errors to catch
-client_errors = (client.err.InterfaceError, client.err.DatabaseError)
-
-
-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
- """
- # Loss of connection errors
- if isinstance(client_error, client.err.InterfaceError) and client_error.args[0] == "(0, '')":
- return errors.LostConnectionError('Server connection lost due to an interface error.', *client_error.args[1:])
- disconnect_codes = {
- 2006: "Connection timed out",
- 2013: "Server connection lost"}
- if isinstance(client_error, client.err.OperationalError) and client_error.args[0] in disconnect_codes:
- return errors.LostConnectionError(disconnect_codes[client_error.args[0]], *client_error.args[1:])
- # Access errors
- if isinstance(client_error, client.err.OperationalError) and client_error.args[0] in (1044, 1142):
- return errors.AccessError('Insufficient privileges.', client_error.args[1], query)
- # Integrity errors
- if isinstance(client_error, client.err.IntegrityError) and client_error.args[0] == 1062:
- return errors.DuplicateError(*client_error.args[1:])
- if isinstance(client_error, client.err.IntegrityError) and client_error.args[0] == 1452:
- return errors.IntegrityError(*client_error.args[1:])
- # Syntax errors
- if isinstance(client_error, client.err.ProgrammingError) and client_error.args[0] == 1064:
- return errors.QuerySyntaxError(client_error.args[1], query)
- # Existence errors
- if isinstance(client_error, client.err.ProgrammingError) and client_error.args[0] == 1146:
- return errors.MissingTableError(client_error.args[1], query)
- if isinstance(client_error, client.err.InternalError) and client_error.args[0] == 1364:
- return errors.MissingAttributeError(*client_error.args[1:])
- if isinstance(client_error, client.err.InternalError) and client_error.args[0] == 1054:
- return errors.UnknownAttributeError(*client_error.args[1:])
- # all the other errors are re-raised in original form
- return client_error
-
-
-logger = logging.getLogger(__name__)
-
-
-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 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):
- 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.init_fun = init_fun
- print("Connecting {user}@{host}:{port}".format(**self.conn_info))
- self._conn = None
- self.connect()
- 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.ConnectionError('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):
- """
- Connects 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",
- charset=config['connection.charset'],
- **{k: v for k, v in self.conn_info.items()
- if k != 'ssl_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",
- charset=config['connection.charset'],
- **{k: v for k, v in self.conn_info.items()
- if not(k == 'ssl_input' or
- k == 'ssl' and self.conn_info['ssl_input'] is None)})
- self._conn.autocommit(True)
-
- def close(self):
- self._conn.close()
-
- def register(self, schema):
- self.schemas[schema.database] = schema
-
- def ping(self):
- """
- Pings the connection. Raises an exception if the connection is closed.
- """
- self._conn.ping(reconnect=False)
-
- @property
- def is_connected(self):
- """
- Returns 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, cursor_class, 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_errors as err:
- raise translate_query_error(err, query) from None
-
- 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
- """
- if reconnect is None:
- reconnect = config['database.reconnect']
- logger.debug("Executing SQL:" + query[0:300])
- 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, cursor_class, suppress_warnings)
- except errors.LostConnectionError:
- if not reconnect:
- raise
- warnings.warn("MySQL server has gone away. Reconnecting to the server.")
- self.connect()
- if self._in_transaction:
- self.cancel_transaction()
- raise errors.LostConnectionError("Connection was lost during a transaction.") from None
- logger.debug("Re-executing")
- cursor = self._conn.cursor(cursor=cursor_class)
- self._execute_query(cursor, query, args, cursor_class, suppress_warnings)
- 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 df643dc8a..000000000
--- a/datajoint/declare.py
+++ /dev/null
@@ -1,467 +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
-from .errors import DataJointError, _support_filepath_types, FILEPATH_FEATURE_SWITCH
-from .attribute_adapter import get_adapter
-
-from .utils import OrderedDict
-
-UUID_DATA_TYPE = 'binary(16)'
-MAX_TABLE_NAME_LENGTH = 64
-CONSTANT_LITERALS = {'CURRENT_TIMESTAMP'} # 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)) from None
-
-
-logger = logging.getLogger(__name__)
-
-
-def build_foreign_key_parser_old():
- # old-style foreign key parser. Superceded 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 Projection
-
- 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)) from None
- 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:
- 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 supported type
- if (not isinstance(ref, (Table, Projection)) or len(ref.restriction) or
- (isinstance(ref, Projection) and (not isinstance(ref._arg, Table) or len(ref._arg.restriction)))):
- 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
- base = ref._arg if isinstance(ref, Projection) else ref # base reference table
- for attr, ref_attr in zip(ref.primary_key, base.primary_key):
- if attr not in attributes:
- attributes.append(attr)
- if primary_key is not None:
- primary_key.append(attr)
- attr_sql.append(
- base.heading[ref_attr].sql.replace(ref_attr, attr, 1).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(base.primary_key),
- ref=base.full_table_name))
-
- # 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 = OrderedDict((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])) from None
- 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 e2e901b6e..000000000
--- a/datajoint/dependencies.py
+++ /dev/null
@@ -1,122 +0,0 @@
-import networkx as nx
-import itertools
-from collections import defaultdict, OrderedDict
-from .errors import DataJointError
-
-
-class Dependencies(nx.DiGraph):
- """
- The graph of dependencies (foreign keys) between loaded tables.
-
- Note: the 'connnection' argument should normally be supplied;
- Empty use is permitted to facilliate 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()
- super().__init__(self)
-
- def load(self):
- """
- 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
- 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 = 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=OrderedDict()))
- 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')
-
- 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
- """
- 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
- """
- 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.
- """
- nodes = self.subgraph(
- nx.algorithms.dag.descendants(self, full_table_name))
-
- return [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.
- """
- nodes = self.subgraph(
- nx.algorithms.dag.ancestors(self, full_table_name))
- return [full_table_name] + list(reversed(list(
- nx.algorithms.dag.topological_sort(nodes))))
diff --git a/datajoint/diagram.py b/datajoint/diagram.py
deleted file mode 100644
index 45fe95ea1..000000000
--- a/datajoint/diagram.py
+++ /dev/null
@@ -1,365 +0,0 @@
-import networkx as nx
-import re
-import functools
-import io
-import warnings
-import inspect
-from .table import Table
-
-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
- """
-
- def _unite(lst):
- """
- reorder list so that parts immediately follow their masters without breaking the topological order.
- Without this correction, simple topological sort may insert other descendants between master and parts
- :example:
- _unite(['a', 'a__q', 'b', 'c', 'c__q', 'b__q', 'd', 'a__r'])
- -> ['a', 'a__q', 'a__r', 'b', 'b__q', 'c', 'c__q', 'd']
- """
- if len(lst) <= 2:
- return lst
- el = lst.pop()
- lst = _unite(lst)
- if '__' in el:
- master = el.split('__')[0]
- if not lst[-1].startswith(master):
- return _unite(lst[:-1] + [el, lst[-1]])
- return lst + [el]
-
- return _unite(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 http://www.graphviz.org/content/attrs
- 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 93a4e01a9..000000000
--- a/datajoint/errors.py
+++ /dev/null
@@ -1,127 +0,0 @@
-"""
-Exception classes for the DataJoint library
-"""
-
-import os
-
-
-# --- Top Level ---
-class DataJointError(Exception):
- """
- Base class for errors specific to DataJoint internal operation.
- """
- 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 28ff4a6f4..000000000
--- a/datajoint/expression.py
+++ /dev/null
@@ -1,949 +0,0 @@
-import collections
-from itertools import count
-import logging
-import inspect
-import numpy as np
-import re
-import datetime
-import decimal
-import pandas
-import uuid
-import binascii # for Python 3.4 compatibility
-from .settings import config
-from .errors import DataJointError
-from .fetch import Fetch, Fetch1
-
-logger = logging.getLogger(__name__)
-
-
-def assert_join_compatibility(rel1, rel2):
- """
- Determine if expressions rel1 and rel2 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 rel1: A QueryExpression object
- :param rel2: A QueryExpression object
- """
- for rel in (rel1, rel2):
- if not isinstance(rel, (U, QueryExpression)):
- raise DataJointError('Object %r is not a QueryExpression and cannot be joined.' % rel)
- if not isinstance(rel1, U) and not isinstance(rel2, U): # dj.U is always compatible
- try:
- raise DataJointError("Cannot join query expressions on dependent attribute `%s`" % next(r for r in set(
- rel1.heading.secondary_attributes).intersection(rel2.heading.secondary_attributes)))
- except StopIteration:
- pass
-
-
-class AndList(list):
- """
- A list of restrictions to by applied to a query expression. The restrictions are AND-ed.
- Each restriction can be a list or set or a query expression whose elements are OR-ed.
- But the elements that are lists can contain other AndLists.
-
- Example:
- rel2 = rel & dj.AndList((cond1, cond2, cond3))
- is equivalent to
- rel2 = rel & cond1 & cond2 & cond3
- """
-
- def append(self, restriction):
- if isinstance(restriction, AndList):
- # extend to reduce nesting
- self.extend(restriction)
- else:
- super().append(restriction)
-
-
-def is_true(restriction):
- return restriction is True or isinstance(restriction, AndList) and not len(restriction)
-
-
-class QueryExpression:
- """
- QueryExpression implements query operators to derive new entity sets from its inputs.
- When fetching data from the database, the expression is compiled into an SQL expression.
- QueryExpression operators are restrict, join, proj, aggr, and union.
- """
-
- def __init__(self, arg=None):
- if arg is None: # initialize
- # initialize
- self._restriction = AndList()
- self._distinct = False
- self._heading = None
- else: # copy
- assert isinstance(arg, QueryExpression), 'Cannot make QueryExpression from %s' % arg.__class__.__name__
- self._restriction = AndList(arg._restriction)
- self._distinct = arg.distinct
- self._heading = arg._heading
-
- @classmethod
- def create(cls): # pragma: no cover
- """abstract method for creating an instance"""
- assert False, "Abstract method `create` must be overridden in subclass."
-
- @property
- def connection(self):
- """
- :return: the dj.Connection object
- """
- return self._connection
-
- @property
- def heading(self):
- """
- :return: the dj.Heading object for the query expression
- """
- return self._heading
-
- @property
- def distinct(self):
- """
- :return: True if the DISTINCT modifier is required to make valid result
- """
- return self._distinct
-
- @property
- def restriction(self):
- """
- :return: The AndList of restrictions applied to input to produce the result.
- """
- assert isinstance(self._restriction, AndList)
- return self._restriction
-
- @property
- def primary_key(self):
- return self.heading.primary_key
-
- def _make_condition(self, arg):
- """
- Translate the input arg into the equivalent SQL condition (a string)
- :param arg: any valid restriction object.
- :return: an SQL condition string or a boolean value.
- """
- def prep_value(k, v):
- """prepare value v for inclusion as a string in an SQL condition"""
- if self.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)) from None
- return "X'%s'" % binascii.hexlify(v.bytes).decode()
- if isinstance(v, (datetime.date, datetime.datetime, datetime.time, decimal.Decimal)):
- return '"%s"' % v
- return '%r' % v
-
- negate = False
- while isinstance(arg, Not):
- negate = not negate
- arg = arg.restriction
- template = "NOT (%s)" if negate else "%s"
-
- # restrict by string
- if isinstance(arg, str):
- return template % arg.strip().replace("%", "%%") # escape % in strings, see issue #376
-
- # restrict by AndList
- if isinstance(arg, AndList):
- # omit all conditions that evaluate to True
- items = [item for item in (self._make_condition(i) for i in arg) 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(arg, U):
- return not negate
-
- # restrict by boolean
- if isinstance(arg, bool):
- return negate != arg
-
- # restrict by a mapping such as a dict -- convert to an AndList of string equality conditions
- if isinstance(arg, collections.abc.Mapping):
- return template % self._make_condition(
- AndList('`%s`=%s' % (k, prep_value(k, v)) for k, v in arg.items() if k in self.heading))
-
- # restrict by a numpy record -- convert to an AndList of string equality conditions
- if isinstance(arg, np.void):
- return template % self._make_condition(
- AndList(('`%s`=%s' % (k, prep_value(k, arg[k])) for k in arg.dtype.fields if k in self.heading)))
-
- # restrict by a QueryExpression subclass -- triggers instantiation
- if inspect.isclass(arg) and issubclass(arg, QueryExpression):
- arg = arg()
-
- # restrict by another expression (aka semijoin and antijoin)
- if isinstance(arg, QueryExpression):
- assert_join_compatibility(self, arg)
- common_attributes = [q for q in arg.heading.names if q in self.heading.names]
- return (
- # without common attributes, any non-empty set matches everything
- (not negate if arg else negate) if not common_attributes
- else '({fields}) {not_}in ({subquery})'.format(
- fields='`' + '`,`'.join(common_attributes) + '`',
- not_="not " if negate else "",
- subquery=arg.make_sql(common_attributes)))
-
- # restrict by pandas.DataFrames
- if isinstance(arg, pandas.DataFrame):
- arg = arg.to_records() # convert to np.recarray
-
- # if iterable (but not a string, a QueryExpression, or an AndList), treat as an OrList
- try:
- or_list = [self._make_condition(q) for q in arg]
- except TypeError:
- raise DataJointError('Invalid restriction type %r' % arg)
- else:
- or_list = [item for item in or_list if item is not False] # ignore all False conditions
- if any(item is True for item in or_list): # if any item is True, the whole thing is True
- return not negate
- return template % ('(%s)' % ' OR '.join(or_list)) if or_list else negate # an empty or list is False
-
- @property
- def where_clause(self):
- """
- convert self.restriction to the SQL WHERE clause
- """
- cond = self._make_condition(self.restriction)
- return '' if cond is True else ' WHERE %s' % cond
-
- def get_select_fields(self, select_fields=None):
- """
- :return: string specifying the attributes to return
- """
- return self.heading.as_sql if select_fields is None else self.heading.project(select_fields).as_sql
-
- # --------- query operators -----------
-
- def __mul__(self, other):
- """
- natural join of query expressions `self` and `other`
- """
- return other * self if isinstance(other, U) else Join.create(self, other)
-
- def __add__(self, other):
- """
- union of two entity sets `self` and `other`
- """
- 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.
- Thus self.proj() leaves only the primary key attributes of self.
- self.proj(a='id') renames the attribute 'id' into 'a' and includes 'a' in the projection.
- self.proj(a='expr') adds a new field a with the value computed with an SQL expression.
- self.proj(a='(id)') adds a new computed field named 'a' that has the same value as id
- Each attribute can only be used once in attributes or named_attributes.
- If the attribute list contains an Ellipsis ..., then all secondary attributes are included
- If an entry of the attribute list starts with a dash, e.g. '-attr', then the secondary attribute
- attr will be excluded, if already present but ignored if not found.
- """
- return Projection.create(self, attributes, named_attributes)
-
- def aggr(self, group, *attributes, keep_all_rows=False, **named_attributes):
- """
- Aggregation/projection operator
- :param group: an entity set whose entities will be grouped per entity of `self`
- :param attributes: attributes of self to include in the result
- :param keep_all_rows: True = preserve the number of elements in the result (equivalent of LEFT JOIN in SQL)
- :param named_attributes: renamings and computations on attributes of self and group
- :return: an entity set representing the result of the aggregation/projection operator of entities from `group`
- per entity of `self`
- """
- return GroupBy.create(self, group, keep_all_rows=keep_all_rows,
- attributes=attributes, named_attributes=named_attributes)
-
- aggregate = aggr # aliased name for aggr
-
- def __iand__(self, restriction):
- """
- in-place restriction.
- A subquery is created if the argument has renamed attributes. Then the restriction is not in place.
-
- See QueryExpression.restrict for more detail.
- """
- if is_true(restriction):
- return self
- return (Subquery.create(self) if self.heading.expressions else self).restrict(restriction)
-
- def __and__(self, restriction):
- """
- Restriction operator
- :return: a restricted copy of the input argument
- See QueryExpression.restrict for more detail.
- """
- return (Subquery.create(self) # the HAVING clause in GroupBy can handle renamed attributes but WHERE cannot
- if not(is_true(restriction)) and self.heading.expressions and not isinstance(self, GroupBy)
- else self.__class__(self)).restrict(restriction)
-
- def __isub__(self, restriction):
- """
- in-place inverted restriction aka antijoin
-
- See QueryExpression.restrict for more detail.
- """
- return self.restrict(Not(restriction))
-
- def __sub__(self, restriction):
- """
- inverted restriction aka antijoin
- :return: a restricted copy of the argument
-
- See QueryExpression.restrict for more detail.
- """
- return self & Not(restriction)
-
- def restrict(self, restriction):
- """
- In-place restriction. Restricts the result to a specified subset of the input.
- rel.restrict(restriction) is equivalent to rel = rel & restriction or rel &= restriction
- rel.restrict(Not(restriction)) is equivalent to rel = rel - restriction or 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.
- """
- assert is_true(restriction) or not self.heading.expressions or isinstance(self, GroupBy), \
- "Cannot restrict a projection with renamed attributes in place."
- self.restriction.append(restriction)
- return self
-
- @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 attributes_in_restriction(self):
- """
- :return: list of attributes that are probably used in the restriction.
- The function errs on the side of false positives.
- For example, if the restriction is "val='id'", then the attribute 'id' would be flagged.
- This is used internally for optimizing SQL statements.
- """
- return set(name for name in self.heading.names
- if re.search(r'\b' + name + r'\b', self.where_clause))
-
- def __repr__(self):
- return super().__repr__() if config['loglevel'].lower() == 'debug' else self.preview()
-
- def preview(self, limit=None, width=None):
- """
- returns a preview of the contents of the query.
- """
- heading = self.heading
- rel = self.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_(self):
- heading = self.heading
- rel = self.proj(*heading.non_blobs)
- info = heading.table_info
- 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 self.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 '')
-
- def make_sql(self, select_fields=None):
- return 'SELECT {fields} FROM {from_}{where}'.format(
- fields=("DISTINCT " if self.distinct else "") + self.get_select_fields(select_fields),
- from_=self.from_clause,
- where=self.where_clause)
-
- def __len__(self):
- """
- number of elements in the result set.
- """
- return self.connection.query(
- 'SELECT count({count}) FROM {from_}{where}'.format(
- count='DISTINCT `{pk}`'.format(pk='`,`'.join(self.primary_key)) if self.distinct and self.primary_key else '*',
- from_=self.from_clause,
- where=self.where_clause)).fetchone()[0]
-
- def __bool__(self):
- """
- :return: True if the result is not empty. Equivalent to len(rel)>0 but may be more efficient.
- """
- return len(self) > 0
-
- def __contains__(self, item):
- """
- returns True if item is found in the .
- :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):
- 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):
- try:
- key = self._iter_keys.pop(0)
- except AttributeError:
- # self._iter_keys is missing because __iter__ has not been called.
- raise TypeError("'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)
-
-
-class Not:
- """
- invert restriction
- """
- def __init__(self, restriction):
- self.restriction = restriction
-
-
-class Join(QueryExpression):
- """
- Join operator.
- Join is a private DataJoint class not exposed to users. See QueryExpression.__mul__ for details.
- """
-
- def __init__(self, arg=None):
- super().__init__(arg)
- if arg is not None:
- assert isinstance(arg, Join), "Join copy constructor requires a Join object"
- self._connection = arg.connection
- self._heading = arg.heading
- self._arg1 = arg._arg1
- self._arg2 = arg._arg2
- self._left = arg._left
-
- @classmethod
- def create(cls, arg1, arg2, keep_all_rows=False):
- obj = cls()
- if inspect.isclass(arg2) and issubclass(arg2, QueryExpression):
- arg2 = arg2() # instantiate if joining with a class
- assert_join_compatibility(arg1, arg2)
- if arg1.connection != arg2.connection:
- raise DataJointError("Cannot join query expressions from different connections.")
- obj._connection = arg1.connection
- obj._arg1 = cls.make_argument_subquery(arg1)
- obj._arg2 = cls.make_argument_subquery(arg2)
- obj._distinct = obj._arg1.distinct or obj._arg2.distinct
- obj._left = keep_all_rows
- obj._heading = obj._arg1.heading.join(obj._arg2.heading)
- obj.restrict(obj._arg1.restriction)
- obj.restrict(obj._arg2.restriction)
- return obj
-
- @staticmethod
- def make_argument_subquery(arg):
- """
- Decide when a Join argument needs to be wrapped in a subquery
- """
- return Subquery.create(arg) if isinstance(arg, (GroupBy, Projection)) or arg.restriction else arg
-
- @property
- def from_clause(self):
- return '{from1} NATURAL{left} JOIN {from2}'.format(
- from1=self._arg1.from_clause,
- left=" LEFT" if self._left else "",
- from2=self._arg2.from_clause)
-
-
-class Union(QueryExpression):
- """
- Union is the private DataJoint class that implements the union operator.
- """
-
- __count = count()
-
- def __init__(self, arg=None):
- super().__init__(arg)
- if arg is not None:
- assert isinstance(arg, Union), "Union copy constructore requires a Union object"
- self._connection = arg.connection
- self._heading = arg.heading
- self._arg1 = arg._arg1
- self._arg2 = arg._arg2
-
- @classmethod
- def create(cls, arg1, arg2):
- obj = cls()
- if inspect.isclass(arg2) and issubclass(arg2, QueryExpression):
- arg2 = arg2() # instantiate if a class
- if not isinstance(arg1, QueryExpression) or not isinstance(arg2, QueryExpression):
- raise DataJointError('an 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.heading.names) != set(arg2.heading.names):
- raise DataJointError('Union requires the same attributes in both arguments')
- if any(not v.in_key for v in arg1.heading.attributes.values()) or \
- all(not v.in_key for v in arg2.heading.attributes.values()):
- raise DataJointError('Union arguments must not have any secondary attributes.')
- obj._connection = arg1.connection
- obj._heading = arg1.heading
- obj._arg1 = arg1
- obj._arg2 = arg2
- return obj
-
- def make_sql(self, select_fields=None):
- return "SELECT {_fields} FROM {_from}{_where}".format(
- _fields=self.get_select_fields(select_fields),
- _from=self.from_clause,
- _where=self.where_clause)
-
- @property
- def from_clause(self):
- return ("(SELECT {fields} FROM {from1}{where1} UNION SELECT {fields} FROM {from2}{where2}) as `_u%x`".format(
- fields=self.get_select_fields(None), from1=self._arg1.from_clause,
- where1=self._arg1.where_clause,
- from2=self._arg2.from_clause,
- where2=self._arg2.where_clause)) % next(self.__count)
-
-
-class Projection(QueryExpression):
- """
- Projection is a private DataJoint class that implements the projection operator.
- See QueryExpression.proj() for user interface.
- """
-
- def __init__(self, arg=None):
- super().__init__(arg)
- if arg is not None:
- assert isinstance(arg, Projection), "Projection copy constructor requires a Projection object."
- self._connection = arg.connection
- self._heading = arg.heading
- self._arg = arg._arg
-
- @staticmethod
- def prepare_attribute_lists(arg, attributes, named_attributes):
- # check that all attributes are strings
- has_ellipsis = Ellipsis in attributes
- attributes = [a for a in attributes if a is not Ellipsis]
- try:
- raise DataJointError("Attribute names must be strings or ..., got %s" % next(
- type(a) for a in attributes if not isinstance(a, str)))
- except StopIteration:
- pass
- named_attributes = {k: v.strip() for k, v in named_attributes.items()} # clean up
- excluded_attributes = set(a.lstrip('-').strip() for a in attributes if a.startswith('-'))
- if has_ellipsis:
- included_already = set(named_attributes.values())
- attributes = [a for a in arg.heading.secondary_attributes if a not in included_already]
- # process excluded attributes
- attributes = [a for a in attributes if a not in excluded_attributes]
- return attributes, named_attributes
-
- @classmethod
- def create(cls, arg, attributes, named_attributes, include_primary_key=True):
- """
- :param arg: The QueryExpression to be projected
- :param attributes: attributes to select
- :param named_attributes: new attributes to create by renaming or computing
- :param include_primary_key: True if the primary key must be included even if it's not in attributes.
- :return: the resulting Projection object
- """
- obj = cls()
- obj._connection = arg.connection
-
- if inspect.isclass(arg) and issubclass(arg, QueryExpression):
- arg = arg() # instantiate if a class
-
- attributes, named_attributes = Projection.prepare_attribute_lists(arg, attributes, named_attributes)
- obj._distinct = arg.distinct
-
- if include_primary_key: # include primary key of the QueryExpression
- attributes = (list(a for a in arg.primary_key if a not in named_attributes.values()) +
- list(a for a in attributes if a not in arg.primary_key))
- else:
- # make distinct if the primary key is not completely selected
- obj._distinct = obj._distinct or not set(arg.primary_key).issubset(
- set(attributes) | set(named_attributes.values()))
- if obj._distinct or cls._need_subquery(arg, attributes, named_attributes):
- obj._arg = Subquery.create(arg)
- obj._heading = obj._arg.heading.project(attributes, named_attributes)
- if not include_primary_key:
- obj._heading = obj._heading.extend_primary_key(attributes)
- else:
- obj._arg = arg
- obj._heading = obj._arg.heading.project(attributes, named_attributes)
- obj &= arg.restriction # copy restriction when no subquery
- return obj
-
- @staticmethod
- def _need_subquery(arg, attributes, named_attributes):
- """
- Decide whether the projection argument needs to be wrapped in a subquery
- """
- if arg.heading.expressions or arg.distinct: # argument has any renamed (computed) attributes
- return True
- restricting_attributes = arg.attributes_in_restriction()
- return (not restricting_attributes.issubset(attributes) or # if any restricting attribute is projected out or
- any(v.strip() in restricting_attributes for v in named_attributes.values())) # or renamed
-
- @property
- def from_clause(self):
- return self._arg.from_clause
-
-
-class GroupBy(QueryExpression):
- """
- GroupBy(rel, comp1='expr1', ..., compn='exprn') yields an entity set with the primary key specified by rel.heading.
- The computed arguments comp1, ..., compn use aggregation operators on the attributes of rel.
- GroupBy is used QueryExpression.aggr and U.aggr.
- GroupBy is a private class in DataJoint, not exposed to users.
- """
-
- def __init__(self, arg=None):
- super().__init__(arg)
- if arg is not None:
- # copy constructor
- assert isinstance(arg, GroupBy), "GroupBy copy constructor requires a GroupBy object"
- self._connection = arg.connection
- self._heading = arg.heading
- self._arg = arg._arg
- self._keep_all_rows = arg._keep_all_rows
-
- @classmethod
- def create(cls, arg, group, attributes, named_attributes, keep_all_rows=False):
- if inspect.isclass(group) and issubclass(group, QueryExpression):
- group = group() # instantiate if a class
- attributes, named_attributes = Projection.prepare_attribute_lists(arg, attributes, named_attributes)
- assert_join_compatibility(arg, group)
- obj = cls()
- obj._keep_all_rows = keep_all_rows
- obj._arg = (Join.make_argument_subquery(group) if isinstance(arg, U)
- else Join.create(arg, group, keep_all_rows=keep_all_rows))
- obj._connection = obj._arg.connection
- # always include primary key of arg
- attributes = (list(a for a in arg.primary_key if a not in named_attributes.values()) +
- list(a for a in attributes if a not in arg.primary_key))
- obj._heading = obj._arg.heading.project(
- attributes, named_attributes, force_primary_key=arg.primary_key)
- return obj
-
- def make_sql(self, select_fields=None):
- return 'SELECT {fields} FROM {from_}{where} GROUP BY `{group_by}`{having}'.format(
- fields=self.get_select_fields(select_fields),
- from_=self._arg.from_clause,
- where=self._arg.where_clause,
- group_by='`,`'.join(self.primary_key),
- having=re.sub(r'^ WHERE', ' HAVING', self.where_clause))
-
- def __len__(self):
- return len(Subquery.create(self))
-
-
-class Subquery(QueryExpression):
- """
- A Subquery encapsulates its argument in a SELECT statement, enabling its use as a subquery.
- The attribute list and the WHERE clause are resolved. Thus, a subquery no longer has any renamed attributes.
- A subquery of a subquery is a just a copy of the subquery with no change in SQL.
- """
- __count = count()
-
- def __init__(self, arg=None):
- super().__init__(arg)
- if arg is not None:
- # copy constructor
- assert isinstance(arg, Subquery)
- self._connection = arg.connection
- self._heading = arg.heading
- self._arg = arg._arg
-
- @classmethod
- def create(cls, arg):
- """
- construct a subquery from arg
- """
- obj = cls()
- obj._connection = arg.connection
- obj._heading = arg.heading.make_subquery_heading()
- obj._arg = arg
- return obj
-
- @property
- def from_clause(self):
- return '(' + self._arg.make_sql() + ') as `_s%x`' % next(self.__count)
-
- def get_select_fields(self, select_fields=None):
- return '*' if select_fields is None else self.heading.project(select_fields).as_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, query_expression):
- if inspect.isclass(query_expression) and issubclass(query_expression, QueryExpression):
- query_expression = query_expression() # instantiate if a class
- if not isinstance(query_expression, QueryExpression):
- raise DataJointError('Set U can only be restricted with a QueryExpression.')
- return Projection.create(query_expression, attributes=self.primary_key,
- named_attributes=dict(), include_primary_key=False)
-
- def __mul__(self, query_expression):
- """
- 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 query_expression: a query expression to join with.
- :return: a copy of the other query expression with the primary key extended.
- """
- if inspect.isclass(query_expression) and issubclass(query_expression, QueryExpression):
- query_expression = query_expression() # instantiate if a class
- if not isinstance(query_expression, QueryExpression):
- raise DataJointError('Set U can only be joined with a QueryExpression.')
- copy = query_expression.__class__(query_expression) # invoke copy constructor
- copy._heading = copy.heading.extend_primary_key(self.primary_key)
- return copy
-
- 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 self.primary_key:
- return GroupBy.create(
- self, group=group, keep_all_rows=False, attributes=(), named_attributes=named_attributes)
- return Projection.create(group, attributes=(), named_attributes=named_attributes, include_primary_key=False)
-
- aggregate = aggr # alias for aggr
diff --git a/datajoint/external.py b/datajoint/external.py
deleted file mode 100644
index 58b64a817..000000000
--- a/datajoint/external.py
+++ /dev/null
@@ -1,388 +0,0 @@
-from pathlib import Path, PurePosixPath, PureWindowsPath
-from collections 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
-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=None, database=None):
-
- # copy constructor -- all QueryExpressions must provide
- if isinstance(connection, ExternalTable):
- other = connection # the first argument is interpreted as the other object
- super().__init__(other)
- self.store = other.store
- self.spec = other.spec
- self.database = other.database
- self._connection = other._connection
- return
-
- # nominal constructor
- super().__init__()
- self.store = store
- self.spec = config.get_store_spec(store)
- self.database = database
- self._connection = connection
- 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':
- Path(external_path).unlink()
-
- 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)) from None
- 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 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 - ["hash IN (SELECT `{column_name}` FROM {referencing_table})".format(**ref)
- 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 & ["hash IN (SELECT `{column_name}` FROM {referencing_table})".format(**ref)
- for ref in self.references]
-
- def delete(self, *, delete_external_files=None, limit=None, display_progress=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 limit: (integer) limit the number of items to delete
- :param display_progress: if True, display progress as files are cleaned up
- :return: yields
- """
- 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:
- try:
- count = (self & {'hash': uuid}).delete_quick(get_count=True) # optimize
- except Exception:
- pass # if delete failed, do not remove the external file
- else:
- assert count in (0, 1)
- try:
- self._remove_external_file(external_path)
- except Exception as error:
- error_list.append((uuid, external_path, str(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 c2e6649ad..000000000
--- a/datajoint/fetch.py
+++ /dev/null
@@ -1,252 +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 OrderedDict, 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 OrderedDict(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 when fetching entire result
- # 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).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 = [OrderedDict((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 the type for blobs
- 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:
- 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 exactly one row.
- :param relation: relation the fetch object fetches data from
- """
- def __init__(self, relation):
- self._expression = relation
-
- def __call__(self, *attrs, squeeze=False, download_path='.'):
- """
- Fetches the expression results from the database when the expression is known to yield only 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 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 should only be used for relations with exactly one tuple')
- ret = OrderedDict((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)
- if len(result) != 1:
- raise DataJointError('fetch1 should only return one tuple. %d tuples were 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 d65f1f829..000000000
--- a/datajoint/hash.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import hashlib
-import uuid
-import io
-from pathlib import Path
-
-
-def hash_key_values(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 898ef5cc2..000000000
--- a/datajoint/heading.py
+++ /dev/null
@@ -1,366 +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 .utils import OrderedDict
-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, sql_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 OrderedDict((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)
-
-
-class Heading:
- """
- Local class for relations' headings.
- Heading contains the property attributes, which is an OrderedDict in which the keys are
- the attribute names and the values are Attributes.
- """
-
- def __init__(self, arg=None):
- """
- :param arg: a list of dicts with the same keys as Attribute
- """
- assert not isinstance(arg, Heading), 'Headings cannot be copied'
- self.indexes = None
- self.table_info = None
- self.attributes = None if arg is None else OrderedDict(
- (q['name'], Attribute(**q)) for q in arg)
-
- def __len__(self):
- return 0 if self.attributes is None else len(self.attributes)
-
- def __bool__(self):
- return self.attributes is not None
-
- @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 expressions(self):
- return [k for k, v in self.attributes.items() if v.sql_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
- """
- if self.attributes is None:
- return 'heading not loaded'
- in_key = True
- ret = ''
- if self.table_info:
- ret += '# ' + self.table_info['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()]))
-
- @property
- def as_sql(self):
- """
- represent heading as SQL field list
- """
- return ','.join('`%s`' % name if self.attributes[name].sql_expression is None
- else '%s as `%s`' % (self.attributes[name].sql_expression, name)
- for name in self.names)
-
- def __iter__(self):
- return iter(self.attributes)
-
- def init_from_database(self, conn, database, table_name, context):
- """
- initialize heading from a database table. The table must exist already.
- """
- 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
- else:
- raise DataJointError('The table `{database}`.`{table_name}` is not defined.'.format(
- table_name=table_name, database=database))
- self.table_info = {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, sql_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)) from None
- raise DataJointError('Unknown attribute type `{type}`'.format(**attr)) from None
- 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']:
- 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 = OrderedDict(((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 project(self, attribute_list, named_attributes=None, force_primary_key=None):
- """
- derive a new heading by selecting, renaming, or computing attributes.
- In relational algebra these operators are known as project, rename, and extend.
- :param attribute_list: the full list of existing attributes to include
- :param force_primary_key: attributes to force to be converted to primary
- :param named_attributes: dictionary of renamed attributes
- """
- try: # check for missing attributes
- raise DataJointError('Attribute `%s` is not found' % next(a for a in attribute_list if a not in self.names))
- except StopIteration:
- if named_attributes is None:
- named_attributes = {}
- if force_primary_key is None:
- force_primary_key = set()
- rename_map = {v: k for k, v in named_attributes.items() if v in self.attributes}
-
- # copied and renamed attributes
- copy_attrs = (dict(self.attributes[k].todict(),
- in_key=self.attributes[k].in_key or k in force_primary_key,
- **({'name': rename_map[k], 'sql_expression': '`%s`' % k} if k in rename_map else {}))
- for k in self.attributes if k in rename_map or k in attribute_list)
- compute_attrs = (dict(default_attribute_properties, name=new_name, sql_expression=expr)
- for new_name, expr in named_attributes.items() if expr not in rename_map)
-
- 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 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(), sql_expression=None) for v in self.attributes.values())
-
- def extend_primary_key(self, new_attributes):
- """
- Create a new heading in which the primary key also includes new_attributes.
- :param new_attributes: new attributes to be added to the primary key.
- """
- try: # check for missing attributes
- raise DataJointError('Attribute `%s` is not found' % next(a for a in new_attributes if a not in self.names))
- except StopIteration:
- return Heading(dict(v.todict(), in_key=v.in_key or v.name in new_attributes)
- for v in self.attributes.values())
diff --git a/datajoint/jobs.py b/datajoint/jobs.py
deleted file mode 100644
index d80911782..000000000
--- a/datajoint/jobs.py
+++ /dev/null
@@ -1,119 +0,0 @@
-import os
-from .hash import hash_key_values
-import platform
-from .table import Table
-from .settings import config
-from .errors import DuplicateError
-
-ERROR_MESSAGE_LENGTH = 2047
-TRUNCATION_APPENDIX = '...truncated'
-
-
-class JobTable(Table):
- """
- A base relation with no definition. Allows reserving jobs
- """
- def __init__(self, arg, database=None):
- if isinstance(arg, JobTable):
- super().__init__(arg)
- # copy constructor
- self.database = arg.database
- self._connection = arg._connection
- self._definition = arg._definition
- self._user = arg._user
- return
- super().__init__()
- self.database = database
- self._connection = arg
- 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 :blob # 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=hash_key_values(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=hash_key_values(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=hash_key_values(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 5f40a85f8..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 = 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 = 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/s3.py b/datajoint/s3.py
deleted file mode 100644
index f8e59bccf..000000000
--- a/datajoint/s3.py
+++ /dev/null
@@ -1,68 +0,0 @@
-"""
-AWS S3 operations
-"""
-from io import BytesIO
-import minio # https://docs.minio.io/docs/python-client-api-reference
-import warnings
-import uuid
-from pathlib import Path
-from . import errors
-
-
-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, **_):
- self.client = minio.Minio(endpoint, access_key=access_key, secret_key=secret_key,
- secure=secure)
- self.bucket = bucket
- if not self.client.bucket_exists(bucket):
- raise errors.BucketInaccessible('Inaccessible s3 bucket %s' % bucket) from None
-
- def put(self, name, buffer):
- return self.client.put_object(
- self.bucket, str(name), BytesIO(buffer), length=len(buffer))
-
- def fput(self, local_file, name, metadata=None):
- return self.client.fput_object(
- self.bucket, str(name), str(local_file), metadata=metadata)
-
- def get(self, name):
- try:
- return self.client.get_object(self.bucket, str(name)).data
- except minio.error.NoSuchKey:
- raise errors.MissingExternalFile('Missing s3 key %s' % name) from None
-
- def fget(self, name, local_filepath):
- """get file from object name to local filepath"""
- 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):
- try:
- self.client.stat_object(self.bucket, str(name))
- except minio.error.NoSuchKey:
- return False
- return True
-
- def get_size(self, name):
- try:
- return self.client.stat_object(self.bucket, str(name)).size
- except minio.error.NoSuchKey:
- raise errors.MissingExternalFile from None
-
- def remove_object(self, name):
- try:
- self.client.remove_object(self.bucket, str(name))
- except minio.ResponseError:
- return 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 bf3a14466..000000000
--- a/datajoint/schemas.py
+++ /dev/null
@@ -1,324 +0,0 @@
-import warnings
-import pymysql
-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
-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.
- This becomes unnecessary in Python 3.6+ as dicts became ordered.
- :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._ordered_class_members if hasattr(c, '_ordered_class_members') else 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, context=None, *, connection=None, create_schema=True, create_tables=True):
- """
- 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.
- :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.
- """
- if connection is None:
- connection = conn()
- self._log = None
-
- self.database = schema_name
- self.connection = connection
- self.context = context
- self.create_tables = create_tables
- self._jobs = None
- self.external = ExternalMapping(self)
-
- if not self.exists:
- if not create_schema:
- raise DataJointError(
- "Database named `{name}` was not defined. "
- "Set argument create_schema=True to create it.".format(name=schema_name))
- else:
- # create database
- logger.info("Creating schema `{name}`.".format(name=schema_name))
- try:
- connection.query("CREATE DATABASE `{name}`".format(name=schema_name))
- logger.info('Creating schema `{name}`.'.format(name=schema_name))
- except pymysql.OperationalError:
- raise DataJointError(
- "Schema `{name}` does not exist and could not be created. "
- "Check permissions.".format(name=schema_name))
- else:
- self.log('created')
- self.log('connect')
- connection.register(self)
-
- @property
- def log(self):
- 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
- """
- 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()
- """
- 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.process_table_class(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 pymysql.OperationalError:
- raise DataJointError("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
- """
- cur = self.connection.query("SHOW DATABASES LIKE '{database}'".format(database=self.database))
- return cur.rowcount > 0
-
- def process_table_class(self, table_class, context, assert_declared=False):
- """
- assign schema properties to the relation class and declare the table
- """
- table_class.database = self.database
- table_class._connection = self.connection
- table_class._heading = Heading()
- 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)
- else:
- instance.declare(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 __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')
- self.process_table_class(cls, context=dict(context, self=cls, **{cls.__name__: cls}))
-
- # Process part relations
- 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.process_table_class(part, context=dict(
- context, master=cls, self=part, **{cls.__name__: cls}))
- return cls
-
- @property
- def jobs(self):
- """
- schema.jobs provides a view of the job reservation table for the schema
- :return: jobs table
- """
- if self._jobs is None:
- self._jobs = JobTable(self.connection, self.database)
- return self._jobs
-
- @property
- def code(self):
- 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.
- """
-
- 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 repl(s):
- d, tab = s.group(1), s.group(2)
- return ('' if d == db else (module_lookup[d]+'.')) + to_camel_case(tab)
-
- return ('' if tier == 'Part' else '@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'`([^`]+)`.`([^`]+)`', repl,
- FreeTable(self.connection, table).describe(printout=False).replace('\n', '\n ' + indent)))
-
- diagram = Diagram(self)
- body = '\n\n\n'.join(make_class_definition(table) for table in diagram.topological_sort())
- python_code = '\n\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
- else:
- with open(python_filename, 'wt') as f:
- f.write(python_code)
-
-
-class VirtualModule(types.ModuleType):
- """
- A virtual module which will contain context for schema.
- """
- 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('SHOW SCHEMAS') if r[0] not in {'information_schema'}]
diff --git a/datajoint/settings.py b/datajoint/settings.py
deleted file mode 100644
index a58ab0f15..000000000
--- a/datajoint/settings.py
+++ /dev/null
@@ -1,233 +0,0 @@
-"""
-Settings for DataJoint.
-"""
-from contextlib import contextmanager
-import json
-import os
-import pprint
-from collections import OrderedDict
-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 = OrderedDict({
- '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': False, # 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.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)) from None
-
- 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')}
-
- 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)) from None
-
- # 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 c311d8c8f..000000000
--- a/datajoint/table.py
+++ /dev/null
@@ -1,736 +0,0 @@
-import collections
-import itertools
-import inspect
-import platform
-import numpy as np
-import pandas
-import logging
-import uuid
-from pathlib import Path
-from .settings import config
-from .declare import declare, alter
-from .expression import QueryExpression
-from . import blob
-from .utils import user_choice
-from .heading import Heading
-from .errors import DuplicateError, AccessError, DataJointError, UnknownAttributeError
-from .version import __version__ as version
-
-logger = logging.getLogger(__name__)
-
-
-class _RenameMap(tuple):
- """ for internal use """
- pass
-
-
-class Table(QueryExpression):
- """
- Table is an abstract class that represents a base relation, i.e. a table in the schema.
- To make it a concrete class, override the abstract properties specifying the connection,
- table name, database, and definition.
- A Relation implements insert and delete methods in addition to inherited relational operators.
- """
- _heading = None
- database = None
- _log_ = None
- declaration_context = None
-
- # -------------- required by QueryExpression ----------------- #
- @property
- def heading(self):
- """
- Returns the table heading. If the table is not declared, attempts to declare it and return heading.
- :return: table heading
- """
- if self._heading is None:
- self._heading = Heading() # instance-level heading
- if not self._heading and self.connection is not None: # lazy loading of heading
- self._heading.init_from_database(
- self.connection, self.database, self.table_name, self.declaration_context)
- return self._heading
-
- 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:
- if prompt:
- print('Table altered')
- self._log('Altered ' + self.full_table_name)
-
- @property
- 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):
- """
- :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 with self's foreign keys
- """
- return self.connection.dependencies.parents(self.full_table_name, primary)
-
- def children(self, primary=None):
- """
- :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 with foreign keys referencing self
- """
- return self.connection.dependencies.children(self.full_table_name, primary)
-
- def descendants(self):
- return self.connection.dependencies.descendants(self.full_table_name)
-
- def ancestors(self):
- return self.connection.dependencies.ancestors(self.full_table_name)
-
- @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 insert1(self, row, **kwargs):
- """
- Insert one data record or one Mapping (like a dict).
- :param row: a numpy record, a dict-like object, or an ordered sequence to be inserted as one row.
- For kwargs, see insert()
- """
- 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 table 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): # allow_insert is only used in AutoPopulate
- raise DataJointError(
- 'Inserts into an auto-populated table can only done inside its make method during a populate call.'
- ' To override, set keyword argument allow_direct_insert=True.')
-
- heading = self.heading
- if inspect.isclass(rows) and issubclass(rows, QueryExpression): # instantiate if a class
- rows = rows()
- 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 heading))
- except StopIteration:
- pass
- fields = list(name for name in rows.heading if name in 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(select_fields=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
-
- if heading.attributes is None:
- logger.warning('Could not access table {table}'.format(table=self.full_table_name))
- return
-
- field_list = None # ensures that all rows have the same attributes in the same order as the first row.
-
- def make_row_to_insert(row):
- """
- :param row: A tuple to insert
- :return: a dict with fields 'names', 'placeholders', 'values'
- """
-
- def make_placeholder(name, value):
- """
- 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 heading:
- return None
- attr = heading[name]
- if attr.adapter:
- value = attr.adapter.put(value)
- if value is None or (attr.numeric and (value == '' or np.isnan(np.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)) from None
- 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 check_fields(fields):
- """
- Validates that all items in `fields` are valid attributes in the heading
- :param fields: field names of a tuple
- """
- if field_list is None:
- if not ignore_extra_fields:
- for field in fields:
- if field not in heading:
- raise KeyError(u'`{0:s}` is not in the table heading'.format(field))
- elif set(field_list) != set(fields).intersection(heading.names):
- raise DataJointError('Attempt to insert rows with different fields')
-
- if isinstance(row, np.void): # np.array
- check_fields(row.dtype.fields)
- attributes = [make_placeholder(name, row[name])
- for name in heading if name in row.dtype.fields]
- elif isinstance(row, collections.abc.Mapping): # dict-based
- check_fields(row)
- attributes = [make_placeholder(name, row[name]) for name in heading if name in row]
- else: # positional
- try:
- if len(row) != len(heading):
- raise DataJointError(
- 'Invalid insert argument. Incorrect number of attributes: '
- '{given} given; {expected} expected'.format(
- given=len(row), expected=len(heading)))
- except TypeError:
- raise DataJointError('Datatype %s cannot be inserted' % type(row))
- else:
- attributes = [make_placeholder(name, value) for name, value in zip(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)))
- nonlocal field_list
- if field_list is None:
- # first row sets the composition of the field list
- field_list = 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
-
- rows = list(make_row_to_insert(row) 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') from None
- except DuplicateError as err:
- raise err.suggest('To ignore duplicate entries in insert, set skip_duplicates=True') from None
-
- 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, verbose=True):
- """
- Deletes the contents of the table and its dependent tables, recursively.
- User is prompted for confirmation if config['safemode'] is set to True.
- """
- conn = self.connection
- already_in_transaction = conn.in_transaction
- safe = config['safemode']
- if already_in_transaction and safe:
- raise DataJointError('Cannot delete within a transaction in safemode. '
- 'Set dj.config["safemode"] = False or complete the ongoing transaction first.')
- graph = conn.dependencies
- graph.load()
- delete_list = collections.OrderedDict(
- (name, _RenameMap(next(iter(graph.parents(name).items()))) if name.isdigit() else FreeTable(conn, name))
- for name in graph.descendants(self.full_table_name))
-
- # construct restrictions for each relation
- restrict_by_me = set()
- # restrictions: Or-Lists of restriction conditions for each table.
- # Uncharacteristically of Or-Lists, an empty entry denotes "delete everything".
- restrictions = collections.defaultdict(list)
- # restrict by self
- if self.restriction:
- restrict_by_me.add(self.full_table_name)
- restrictions[self.full_table_name].append(self.restriction) # copy own restrictions
- # restrict by renamed nodes
- restrict_by_me.update(table for table in delete_list if table.isdigit()) # restrict by all renamed nodes
- # restrict by secondary dependencies
- for table in delete_list:
- restrict_by_me.update(graph.children(table, primary=False)) # restrict by any non-primary dependents
-
- # compile restriction lists
- for name, table in delete_list.items():
- for dep in graph.children(name):
- # if restrict by me, then restrict by the entire relation otherwise copy restrictions
- restrictions[dep].extend([table] if name in restrict_by_me else restrictions[name])
-
- # apply restrictions
- for name, table in delete_list.items():
- if not name.isdigit() and restrictions[name]: # do not restrict by an empty list
- table.restrict([
- r.proj() if isinstance(r, FreeTable) else (
- delete_list[r[0]].proj(**{a: b for a, b in r[1]['attr_map'].items()})
- if isinstance(r, _RenameMap) else r)
- for r in restrictions[name]])
- if safe:
- print('About to delete:')
-
- if not already_in_transaction:
- conn.start_transaction()
- total = 0
- try:
- for name, table in reversed(list(delete_list.items())):
- if not name.isdigit():
- count = table.delete_quick(get_count=True)
- total += count
- if (verbose or safe) and count:
- print('{table}: {count} items'.format(table=name, count=count))
- except:
- # Delete failed, perhaps due to insufficient privileges. Cancel transaction.
- if not already_in_transaction:
- conn.cancel_transaction()
- raise
- else:
- assert not (already_in_transaction and safe)
- if not total:
- print('Nothing to delete')
- if not already_in_transaction:
- conn.cancel_transaction()
- else:
- if already_in_transaction:
- if verbose:
- print('The delete is pending within the ongoing transaction.')
- else:
- if not safe or user_choice("Proceed?", default='no') == 'yes':
- conn.commit_transaction()
- if verbose or safe:
- print('Committed.')
- else:
- conn.cancel_transaction()
- if verbose or safe:
- print('Cancelled deletes.')
-
- 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 relation with an applied restriction condition 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()]
- 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()
- in_key = True
- definition = ('# ' + self.heading.table_info['comment'] + '\n'
- if self.heading.table_info['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 list(parents.items()): # need list() to force a copy
- if attr.name in fk_props['attr_map']:
- do_include = False
- if attributes_thus_far.issuperset(fk_props['attr_map']):
- parents.pop(parent_name)
- # 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 parent_name.isdigit():
- # 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
- parent_name = list(self.connection.dependencies.in_edges(parent_name))[0][0]
- lst = [(attr, ref) for attr, ref in fk_props['attr_map'].items() if ref != attr]
- 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(a, b) for a, b in lst))
- 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):
- """
- Updates a field in an existing tuple. This is not a datajoyous operation and should not be used
- routinely. Relational database maintain referential integrity on the level of a tuple. Therefore,
- the UPDATE operator can violate referential integrity. The datajoyous way to update information is
- to delete the entire tuple and insert the entire update tuple.
-
- 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
- """
- 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(np.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 ())
-
-
-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._ordered_class_members
- 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 arg: a dj.Connection or a dj.FreeTable
- """
-
- def __init__(self, arg, full_table_name=None):
- super().__init__()
- if isinstance(arg, FreeTable):
- # copy constructor
- self.database = arg.database
- self._table_name = arg._table_name
- self._connection = arg._connection
- else:
- self.database, self._table_name = (s.strip('`') for s in full_table_name.split('.'))
- self._connection = arg
-
- def __repr__(self):
- return "FreeTable(`%s`.`%s`)" % (self.database, self._table_name)
-
- @property
- def table_name(self):
- """
- :return: the table name in the schema
- """
- return self._table_name
-
-
-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__
- """
-
- def __init__(self, arg, database=None, skip_logging=False):
- super().__init__()
-
- if isinstance(arg, Log):
- # copy constructor
- self.database = arg.database
- self.skip_logging = arg.skip_logging
- self._connection = arg._connection
- self._definition = arg._definition
- self._user = arg._user
- return
-
- self.database = database
- self.skip_logging = skip_logging
- self._connection = arg
- 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)
-
- 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 '~log'
-
- 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"""
- self.delete_quick()
-
- 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 3942264b5..000000000
--- a/datajoint/user_tables.py
+++ /dev/null
@@ -1,186 +0,0 @@
-"""
-Hosts the table tiers, user relations should be derived from.
-"""
-
-import collections
-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',
- 'fetch', 'fetch1', 'head', 'tail',
- 'insert', 'insert1', 'drop', 'drop_quick', 'delete', 'delete_quick'}
-
-
-class OrderedClass(type):
- """
- Class whose members are ordered
- See https://docs.python.org/3/reference/datamodel.html#metaclass-example
-
- Note: Since Python 3.6, _ordered_class_members will no longer be necessary (PEP 520)
- https://www.python.org/dev/peps/pep-0520/
- """
- @classmethod
- def __prepare__(metacls, name, bases, **kwds):
- return collections.OrderedDict()
-
- def __new__(cls, name, bases, namespace, **kwds):
- result = type.__new__(cls, name, bases, dict(namespace))
- result._ordered_class_members = list(namespace)
- return result
-
- def __setattr__(cls, name, value):
- if hasattr(cls, '_ordered_class_members'):
- cls._ordered_class_members.append(name)
- super().__setattr__(name, value)
-
- 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 __sub__(cls, arg):
- return cls() - arg
-
- def __mul__(cls, arg):
- return cls() * arg
-
- def __add__(cls, arg):
- return cls() + arg
-
- def __iter__(cls):
- return iter(cls())
-
-
-class UserTable(Table, metaclass=OrderedClass):
- """
- A subclass of UserTable is a dedicated class interfacing a base relation.
- UserTable is initialized by the decorator generated by schema().
- """
- _connection = None
- _heading = None
- 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):
- """
- :returns: 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}:
- 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
- _heading = 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()
- 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 8ad6f4b48..000000000
--- a/datajoint/utils.py
+++ /dev/null
@@ -1,117 +0,0 @@
-"""General-purpose utilities"""
-
-import re
-from pathlib import Path
-import shutil
-import sys
-from .errors import DataJointError
-
-
-if sys.version_info[1] < 6:
- from collections import OrderedDict
-else:
- # use dict in Python 3.6+ -- They are already ordered and look nicer
- OrderedDict = dict
-
-
-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 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") # yields "TableName"
- """
-
- def to_upper(match):
- return match.group(0)[-1].upper()
-
- return re.sub('(^|[_\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 210836658..000000000
--- a/datajoint/version.py
+++ /dev/null
@@ -1,3 +0,0 @@
-__version__ = "0.12.6"
-
-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 a1caecd6c..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.insert(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 d76404ff1..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_kill_order_by.rst b/docs-parts/computation/06-distributed-computing_kill_order_by.rst
deleted file mode 100644
index 9830b70ed..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:: text
-
- 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 d3a3338e1..000000000
--- a/docs-parts/computation/06-distributed-computing_lang2.rst
+++ /dev/null
@@ -1,10 +0,0 @@
-
-.. code-block:: text
-
- 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 f4b840866..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:: text
-
- 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 e8a98ae77..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:: text
-
- 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 3f27bda08..000000000
--- a/docs-parts/computation/06-distributed-computing_lang5.rst
+++ /dev/null
@@ -1,6 +0,0 @@
-
-For example:
-
-.. code-block:: text
-
- 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 1d3330cd2..000000000
--- a/docs-parts/definition/11-ERD_lang1.rst
+++ /dev/null
@@ -1,21 +0,0 @@
-
-To plot the ERD for an entire schema, an ERD 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.ERD(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.ERD(seq).draw() # draw the ERD
-
-Note that calling the ``.draw()`` method is not necessary when working in a Jupyter notebook.
-The preferred workflow is to simply let the object display itself, for example by writing ``dj.ERD(seq)``.
-The ERD will then render in the notebook using its ``_repr_html_`` method.
-An ERD 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 cb345a955..000000000
--- a/docs-parts/definition/11-ERD_lang2.rst
+++ /dev/null
@@ -1,4 +0,0 @@
-
-.. code-block:: python
-
- dj.ERD(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 3474dcd45..000000000
--- a/docs-parts/definition/11-ERD_lang3.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-
-.. code-block:: python
-
- # plot the ERD with tables Genome and Species from module seq.
- (dj.ERD(seq.Genome) + dj.ERD(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 f67b40d10..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.ERD(seq.Genome)+1).draw()
-
-.. code-block:: python
-
- # Plot all the tables directly upstream from ``seq.Genome``:
- (dj.ERD(seq.Genome)-1).draw()
-
-.. code-block:: python
-
- # Plot the local neighborhood of ``seq.Genome``
- (dj.ERD(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 70d7606f5..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 can 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 ``schema.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.Di(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.Di(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.Di(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 c91e2e554..000000000
--- a/docs-parts/intro/Releases_lang1.rst
+++ /dev/null
@@ -1,211 +0,0 @@
-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 and #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/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 3e6f5e043..000000000
--- a/docs-parts/queries/03-Fetch_lang1.rst
+++ /dev/null
@@ -1,56 +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.
-
-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 with no need for conversion.
-
-.. 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/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 c39fa12d1..000000000
--- a/docs-parts/version_common.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "comm_version": "v0.1"
-}
\ 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 9648df0ce..000000000
--- a/local-docker-compose.yml
+++ /dev/null
@@ -1,109 +0,0 @@
-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: raphaelguzman/nginx:v0.0.6
- 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: >
- /bin/sh -c
- "
- pip install --user nose nose-cov coveralls flake8 ptvsd .;
- 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:
\ No newline at end of file
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 5c84e822c..000000000
--- a/requirements.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-numpy
-pymysql>=0.7.2
-pyparsing
-ipython
-pandas
-tqdm
-networkx
-pydot
-minio
-matplotlib
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 56253c24b..000000000
--- a/setup.py
+++ /dev/null
@@ -1,35 +0,0 @@
-#!/usr/bin/env python
-from setuptools import setup, find_packages
-from os import path
-import sys
-
-min_py_version = (3, 5)
-
-if sys.version_info < min_py_version:
- sys.exit('DataJoint is only supported on 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
-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)
-)
diff --git a/src/datajoint/__init__.py b/src/datajoint/__init__.py
new file mode 100644
index 000000000..4970b19d4
--- /dev/null
+++ b/src/datajoint/__init__.py
@@ -0,0 +1,301 @@
+"""
+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",
+ # Storage Adapter API
+ "StorageAdapter",
+ "get_storage_adapter",
+ # Other
+ "errors",
+ "migrate",
+ "DataJointError",
+ "ThreadSafetyError",
+ "logger",
+ "cli",
+ "ValidationResult",
+]
+
+# =============================================================================
+# Eager imports — core functionality needed immediately
+# =============================================================================
+from . import errors
+from . import migrate
+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 .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..543e972d3
--- /dev/null
+++ b/src/datajoint/adapters/postgres.py
@@ -0,0 +1,1576 @@
+"""
+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 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..ff1b0e234
--- /dev/null
+++ b/src/datajoint/schemas.py
@@ -0,0 +1,821 @@
+"""
+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()
+ 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/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..7f8cbaf70
--- /dev/null
+++ b/src/datajoint/table.py
@@ -0,0 +1,1577 @@
+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 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 0b6e15ef4..000000000
--- a/test_requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-matplotlib
-pydotplus
-moto
diff --git a/tests/__init__.py b/tests/__init__.py
index 90e8b8922..e69de29bb 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,195 +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', 'localhost'),
- user=environ.get('DJ_TEST_USER', 'datajoint'),
- password=environ.get('DJ_TEST_PASSWORD', 'datajoint'))
-
-CONN_INFO_ROOT = dict(
- host=environ.get('DJ_HOST', 'localhost'),
- user=environ.get('DJ_USER', 'root'),
- password=environ.get('DJ_PASS', 'simple'))
-
-S3_CONN_INFO = dict(
- endpoint=environ.get('S3_ENDPOINT', 'localhost:9000'),
- 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)
-
-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 mysql5.6/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;
- """)
-
-# 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
-
- # 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.BucketAlreadyOwnedByYou:
- pass
-
- 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.BucketAlreadyOwnedByYou:
- pass
-
- # 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 = dj.conn(**CONN_INFO)
- conn.query('SET FOREIGN_KEY_CHECKS=0')
- cur = conn.query('SHOW DATABASES LIKE "{}\_%%"'.format(PREFIX))
- for db in cur.fetchall():
- conn.query('DROP DATABASE `{}`'.format(db[0]))
- conn.query('SET FOREIGN_KEY_CHECKS=1')
- if os.path.exists("dj_local_conf.json"):
- remove("dj_local_conf.json")
-
- # Remove old S3
- objs = list(minioClient.list_objects_v2(
- 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_v2(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/integration/data/Course.csv b/tests/integration/data/Course.csv
new file mode 100644
index 000000000..a308d8d6a
--- /dev/null
+++ b/tests/integration/data/Course.csv
@@ -0,0 +1,46 @@
+dept,course,course_name,credits
+BIOL,1006,World of Dinosaurs,3.0
+BIOL,1010,Biology in the 21st Century,3.0
+BIOL,1030,Human Biology,3.0
+BIOL,1210,Principles of Biology,4.0
+BIOL,2010,Evolution & Diversity of Life,3.0
+BIOL,2020,Principles of Cell Biology,3.0
+BIOL,2021,Principles of Cell Science,4.0
+BIOL,2030,Principles of Genetics,3.0
+BIOL,2210,Human Genetics,3.0
+BIOL,2325,Human Anatomy,4.0
+BIOL,2330,Plants & Society,3.0
+BIOL,2355,Field Botany,2.0
+BIOL,2420,Human Physiology,4.0
+CS,1030,Foundations of Computer Science,3.0
+CS,1410,Introduction to Object-Oriented Programming,4.0
+CS,2100,Discrete Structures,3.0
+CS,2420,Introduction to Algorithms & Data Structures,4.0
+CS,3100,Models of Computation,3.0
+CS,3200,Introduction to Scientific Computing,3.0
+CS,3500,Software Practice,4.0
+CS,3505,Software Practice II,3.0
+CS,3810,Computer Organization,4.0
+CS,4000,Senior Capstone Project - Design Phase,3.0
+CS,4150,Algorithms,3.0
+CS,4400,Computer Systems,4.0
+CS,4500,Senior Capstone Project,3.0
+CS,4940,Undergraduate Research,3.0
+CS,4970,Computer Science Bachelors Thesis,3.0
+MATH,1210,Calculus I,4.0
+MATH,1220,Calculus II,4.0
+MATH,1250,Calculus for AP Students I,4.0
+MATH,1260,Calculus for AP Students II,4.0
+MATH,2210,Calculus III,3.0
+MATH,2270,Linear Algebra,4.0
+MATH,2280,Introduction to Differential Equations,4.0
+MATH,3210,Foundations of Analysis I,4.0
+MATH,3220,Foundations of Analysis II,4.0
+PHYS,2040,Classical Theoretical Physics II,4.0
+PHYS,2060,Quantum Mechanics,3.0
+PHYS,2100,General Relativity and Cosmology,3.0
+PHYS,2140,Statistical Mechanics,4.0
+PHYS,2210,Physics for Scientists and Engineers I,4.0
+PHYS,2220,Physics for Scientists and Engineers II,4.0
+PHYS,3210,Physics for Scientists I (Honors),4.0
+PHYS,3220,Physics for Scientists II (Honors),4.0
diff --git a/tests/integration/data/CurrentTerm.csv b/tests/integration/data/CurrentTerm.csv
new file mode 100644
index 000000000..037d9b344
--- /dev/null
+++ b/tests/integration/data/CurrentTerm.csv
@@ -0,0 +1,2 @@
+omega,term_year,term
+1,2020,Fall
diff --git a/tests/integration/data/Department.csv b/tests/integration/data/Department.csv
new file mode 100644
index 000000000..5a7857eef
--- /dev/null
+++ b/tests/integration/data/Department.csv
@@ -0,0 +1,9 @@
+dept,dept_name,dept_address,dept_phone
+BIOL,Life Sciences,"931 Eric Trail Suite 331
+Lake Scott, CT 53527",(238)497-9162x0223
+CS,Computer Science,"0104 Santos Hill Apt. 497
+Michelleland, MT 94473",3828723244
+MATH,Mathematics,"8358 Bryan Ports
+Lake Matthew, SC 36983",+1-461-767-9298x842
+PHYS,Physics,"7744 Haley Meadows Suite 661
+Lake Eddie, CT 51544",4097052774
diff --git a/tests/integration/data/Enroll.csv b/tests/integration/data/Enroll.csv
new file mode 100644
index 000000000..fc9a6b2a0
--- /dev/null
+++ b/tests/integration/data/Enroll.csv
@@ -0,0 +1,3365 @@
+student_id,dept,course,term_year,term,section
+394,BIOL,1006,2015,Spring,b
+138,BIOL,1006,2015,Summer,a
+182,BIOL,1006,2015,Summer,a
+246,BIOL,1006,2015,Summer,a
+249,BIOL,1006,2015,Summer,b
+290,BIOL,1006,2015,Summer,b
+115,BIOL,1006,2016,Spring,a
+160,BIOL,1006,2016,Spring,a
+176,BIOL,1006,2016,Spring,a
+276,BIOL,1006,2016,Spring,a
+285,BIOL,1006,2016,Spring,a
+123,BIOL,1006,2016,Spring,b
+312,BIOL,1006,2016,Summer,a
+179,BIOL,1006,2016,Summer,b
+214,BIOL,1006,2016,Summer,d
+389,BIOL,1006,2016,Summer,d
+124,BIOL,1006,2017,Fall,a
+128,BIOL,1006,2017,Fall,a
+199,BIOL,1006,2017,Fall,a
+262,BIOL,1006,2017,Fall,a
+288,BIOL,1006,2017,Fall,a
+321,BIOL,1006,2017,Fall,a
+326,BIOL,1006,2017,Fall,a
+345,BIOL,1006,2017,Fall,a
+392,BIOL,1006,2017,Fall,a
+165,BIOL,1006,2017,Fall,b
+229,BIOL,1006,2017,Fall,b
+318,BIOL,1006,2017,Fall,b
+107,BIOL,1006,2018,Spring,a
+117,BIOL,1006,2018,Spring,a
+164,BIOL,1006,2018,Spring,a
+362,BIOL,1006,2018,Spring,a
+366,BIOL,1006,2018,Spring,a
+397,BIOL,1006,2018,Spring,a
+227,BIOL,1006,2018,Spring,b
+261,BIOL,1006,2018,Spring,b
+270,BIOL,1006,2018,Spring,b
+292,BIOL,1006,2018,Spring,b
+294,BIOL,1006,2018,Spring,b
+348,BIOL,1006,2018,Spring,b
+373,BIOL,1006,2018,Spring,b
+375,BIOL,1006,2018,Spring,b
+102,BIOL,1006,2018,Fall,a
+113,BIOL,1006,2018,Fall,a
+131,BIOL,1006,2018,Fall,a
+296,BIOL,1006,2018,Fall,a
+391,BIOL,1006,2018,Fall,a
+127,BIOL,1006,2019,Spring,a
+139,BIOL,1006,2019,Summer,a
+143,BIOL,1006,2019,Summer,a
+178,BIOL,1006,2019,Summer,a
+234,BIOL,1006,2019,Summer,a
+247,BIOL,1006,2019,Summer,a
+259,BIOL,1006,2019,Summer,a
+303,BIOL,1006,2019,Summer,a
+329,BIOL,1006,2019,Summer,a
+356,BIOL,1006,2019,Summer,a
+109,BIOL,1006,2019,Fall,a
+173,BIOL,1006,2019,Fall,a
+187,BIOL,1006,2019,Fall,a
+364,BIOL,1006,2019,Fall,a
+169,BIOL,1006,2019,Fall,b
+332,BIOL,1006,2019,Fall,b
+398,BIOL,1006,2019,Fall,b
+142,BIOL,1006,2020,Spring,a
+194,BIOL,1006,2020,Spring,a
+267,BIOL,1006,2020,Spring,a
+330,BIOL,1006,2020,Spring,a
+340,BIOL,1006,2020,Spring,a
+365,BIOL,1006,2020,Spring,a
+129,BIOL,1006,2020,Fall,a
+222,BIOL,1006,2020,Fall,a
+241,BIOL,1006,2020,Fall,a
+297,BIOL,1006,2020,Fall,a
+313,BIOL,1006,2020,Fall,a
+333,BIOL,1006,2020,Fall,a
+376,BIOL,1006,2020,Fall,a
+379,BIOL,1006,2020,Fall,a
+390,BIOL,1006,2020,Fall,a
+220,BIOL,1006,2020,Fall,b
+255,BIOL,1006,2020,Fall,b
+272,BIOL,1006,2020,Fall,b
+277,BIOL,1006,2020,Fall,b
+313,BIOL,1006,2020,Fall,b
+371,BIOL,1006,2020,Fall,b
+378,BIOL,1006,2020,Fall,b
+118,BIOL,1006,2020,Fall,c
+235,BIOL,1006,2020,Fall,c
+271,BIOL,1006,2020,Fall,c
+289,BIOL,1006,2020,Fall,c
+313,BIOL,1006,2020,Fall,c
+378,BIOL,1006,2020,Fall,c
+182,BIOL,1010,2015,Summer,a
+276,BIOL,1010,2015,Summer,a
+277,BIOL,1010,2015,Summer,a
+382,BIOL,1010,2015,Summer,a
+123,BIOL,1010,2015,Summer,b
+177,BIOL,1010,2015,Summer,b
+382,BIOL,1010,2015,Summer,b
+277,BIOL,1010,2015,Summer,c
+301,BIOL,1010,2015,Summer,c
+163,BIOL,1010,2015,Summer,d
+179,BIOL,1010,2015,Fall,a
+210,BIOL,1010,2015,Fall,a
+211,BIOL,1010,2015,Fall,b
+290,BIOL,1010,2015,Fall,b
+211,BIOL,1010,2015,Fall,c
+176,BIOL,1010,2016,Summer,a
+192,BIOL,1010,2016,Summer,a
+195,BIOL,1010,2016,Summer,a
+282,BIOL,1010,2016,Summer,a
+317,BIOL,1010,2016,Summer,a
+249,BIOL,1010,2017,Spring,a
+278,BIOL,1010,2017,Spring,a
+312,BIOL,1010,2017,Spring,a
+373,BIOL,1010,2017,Spring,a
+391,BIOL,1010,2017,Spring,a
+397,BIOL,1010,2017,Spring,a
+151,BIOL,1010,2017,Summer,a
+321,BIOL,1010,2017,Summer,a
+353,BIOL,1010,2017,Summer,a
+102,BIOL,1010,2018,Summer,a
+105,BIOL,1010,2018,Summer,a
+214,BIOL,1010,2018,Summer,a
+260,BIOL,1010,2018,Summer,a
+294,BIOL,1010,2018,Summer,a
+318,BIOL,1010,2018,Summer,a
+368,BIOL,1010,2018,Summer,a
+392,BIOL,1010,2018,Summer,a
+399,BIOL,1010,2018,Summer,a
+133,BIOL,1010,2018,Summer,b
+173,BIOL,1010,2018,Summer,b
+197,BIOL,1010,2018,Summer,b
+238,BIOL,1010,2018,Summer,b
+275,BIOL,1010,2018,Summer,b
+285,BIOL,1010,2018,Summer,b
+292,BIOL,1010,2018,Summer,b
+311,BIOL,1010,2018,Summer,b
+313,BIOL,1010,2018,Summer,b
+366,BIOL,1010,2018,Summer,b
+378,BIOL,1010,2018,Summer,b
+259,BIOL,1010,2018,Summer,c
+262,BIOL,1010,2018,Summer,c
+309,BIOL,1010,2018,Summer,c
+313,BIOL,1010,2018,Summer,c
+329,BIOL,1010,2018,Summer,c
+342,BIOL,1010,2018,Summer,c
+374,BIOL,1010,2018,Summer,c
+169,BIOL,1010,2018,Fall,a
+239,BIOL,1010,2018,Fall,a
+252,BIOL,1010,2018,Fall,a
+258,BIOL,1010,2018,Fall,a
+345,BIOL,1010,2018,Fall,a
+362,BIOL,1010,2018,Fall,a
+164,BIOL,1010,2018,Fall,b
+298,BIOL,1010,2018,Fall,b
+139,BIOL,1010,2019,Spring,a
+372,BIOL,1010,2019,Spring,a
+375,BIOL,1010,2019,Spring,a
+109,BIOL,1010,2019,Spring,b
+165,BIOL,1010,2019,Spring,b
+217,BIOL,1010,2019,Spring,b
+228,BIOL,1010,2019,Spring,b
+231,BIOL,1010,2019,Spring,b
+240,BIOL,1010,2019,Spring,c
+332,BIOL,1010,2019,Spring,c
+247,BIOL,1010,2019,Spring,d
+314,BIOL,1010,2019,Spring,d
+379,BIOL,1010,2019,Spring,d
+113,BIOL,1010,2020,Summer,a
+122,BIOL,1010,2020,Summer,a
+148,BIOL,1010,2020,Summer,a
+153,BIOL,1010,2020,Summer,a
+178,BIOL,1010,2020,Summer,a
+200,BIOL,1010,2020,Summer,a
+256,BIOL,1010,2020,Summer,a
+270,BIOL,1010,2020,Summer,a
+340,BIOL,1010,2020,Summer,a
+108,BIOL,1010,2020,Summer,b
+118,BIOL,1010,2020,Summer,b
+122,BIOL,1010,2020,Summer,b
+175,BIOL,1010,2020,Summer,b
+244,BIOL,1010,2020,Summer,b
+257,BIOL,1010,2020,Summer,b
+270,BIOL,1010,2020,Summer,b
+306,BIOL,1010,2020,Summer,b
+348,BIOL,1010,2020,Summer,b
+384,BIOL,1010,2020,Summer,b
+112,BIOL,1010,2020,Summer,c
+131,BIOL,1010,2020,Summer,c
+146,BIOL,1010,2020,Summer,c
+185,BIOL,1010,2020,Summer,c
+270,BIOL,1010,2020,Summer,c
+348,BIOL,1010,2020,Summer,c
+371,BIOL,1010,2020,Summer,c
+390,BIOL,1010,2020,Summer,c
+398,BIOL,1010,2020,Summer,c
+100,BIOL,1010,2020,Summer,d
+121,BIOL,1010,2020,Summer,d
+244,BIOL,1010,2020,Summer,d
+254,BIOL,1010,2020,Summer,d
+263,BIOL,1010,2020,Summer,d
+270,BIOL,1010,2020,Summer,d
+300,BIOL,1010,2020,Summer,d
+323,BIOL,1010,2020,Summer,d
+340,BIOL,1010,2020,Summer,d
+371,BIOL,1010,2020,Summer,d
+211,BIOL,1030,2015,Spring,c
+379,BIOL,1030,2015,Spring,d
+204,BIOL,1030,2015,Summer,a
+246,BIOL,1030,2015,Summer,a
+321,BIOL,1030,2015,Summer,a
+117,BIOL,1030,2016,Spring,a
+273,BIOL,1030,2016,Spring,a
+282,BIOL,1030,2016,Spring,a
+392,BIOL,1030,2016,Spring,a
+160,BIOL,1030,2016,Summer,a
+195,BIOL,1030,2016,Summer,a
+270,BIOL,1030,2016,Summer,a
+277,BIOL,1030,2016,Summer,a
+290,BIOL,1030,2016,Summer,a
+329,BIOL,1030,2016,Summer,a
+395,BIOL,1030,2016,Summer,a
+120,BIOL,1030,2016,Fall,a
+176,BIOL,1030,2016,Fall,a
+213,BIOL,1030,2016,Fall,a
+276,BIOL,1030,2016,Fall,a
+115,BIOL,1030,2017,Spring,a
+257,BIOL,1030,2017,Spring,a
+299,BIOL,1030,2017,Spring,a
+313,BIOL,1030,2017,Spring,a
+214,BIOL,1030,2017,Spring,b
+243,BIOL,1030,2017,Spring,b
+374,BIOL,1030,2017,Spring,b
+151,BIOL,1030,2017,Spring,c
+215,BIOL,1030,2017,Spring,c
+257,BIOL,1030,2017,Spring,c
+335,BIOL,1030,2017,Spring,c
+348,BIOL,1030,2017,Spring,c
+388,BIOL,1030,2017,Spring,c
+132,BIOL,1030,2018,Summer,a
+197,BIOL,1030,2018,Summer,a
+285,BIOL,1030,2018,Summer,a
+372,BIOL,1030,2018,Summer,a
+378,BIOL,1030,2018,Summer,a
+102,BIOL,1030,2018,Fall,a
+183,BIOL,1030,2018,Fall,a
+199,BIOL,1030,2018,Fall,a
+230,BIOL,1030,2018,Fall,a
+253,BIOL,1030,2018,Fall,a
+259,BIOL,1030,2018,Fall,a
+275,BIOL,1030,2018,Fall,a
+387,BIOL,1030,2018,Fall,a
+391,BIOL,1030,2018,Fall,a
+179,BIOL,1030,2019,Spring,a
+333,BIOL,1030,2019,Spring,a
+139,BIOL,1030,2019,Spring,b
+217,BIOL,1030,2019,Spring,b
+258,BIOL,1030,2019,Spring,b
+143,BIOL,1030,2019,Spring,c
+177,BIOL,1030,2019,Spring,c
+248,BIOL,1030,2019,Spring,c
+256,BIOL,1030,2019,Spring,c
+258,BIOL,1030,2019,Spring,c
+298,BIOL,1030,2019,Spring,c
+307,BIOL,1030,2019,Spring,c
+318,BIOL,1030,2019,Spring,c
+375,BIOL,1030,2019,Spring,c
+397,BIOL,1030,2019,Spring,c
+231,BIOL,1030,2019,Spring,d
+384,BIOL,1030,2019,Spring,d
+128,BIOL,1030,2019,Summer,a
+167,BIOL,1030,2019,Summer,a
+260,BIOL,1030,2019,Summer,a
+314,BIOL,1030,2019,Summer,a
+347,BIOL,1030,2019,Summer,a
+380,BIOL,1030,2019,Summer,a
+100,BIOL,1030,2020,Spring,a
+135,BIOL,1030,2020,Spring,a
+153,BIOL,1030,2020,Spring,a
+254,BIOL,1030,2020,Spring,a
+292,BIOL,1030,2020,Spring,a
+325,BIOL,1030,2020,Spring,a
+341,BIOL,1030,2020,Spring,a
+109,BIOL,1030,2020,Summer,a
+113,BIOL,1030,2020,Summer,a
+123,BIOL,1030,2020,Summer,a
+131,BIOL,1030,2020,Summer,a
+164,BIOL,1030,2020,Summer,a
+170,BIOL,1030,2020,Summer,a
+185,BIOL,1030,2020,Summer,a
+332,BIOL,1030,2020,Summer,a
+340,BIOL,1030,2020,Summer,a
+360,BIOL,1030,2020,Summer,a
+371,BIOL,1030,2020,Summer,a
+386,BIOL,1030,2020,Summer,a
+144,BIOL,1210,2016,Spring,a
+182,BIOL,1210,2016,Spring,a
+270,BIOL,1210,2016,Spring,a
+301,BIOL,1210,2016,Spring,a
+115,BIOL,1210,2017,Spring,a
+117,BIOL,1210,2017,Spring,a
+210,BIOL,1210,2017,Spring,a
+278,BIOL,1210,2017,Spring,a
+299,BIOL,1210,2017,Spring,a
+372,BIOL,1210,2017,Spring,a
+377,BIOL,1210,2017,Spring,a
+275,BIOL,1210,2017,Summer,a
+282,BIOL,1210,2017,Summer,a
+120,BIOL,1210,2018,Spring,a
+131,BIOL,1210,2018,Spring,a
+134,BIOL,1210,2018,Spring,a
+177,BIOL,1210,2018,Spring,a
+332,BIOL,1210,2018,Spring,a
+220,BIOL,1210,2018,Fall,a
+255,BIOL,1210,2018,Fall,a
+151,BIOL,1210,2018,Fall,b
+179,BIOL,1210,2018,Fall,b
+366,BIOL,1210,2018,Fall,b
+173,BIOL,1210,2019,Spring,a
+230,BIOL,1210,2019,Spring,a
+256,BIOL,1210,2019,Spring,a
+305,BIOL,1210,2019,Spring,a
+307,BIOL,1210,2019,Spring,a
+342,BIOL,1210,2019,Spring,a
+356,BIOL,1210,2019,Spring,a
+193,BIOL,2010,2015,Spring,a
+182,BIOL,2010,2015,Summer,a
+195,BIOL,2010,2015,Summer,a
+377,BIOL,2010,2015,Summer,a
+336,BIOL,2010,2015,Fall,a
+123,BIOL,2010,2017,Summer,a
+127,BIOL,2010,2017,Summer,a
+173,BIOL,2010,2017,Summer,a
+259,BIOL,2010,2017,Summer,a
+277,BIOL,2010,2017,Summer,a
+120,BIOL,2010,2017,Fall,a
+208,BIOL,2010,2017,Fall,a
+262,BIOL,2010,2017,Fall,a
+304,BIOL,2010,2017,Fall,a
+355,BIOL,2010,2017,Fall,a
+372,BIOL,2010,2017,Fall,a
+391,BIOL,2010,2017,Fall,a
+134,BIOL,2010,2018,Spring,a
+197,BIOL,2010,2018,Spring,a
+210,BIOL,2010,2018,Spring,a
+214,BIOL,2010,2018,Spring,a
+255,BIOL,2010,2018,Spring,a
+270,BIOL,2010,2018,Spring,a
+285,BIOL,2010,2018,Spring,a
+348,BIOL,2010,2018,Spring,a
+373,BIOL,2010,2018,Spring,a
+385,BIOL,2010,2018,Spring,a
+309,BIOL,2010,2019,Fall,a
+312,BIOL,2010,2019,Fall,a
+313,BIOL,2010,2019,Fall,a
+316,BIOL,2010,2019,Fall,a
+109,BIOL,2010,2020,Spring,a
+113,BIOL,2010,2020,Spring,a
+135,BIOL,2010,2020,Spring,a
+169,BIOL,2010,2020,Spring,a
+223,BIOL,2010,2020,Spring,a
+231,BIOL,2010,2020,Spring,a
+384,BIOL,2010,2020,Spring,a
+386,BIOL,2010,2020,Spring,a
+108,BIOL,2010,2020,Spring,b
+164,BIOL,2010,2020,Spring,b
+178,BIOL,2010,2020,Spring,b
+179,BIOL,2010,2020,Spring,b
+292,BIOL,2010,2020,Spring,b
+146,BIOL,2010,2020,Summer,a
+166,BIOL,2010,2020,Summer,a
+167,BIOL,2010,2020,Summer,a
+170,BIOL,2010,2020,Summer,a
+175,BIOL,2010,2020,Summer,a
+221,BIOL,2010,2020,Summer,a
+228,BIOL,2010,2020,Summer,a
+242,BIOL,2010,2020,Summer,a
+248,BIOL,2010,2020,Summer,a
+250,BIOL,2010,2020,Summer,a
+251,BIOL,2010,2020,Summer,a
+256,BIOL,2010,2020,Summer,a
+311,BIOL,2010,2020,Summer,a
+333,BIOL,2010,2020,Summer,a
+364,BIOL,2010,2020,Summer,a
+375,BIOL,2010,2020,Summer,a
+378,BIOL,2010,2020,Summer,a
+128,BIOL,2010,2020,Summer,b
+177,BIOL,2010,2020,Summer,b
+228,BIOL,2010,2020,Summer,b
+235,BIOL,2010,2020,Summer,b
+293,BIOL,2010,2020,Summer,b
+296,BIOL,2010,2020,Summer,b
+306,BIOL,2010,2020,Summer,b
+363,BIOL,2010,2020,Summer,b
+390,BIOL,2010,2020,Summer,b
+120,BIOL,2020,2015,Summer,a
+144,BIOL,2020,2015,Summer,a
+210,BIOL,2020,2015,Summer,a
+126,BIOL,2020,2015,Fall,a
+140,BIOL,2020,2015,Fall,a
+374,BIOL,2020,2015,Fall,b
+392,BIOL,2020,2015,Fall,b
+176,BIOL,2020,2015,Fall,c
+182,BIOL,2020,2015,Fall,c
+295,BIOL,2020,2015,Fall,c
+377,BIOL,2020,2015,Fall,c
+192,BIOL,2020,2015,Fall,d
+115,BIOL,2020,2016,Spring,a
+117,BIOL,2020,2016,Spring,a
+212,BIOL,2020,2016,Spring,a
+214,BIOL,2020,2016,Spring,a
+313,BIOL,2020,2016,Spring,a
+357,BIOL,2020,2016,Spring,a
+123,BIOL,2020,2018,Spring,a
+129,BIOL,2020,2018,Spring,a
+139,BIOL,2020,2018,Spring,a
+285,BIOL,2020,2018,Spring,a
+292,BIOL,2020,2018,Spring,a
+321,BIOL,2020,2018,Spring,a
+332,BIOL,2020,2018,Spring,a
+152,BIOL,2020,2018,Fall,a
+158,BIOL,2020,2018,Fall,a
+163,BIOL,2020,2018,Fall,a
+165,BIOL,2020,2018,Fall,a
+177,BIOL,2020,2018,Fall,a
+183,BIOL,2020,2018,Fall,a
+199,BIOL,2020,2018,Fall,a
+255,BIOL,2020,2018,Fall,a
+257,BIOL,2020,2018,Fall,a
+261,BIOL,2020,2018,Fall,a
+270,BIOL,2020,2018,Fall,a
+274,BIOL,2020,2018,Fall,a
+276,BIOL,2020,2018,Fall,a
+399,BIOL,2020,2018,Fall,a
+100,BIOL,2020,2018,Fall,b
+113,BIOL,2020,2018,Fall,b
+260,BIOL,2020,2018,Fall,b
+262,BIOL,2020,2018,Fall,b
+267,BIOL,2020,2018,Fall,b
+344,BIOL,2020,2018,Fall,b
+345,BIOL,2020,2018,Fall,b
+373,BIOL,2020,2018,Fall,b
+378,BIOL,2020,2018,Fall,b
+362,BIOL,2020,2018,Fall,c
+387,BIOL,2020,2018,Fall,c
+101,BIOL,2020,2018,Fall,d
+231,BIOL,2020,2018,Fall,d
+288,BIOL,2020,2018,Fall,d
+325,BIOL,2020,2018,Fall,d
+342,BIOL,2020,2018,Fall,d
+379,BIOL,2020,2018,Fall,d
+102,BIOL,2020,2019,Summer,a
+119,BIOL,2020,2019,Summer,a
+289,BIOL,2020,2019,Summer,a
+293,BIOL,2020,2019,Summer,a
+307,BIOL,2020,2019,Summer,a
+282,BIOL,2021,2015,Spring,a
+377,BIOL,2021,2015,Spring,a
+394,BIOL,2021,2015,Spring,a
+249,BIOL,2021,2015,Summer,b
+290,BIOL,2021,2015,Summer,c
+179,BIOL,2021,2016,Fall,a
+243,BIOL,2021,2016,Fall,a
+268,BIOL,2021,2016,Fall,a
+270,BIOL,2021,2016,Fall,a
+379,BIOL,2021,2016,Fall,a
+115,BIOL,2021,2017,Summer,a
+182,BIOL,2021,2017,Summer,a
+348,BIOL,2021,2017,Summer,a
+388,BIOL,2021,2017,Summer,a
+207,BIOL,2021,2017,Fall,a
+264,BIOL,2021,2017,Fall,a
+292,BIOL,2021,2017,Fall,a
+345,BIOL,2021,2017,Fall,a
+102,BIOL,2021,2018,Spring,a
+177,BIOL,2021,2018,Spring,a
+311,BIOL,2021,2018,Spring,a
+361,BIOL,2021,2018,Spring,a
+373,BIOL,2021,2018,Spring,a
+117,BIOL,2021,2018,Summer,a
+169,BIOL,2021,2018,Summer,a
+257,BIOL,2021,2018,Summer,a
+312,BIOL,2021,2018,Summer,a
+318,BIOL,2021,2018,Summer,a
+344,BIOL,2021,2018,Summer,a
+356,BIOL,2021,2018,Summer,a
+366,BIOL,2021,2018,Summer,a
+378,BIOL,2021,2018,Summer,a
+127,BIOL,2021,2018,Fall,a
+152,BIOL,2021,2018,Fall,a
+199,BIOL,2021,2018,Fall,a
+239,BIOL,2021,2018,Fall,a
+256,BIOL,2021,2018,Fall,a
+152,BIOL,2021,2018,Fall,b
+309,BIOL,2021,2018,Fall,b
+397,BIOL,2021,2018,Fall,b
+248,BIOL,2021,2018,Fall,c
+296,BIOL,2021,2018,Fall,c
+342,BIOL,2021,2018,Fall,c
+384,BIOL,2021,2018,Fall,c
+133,BIOL,2021,2018,Fall,d
+296,BIOL,2021,2018,Fall,d
+196,BIOL,2021,2019,Spring,a
+399,BIOL,2021,2019,Spring,a
+139,BIOL,2021,2019,Spring,b
+178,BIOL,2021,2019,Spring,b
+238,BIOL,2021,2019,Spring,b
+313,BIOL,2021,2019,Spring,b
+107,BIOL,2021,2019,Fall,a
+164,BIOL,2021,2019,Fall,a
+300,BIOL,2021,2019,Fall,a
+303,BIOL,2021,2019,Fall,a
+340,BIOL,2021,2019,Fall,a
+364,BIOL,2021,2019,Fall,a
+140,BIOL,2030,2015,Fall,a
+212,BIOL,2030,2015,Fall,a
+215,BIOL,2030,2015,Fall,a
+249,BIOL,2030,2015,Fall,a
+379,BIOL,2030,2015,Fall,a
+119,BIOL,2030,2016,Summer,a
+163,BIOL,2030,2016,Summer,b
+207,BIOL,2030,2016,Summer,b
+392,BIOL,2030,2016,Summer,b
+151,BIOL,2030,2016,Fall,a
+213,BIOL,2030,2016,Fall,a
+277,BIOL,2030,2016,Fall,a
+314,BIOL,2030,2016,Fall,a
+397,BIOL,2030,2016,Fall,a
+123,BIOL,2030,2017,Spring,a
+179,BIOL,2030,2017,Spring,a
+182,BIOL,2030,2017,Spring,a
+257,BIOL,2030,2017,Spring,a
+313,BIOL,2030,2017,Spring,a
+374,BIOL,2030,2017,Spring,a
+377,BIOL,2030,2017,Spring,a
+243,BIOL,2030,2017,Spring,b
+246,BIOL,2030,2017,Spring,b
+285,BIOL,2030,2017,Spring,b
+348,BIOL,2030,2017,Spring,b
+372,BIOL,2030,2017,Spring,b
+378,BIOL,2030,2017,Spring,c
+120,BIOL,2030,2017,Spring,d
+285,BIOL,2030,2017,Spring,d
+355,BIOL,2030,2017,Spring,d
+393,BIOL,2030,2017,Spring,d
+230,BIOL,2030,2018,Summer,a
+342,BIOL,2030,2018,Summer,a
+373,BIOL,2030,2018,Summer,a
+101,BIOL,2030,2018,Summer,b
+132,BIOL,2030,2018,Summer,b
+214,BIOL,2030,2018,Summer,b
+276,BIOL,2030,2018,Summer,b
+371,BIOL,2030,2018,Summer,b
+312,BIOL,2030,2019,Summer,a
+318,BIOL,2030,2019,Summer,a
+100,BIOL,2030,2019,Summer,b
+113,BIOL,2030,2019,Summer,b
+173,BIOL,2030,2019,Summer,b
+228,BIOL,2030,2019,Summer,b
+270,BIOL,2030,2019,Summer,b
+309,BIOL,2030,2019,Summer,b
+362,BIOL,2030,2019,Summer,b
+396,BIOL,2030,2019,Summer,b
+109,BIOL,2030,2019,Summer,c
+135,BIOL,2030,2019,Summer,c
+188,BIOL,2030,2019,Summer,c
+247,BIOL,2030,2019,Summer,c
+270,BIOL,2030,2019,Summer,c
+296,BIOL,2030,2019,Summer,c
+320,BIOL,2030,2019,Summer,c
+399,BIOL,2030,2019,Summer,c
+131,BIOL,2030,2019,Summer,d
+143,BIOL,2030,2019,Summer,d
+241,BIOL,2030,2019,Summer,d
+300,BIOL,2030,2019,Summer,d
+345,BIOL,2030,2019,Summer,d
+164,BIOL,2030,2020,Spring,a
+171,BIOL,2030,2020,Spring,a
+366,BIOL,2030,2020,Spring,a
+102,BIOL,2030,2020,Spring,b
+199,BIOL,2030,2020,Spring,b
+311,BIOL,2030,2020,Spring,b
+347,BIOL,2030,2020,Spring,b
+375,BIOL,2030,2020,Spring,b
+243,BIOL,2210,2016,Summer,a
+278,BIOL,2210,2016,Summer,a
+312,BIOL,2210,2016,Summer,a
+356,BIOL,2210,2016,Summer,a
+392,BIOL,2210,2016,Summer,a
+115,BIOL,2210,2017,Spring,a
+231,BIOL,2210,2017,Spring,a
+182,BIOL,2210,2017,Spring,b
+215,BIOL,2210,2017,Spring,b
+255,BIOL,2210,2017,Spring,b
+309,BIOL,2210,2017,Spring,b
+348,BIOL,2210,2017,Spring,b
+107,BIOL,2210,2017,Spring,c
+177,BIOL,2210,2017,Spring,c
+215,BIOL,2210,2017,Spring,c
+277,BIOL,2210,2017,Spring,c
+393,BIOL,2210,2017,Spring,c
+397,BIOL,2210,2017,Spring,c
+151,BIOL,2210,2017,Summer,a
+187,BIOL,2210,2017,Summer,a
+214,BIOL,2210,2017,Summer,a
+257,BIOL,2210,2017,Summer,a
+120,BIOL,2210,2017,Summer,b
+164,BIOL,2210,2017,Summer,b
+259,BIOL,2210,2017,Summer,b
+270,BIOL,2210,2017,Summer,b
+342,BIOL,2210,2017,Summer,b
+378,BIOL,2210,2017,Summer,b
+387,BIOL,2210,2017,Summer,b
+285,BIOL,2210,2017,Summer,c
+374,BIOL,2210,2017,Summer,c
+375,BIOL,2210,2017,Summer,c
+128,BIOL,2210,2018,Spring,a
+275,BIOL,2210,2018,Spring,a
+276,BIOL,2210,2018,Spring,a
+391,BIOL,2210,2018,Spring,a
+131,BIOL,2210,2018,Summer,a
+143,BIOL,2210,2018,Summer,a
+169,BIOL,2210,2018,Summer,a
+174,BIOL,2210,2018,Summer,a
+239,BIOL,2210,2018,Summer,a
+260,BIOL,2210,2018,Summer,a
+298,BIOL,2210,2018,Summer,a
+369,BIOL,2210,2018,Summer,a
+227,BIOL,2210,2018,Summer,b
+230,BIOL,2210,2018,Summer,b
+311,BIOL,2210,2018,Summer,b
+313,BIOL,2210,2018,Summer,b
+173,BIOL,2210,2018,Summer,c
+210,BIOL,2210,2018,Summer,c
+258,BIOL,2210,2018,Summer,c
+102,BIOL,2210,2019,Summer,a
+179,BIOL,2210,2019,Summer,a
+314,BIOL,2210,2019,Summer,a
+329,BIOL,2210,2019,Summer,a
+368,BIOL,2210,2019,Summer,a
+377,BIOL,2210,2019,Summer,a
+119,BIOL,2210,2019,Summer,b
+228,BIOL,2210,2019,Summer,b
+318,BIOL,2210,2019,Summer,b
+386,BIOL,2210,2019,Summer,b
+293,BIOL,2210,2019,Fall,a
+380,BIOL,2210,2019,Fall,a
+289,BIOL,2210,2019,Fall,b
+293,BIOL,2210,2019,Fall,b
+121,BIOL,2210,2020,Fall,a
+185,BIOL,2210,2020,Fall,a
+219,BIOL,2210,2020,Fall,a
+220,BIOL,2210,2020,Fall,a
+240,BIOL,2210,2020,Fall,a
+271,BIOL,2210,2020,Fall,a
+297,BIOL,2210,2020,Fall,a
+347,BIOL,2210,2020,Fall,a
+360,BIOL,2210,2020,Fall,a
+366,BIOL,2210,2020,Fall,a
+371,BIOL,2210,2020,Fall,a
+373,BIOL,2210,2020,Fall,a
+321,BIOL,2325,2015,Spring,a
+182,BIOL,2325,2015,Fall,a
+277,BIOL,2325,2015,Fall,b
+290,BIOL,2325,2015,Fall,b
+379,BIOL,2325,2015,Fall,b
+149,BIOL,2325,2015,Fall,c
+163,BIOL,2325,2015,Fall,c
+192,BIOL,2325,2015,Fall,c
+204,BIOL,2325,2015,Fall,c
+312,BIOL,2325,2015,Fall,c
+138,BIOL,2325,2016,Summer,a
+357,BIOL,2325,2016,Summer,a
+369,BIOL,2325,2016,Summer,a
+394,BIOL,2325,2016,Summer,a
+127,BIOL,2325,2017,Fall,a
+385,BIOL,2325,2017,Fall,a
+102,BIOL,2325,2017,Fall,b
+123,BIOL,2325,2017,Fall,b
+260,BIOL,2325,2017,Fall,b
+296,BIOL,2325,2017,Fall,b
+387,BIOL,2325,2017,Fall,b
+100,BIOL,2325,2018,Spring,a
+105,BIOL,2325,2018,Spring,a
+119,BIOL,2325,2018,Spring,a
+214,BIOL,2325,2018,Spring,a
+332,BIOL,2325,2018,Spring,a
+373,BIOL,2325,2018,Spring,a
+374,BIOL,2325,2018,Spring,a
+132,BIOL,2325,2018,Summer,a
+151,BIOL,2325,2018,Summer,a
+255,BIOL,2325,2018,Summer,a
+262,BIOL,2325,2018,Summer,a
+275,BIOL,2325,2018,Summer,a
+318,BIOL,2325,2018,Summer,a
+386,BIOL,2325,2018,Summer,a
+393,BIOL,2325,2018,Summer,a
+397,BIOL,2325,2018,Summer,a
+124,BIOL,2325,2018,Fall,a
+133,BIOL,2325,2018,Fall,a
+164,BIOL,2325,2018,Fall,a
+220,BIOL,2325,2018,Fall,a
+247,BIOL,2325,2018,Fall,a
+309,BIOL,2325,2018,Fall,a
+129,BIOL,2325,2018,Fall,b
+131,BIOL,2325,2018,Fall,b
+167,BIOL,2325,2018,Fall,b
+129,BIOL,2325,2018,Fall,c
+217,BIOL,2325,2018,Fall,c
+239,BIOL,2325,2018,Fall,c
+274,BIOL,2325,2018,Fall,c
+356,BIOL,2325,2018,Fall,c
+399,BIOL,2325,2018,Fall,c
+152,BIOL,2325,2019,Spring,a
+292,BIOL,2325,2019,Spring,a
+329,BIOL,2325,2019,Spring,a
+333,BIOL,2325,2019,Spring,a
+342,BIOL,2325,2019,Spring,a
+377,BIOL,2325,2019,Spring,a
+391,BIOL,2325,2019,Spring,a
+270,BIOL,2325,2019,Spring,b
+313,BIOL,2325,2019,Spring,b
+314,BIOL,2325,2019,Spring,b
+342,BIOL,2325,2019,Spring,b
+120,BIOL,2325,2019,Summer,a
+135,BIOL,2325,2019,Summer,a
+139,BIOL,2325,2019,Summer,a
+179,BIOL,2325,2019,Summer,a
+276,BIOL,2325,2019,Summer,a
+285,BIOL,2325,2019,Summer,a
+325,BIOL,2325,2019,Summer,a
+290,BIOL,2330,2015,Fall,a
+138,BIOL,2330,2015,Fall,b
+204,BIOL,2330,2015,Fall,d
+312,BIOL,2330,2015,Fall,d
+120,BIOL,2330,2016,Spring,a
+123,BIOL,2330,2016,Spring,a
+195,BIOL,2330,2016,Spring,a
+282,BIOL,2330,2016,Spring,a
+357,BIOL,2330,2016,Spring,a
+377,BIOL,2330,2016,Spring,a
+177,BIOL,2330,2016,Fall,a
+270,BIOL,2330,2016,Fall,a
+291,BIOL,2330,2016,Fall,a
+335,BIOL,2330,2016,Fall,a
+369,BIOL,2330,2016,Fall,a
+393,BIOL,2330,2016,Fall,a
+214,BIOL,2330,2017,Summer,a
+229,BIOL,2330,2017,Summer,a
+277,BIOL,2330,2017,Summer,a
+309,BIOL,2330,2017,Summer,a
+155,BIOL,2330,2017,Fall,a
+165,BIOL,2330,2017,Fall,a
+208,BIOL,2330,2017,Fall,a
+342,BIOL,2330,2017,Fall,a
+355,BIOL,2330,2017,Fall,a
+387,BIOL,2330,2017,Fall,a
+391,BIOL,2330,2017,Fall,a
+187,BIOL,2330,2017,Fall,b
+199,BIOL,2330,2017,Fall,b
+266,BIOL,2330,2017,Fall,b
+288,BIOL,2330,2017,Fall,b
+392,BIOL,2330,2017,Fall,b
+106,BIOL,2330,2019,Fall,a
+125,BIOL,2330,2019,Fall,a
+227,BIOL,2330,2019,Fall,a
+240,BIOL,2330,2019,Fall,a
+307,BIOL,2330,2019,Fall,a
+378,BIOL,2330,2019,Fall,a
+380,BIOL,2330,2019,Fall,a
+183,BIOL,2330,2020,Spring,a
+210,BIOL,2330,2020,Spring,a
+300,BIOL,2330,2020,Spring,a
+340,BIOL,2330,2020,Spring,a
+348,BIOL,2330,2020,Spring,a
+211,BIOL,2355,2015,Spring,a
+192,BIOL,2355,2015,Summer,a
+246,BIOL,2355,2015,Summer,a
+377,BIOL,2355,2015,Summer,a
+144,BIOL,2355,2016,Spring,a
+395,BIOL,2355,2016,Spring,a
+215,BIOL,2355,2016,Spring,b
+321,BIOL,2355,2016,Spring,b
+392,BIOL,2355,2016,Spring,b
+395,BIOL,2355,2016,Spring,b
+105,BIOL,2355,2017,Spring,a
+145,BIOL,2355,2017,Spring,a
+278,BIOL,2355,2017,Spring,a
+290,BIOL,2355,2017,Spring,a
+312,BIOL,2355,2017,Spring,a
+105,BIOL,2355,2017,Spring,b
+270,BIOL,2355,2017,Spring,b
+329,BIOL,2355,2017,Spring,b
+282,BIOL,2355,2017,Spring,c
+299,BIOL,2355,2017,Spring,c
+369,BIOL,2355,2017,Spring,c
+397,BIOL,2355,2017,Spring,c
+102,BIOL,2355,2017,Spring,d
+163,BIOL,2355,2017,Spring,d
+179,BIOL,2355,2017,Spring,d
+243,BIOL,2355,2017,Spring,d
+285,BIOL,2355,2017,Spring,d
+329,BIOL,2355,2017,Spring,d
+374,BIOL,2355,2017,Spring,d
+378,BIOL,2355,2017,Spring,d
+123,BIOL,2355,2017,Summer,a
+318,BIOL,2355,2017,Summer,a
+375,BIOL,2355,2017,Summer,a
+237,BIOL,2355,2017,Fall,a
+335,BIOL,2355,2017,Fall,a
+366,BIOL,2355,2017,Fall,a
+155,BIOL,2355,2017,Fall,b
+182,BIOL,2355,2017,Fall,b
+256,BIOL,2355,2017,Fall,b
+264,BIOL,2355,2017,Fall,b
+373,BIOL,2355,2017,Fall,b
+169,BIOL,2355,2018,Spring,a
+214,BIOL,2355,2018,Spring,a
+230,BIOL,2355,2018,Spring,a
+277,BIOL,2355,2018,Spring,a
+393,BIOL,2355,2018,Spring,a
+119,BIOL,2355,2018,Summer,a
+128,BIOL,2355,2018,Summer,a
+131,BIOL,2355,2018,Summer,a
+185,BIOL,2355,2018,Summer,a
+227,BIOL,2355,2018,Summer,a
+262,BIOL,2355,2018,Summer,a
+332,BIOL,2355,2018,Summer,a
+342,BIOL,2355,2018,Summer,a
+187,BIOL,2355,2018,Summer,b
+276,BIOL,2355,2018,Summer,b
+311,BIOL,2355,2018,Summer,b
+348,BIOL,2355,2018,Summer,b
+379,BIOL,2355,2018,Summer,b
+391,BIOL,2355,2018,Summer,b
+398,BIOL,2355,2018,Summer,b
+113,BIOL,2355,2018,Summer,c
+129,BIOL,2355,2018,Summer,c
+274,BIOL,2355,2018,Summer,c
+275,BIOL,2355,2018,Summer,c
+332,BIOL,2355,2018,Summer,c
+119,BIOL,2355,2018,Summer,d
+207,BIOL,2355,2018,Summer,d
+276,BIOL,2355,2018,Summer,d
+347,BIOL,2355,2018,Summer,d
+379,BIOL,2355,2018,Summer,d
+387,BIOL,2355,2018,Summer,d
+127,BIOL,2355,2018,Fall,a
+292,BIOL,2355,2018,Fall,a
+313,BIOL,2355,2018,Fall,a
+314,BIOL,2355,2018,Fall,a
+359,BIOL,2355,2018,Fall,a
+380,BIOL,2355,2018,Fall,a
+178,BIOL,2355,2019,Spring,a
+247,BIOL,2355,2019,Spring,a
+356,BIOL,2355,2019,Spring,a
+151,BIOL,2355,2019,Spring,b
+372,BIOL,2355,2019,Spring,b
+146,BIOL,2355,2019,Spring,c
+248,BIOL,2355,2019,Spring,c
+255,BIOL,2355,2019,Spring,c
+345,BIOL,2355,2019,Spring,c
+109,BIOL,2355,2019,Spring,d
+107,BIOL,2355,2020,Spring,a
+118,BIOL,2355,2020,Spring,a
+309,BIOL,2355,2020,Spring,a
+362,BIOL,2355,2020,Spring,a
+106,BIOL,2355,2020,Summer,a
+122,BIOL,2355,2020,Summer,a
+221,BIOL,2355,2020,Summer,a
+258,BIOL,2355,2020,Summer,a
+323,BIOL,2355,2020,Summer,a
+333,BIOL,2355,2020,Summer,a
+106,BIOL,2355,2020,Summer,b
+137,BIOL,2355,2020,Summer,b
+177,BIOL,2355,2020,Summer,b
+244,BIOL,2355,2020,Summer,b
+307,BIOL,2355,2020,Summer,b
+325,BIOL,2355,2020,Summer,b
+363,BIOL,2355,2020,Summer,b
+120,BIOL,2355,2020,Fall,a
+124,BIOL,2355,2020,Fall,a
+135,BIOL,2355,2020,Fall,a
+142,BIOL,2355,2020,Fall,a
+167,BIOL,2355,2020,Fall,a
+175,BIOL,2355,2020,Fall,a
+181,BIOL,2355,2020,Fall,a
+186,BIOL,2355,2020,Fall,a
+220,BIOL,2355,2020,Fall,a
+233,BIOL,2355,2020,Fall,a
+271,BIOL,2355,2020,Fall,a
+390,BIOL,2355,2020,Fall,a
+177,BIOL,2420,2015,Spring,a
+246,BIOL,2420,2015,Spring,b
+140,BIOL,2420,2015,Spring,c
+192,BIOL,2420,2015,Spring,d
+374,BIOL,2420,2015,Summer,a
+290,BIOL,2420,2015,Fall,a
+119,BIOL,2420,2016,Spring,a
+162,BIOL,2420,2016,Spring,a
+115,BIOL,2420,2017,Summer,a
+117,BIOL,2420,2017,Summer,a
+132,BIOL,2420,2017,Summer,a
+164,BIOL,2420,2017,Summer,a
+182,BIOL,2420,2017,Summer,a
+229,BIOL,2420,2017,Summer,a
+264,BIOL,2420,2017,Summer,a
+107,BIOL,2420,2017,Summer,b
+123,BIOL,2420,2017,Summer,b
+207,BIOL,2420,2017,Summer,b
+309,BIOL,2420,2017,Summer,b
+348,BIOL,2420,2017,Summer,b
+169,BIOL,2420,2018,Spring,a
+185,BIOL,2420,2018,Spring,a
+270,BIOL,2420,2018,Spring,a
+375,BIOL,2420,2018,Spring,a
+120,BIOL,2420,2020,Spring,a
+210,BIOL,2420,2020,Spring,a
+235,BIOL,2420,2020,Spring,a
+242,BIOL,2420,2020,Spring,a
+248,BIOL,2420,2020,Spring,a
+285,BIOL,2420,2020,Spring,a
+373,BIOL,2420,2020,Spring,a
+397,BIOL,2420,2020,Spring,a
+121,BIOL,2420,2020,Spring,b
+183,BIOL,2420,2020,Spring,b
+230,BIOL,2420,2020,Spring,b
+241,BIOL,2420,2020,Spring,b
+248,BIOL,2420,2020,Spring,b
+365,BIOL,2420,2020,Spring,b
+124,BIOL,2420,2020,Summer,a
+128,BIOL,2420,2020,Summer,a
+131,BIOL,2420,2020,Summer,a
+151,BIOL,2420,2020,Summer,a
+189,BIOL,2420,2020,Summer,a
+200,BIOL,2420,2020,Summer,a
+292,BIOL,2420,2020,Summer,a
+311,BIOL,2420,2020,Summer,a
+313,BIOL,2420,2020,Summer,a
+323,BIOL,2420,2020,Summer,a
+333,BIOL,2420,2020,Summer,a
+347,BIOL,2420,2020,Summer,a
+363,BIOL,2420,2020,Summer,a
+368,BIOL,2420,2020,Summer,a
+122,BIOL,2420,2020,Fall,a
+146,BIOL,2420,2020,Fall,a
+175,BIOL,2420,2020,Fall,a
+224,BIOL,2420,2020,Fall,a
+255,BIOL,2420,2020,Fall,a
+272,BIOL,2420,2020,Fall,a
+321,BIOL,2420,2020,Fall,a
+329,BIOL,2420,2020,Fall,a
+342,BIOL,2420,2020,Fall,a
+391,BIOL,2420,2020,Fall,a
+138,CS,1030,2016,Spring,a
+149,CS,1030,2016,Spring,a
+162,CS,1030,2016,Spring,a
+290,CS,1030,2016,Spring,a
+291,CS,1030,2016,Spring,a
+312,CS,1030,2016,Spring,a
+348,CS,1030,2016,Spring,a
+395,CS,1030,2016,Spring,a
+123,CS,1030,2016,Summer,a
+214,CS,1030,2016,Summer,a
+245,CS,1030,2016,Summer,a
+277,CS,1030,2016,Summer,a
+385,CS,1030,2016,Summer,a
+393,CS,1030,2016,Summer,a
+102,CS,1030,2016,Fall,a
+116,CS,1030,2016,Fall,a
+243,CS,1030,2016,Fall,a
+262,CS,1030,2016,Fall,a
+321,CS,1030,2016,Fall,a
+128,CS,1030,2018,Fall,a
+238,CS,1030,2018,Fall,a
+256,CS,1030,2018,Fall,a
+305,CS,1030,2018,Fall,a
+344,CS,1030,2018,Fall,a
+366,CS,1030,2018,Fall,a
+387,CS,1030,2018,Fall,a
+143,CS,1030,2019,Fall,a
+260,CS,1030,2019,Fall,a
+285,CS,1030,2019,Fall,a
+398,CS,1030,2019,Fall,a
+173,CS,1030,2019,Fall,b
+185,CS,1030,2019,Fall,b
+210,CS,1030,2019,Fall,b
+247,CS,1030,2019,Fall,b
+303,CS,1030,2019,Fall,b
+329,CS,1030,2019,Fall,b
+359,CS,1030,2019,Fall,b
+100,CS,1030,2020,Spring,a
+122,CS,1030,2020,Spring,a
+175,CS,1030,2020,Spring,a
+221,CS,1030,2020,Spring,a
+307,CS,1030,2020,Spring,a
+170,CS,1030,2020,Spring,b
+332,CS,1030,2020,Spring,b
+391,CS,1030,2020,Spring,b
+118,CS,1030,2020,Spring,c
+120,CS,1030,2020,Spring,c
+124,CS,1030,2020,Spring,c
+135,CS,1030,2020,Spring,c
+309,CS,1030,2020,Spring,c
+119,CS,1030,2020,Fall,a
+131,CS,1030,2020,Fall,a
+167,CS,1030,2020,Fall,a
+181,CS,1030,2020,Fall,a
+202,CS,1030,2020,Fall,a
+227,CS,1030,2020,Fall,a
+255,CS,1030,2020,Fall,a
+271,CS,1030,2020,Fall,a
+342,CS,1030,2020,Fall,a
+347,CS,1030,2020,Fall,a
+215,CS,1410,2015,Summer,b
+276,CS,1410,2015,Summer,b
+182,CS,1410,2015,Summer,c
+172,CS,1410,2015,Summer,d
+270,CS,1410,2015,Summer,d
+301,CS,1410,2015,Summer,d
+382,CS,1410,2015,Summer,d
+216,CS,1410,2016,Spring,a
+335,CS,1410,2016,Spring,a
+355,CS,1410,2016,Spring,a
+216,CS,1410,2016,Spring,b
+273,CS,1410,2016,Spring,b
+291,CS,1410,2016,Spring,b
+335,CS,1410,2016,Spring,b
+207,CS,1410,2016,Summer,a
+389,CS,1410,2016,Summer,a
+394,CS,1410,2016,Summer,a
+290,CS,1410,2017,Spring,a
+391,CS,1410,2017,Spring,a
+120,CS,1410,2018,Spring,a
+231,CS,1410,2018,Spring,a
+348,CS,1410,2018,Spring,a
+100,CS,1410,2018,Spring,b
+107,CS,1410,2018,Spring,b
+109,CS,1410,2018,Spring,b
+120,CS,1410,2018,Spring,b
+164,CS,1410,2018,Spring,b
+199,CS,1410,2018,Spring,b
+203,CS,1410,2018,Spring,b
+229,CS,1410,2018,Spring,b
+109,CS,1410,2018,Spring,c
+388,CS,1410,2018,Spring,c
+199,CS,1410,2018,Spring,d
+275,CS,1410,2018,Spring,d
+307,CS,1410,2018,Spring,d
+366,CS,1410,2018,Spring,d
+392,CS,1410,2018,Spring,d
+121,CS,1410,2020,Spring,a
+122,CS,1410,2020,Spring,a
+267,CS,1410,2020,Spring,a
+312,CS,1410,2020,Spring,a
+200,CS,1410,2020,Spring,b
+277,CS,1410,2020,Spring,b
+329,CS,1410,2020,Spring,b
+375,CS,1410,2020,Spring,b
+277,CS,2100,2015,Summer,a
+313,CS,2100,2015,Summer,a
+214,CS,2100,2016,Spring,a
+276,CS,2100,2016,Spring,a
+295,CS,2100,2016,Spring,a
+123,CS,2100,2016,Summer,a
+179,CS,2100,2016,Summer,a
+160,CS,2100,2016,Summer,b
+179,CS,2100,2016,Summer,b
+262,CS,2100,2016,Summer,b
+335,CS,2100,2016,Summer,b
+374,CS,2100,2016,Summer,b
+388,CS,2100,2016,Summer,b
+134,CS,2100,2016,Summer,c
+278,CS,2100,2016,Summer,c
+256,CS,2100,2017,Spring,a
+377,CS,2100,2017,Spring,a
+378,CS,2100,2017,Spring,a
+143,CS,2100,2017,Fall,a
+163,CS,2100,2017,Fall,a
+215,CS,2100,2017,Fall,a
+311,CS,2100,2017,Fall,a
+348,CS,2100,2017,Fall,a
+356,CS,2100,2017,Fall,a
+366,CS,2100,2017,Fall,a
+101,CS,2100,2018,Spring,a
+185,CS,2100,2018,Spring,a
+255,CS,2100,2018,Spring,a
+361,CS,2100,2018,Spring,a
+387,CS,2100,2018,Spring,a
+258,CS,2100,2018,Summer,a
+261,CS,2100,2018,Summer,a
+270,CS,2100,2018,Summer,a
+369,CS,2100,2018,Summer,a
+133,CS,2100,2018,Summer,b
+182,CS,2100,2018,Summer,b
+285,CS,2100,2018,Summer,b
+329,CS,2100,2018,Summer,b
+139,CS,2100,2018,Summer,c
+258,CS,2100,2018,Summer,c
+298,CS,2100,2018,Summer,c
+329,CS,2100,2018,Summer,c
+332,CS,2100,2018,Summer,c
+345,CS,2100,2018,Summer,c
+371,CS,2100,2018,Summer,c
+381,CS,2100,2018,Summer,c
+392,CS,2100,2018,Summer,c
+393,CS,2100,2018,Summer,c
+158,CS,2100,2018,Fall,a
+230,CS,2100,2018,Fall,a
+292,CS,2100,2018,Fall,a
+373,CS,2100,2018,Fall,a
+257,CS,2100,2018,Fall,b
+309,CS,2100,2018,Fall,b
+344,CS,2100,2018,Fall,b
+384,CS,2100,2018,Fall,b
+124,CS,2100,2018,Fall,c
+196,CS,2100,2018,Fall,c
+217,CS,2100,2018,Fall,c
+231,CS,2100,2018,Fall,c
+252,CS,2100,2018,Fall,c
+257,CS,2100,2018,Fall,c
+164,CS,2100,2018,Fall,d
+199,CS,2100,2018,Fall,d
+253,CS,2100,2018,Fall,d
+259,CS,2100,2018,Fall,d
+391,CS,2100,2018,Fall,d
+399,CS,2100,2018,Fall,d
+107,CS,2100,2019,Spring,a
+240,CS,2100,2019,Spring,a
+307,CS,2100,2019,Spring,a
+379,CS,2100,2019,Spring,a
+156,CS,2100,2019,Spring,b
+312,CS,2100,2019,Spring,b
+241,CS,2100,2019,Summer,a
+293,CS,2100,2019,Summer,a
+296,CS,2100,2019,Summer,a
+314,CS,2100,2019,Summer,a
+347,CS,2100,2019,Summer,a
+390,CS,2100,2019,Summer,a
+106,CS,2100,2019,Summer,b
+131,CS,2100,2019,Summer,b
+169,CS,2100,2019,Summer,b
+194,CS,2100,2019,Summer,b
+238,CS,2100,2019,Summer,b
+359,CS,2100,2019,Summer,b
+368,CS,2100,2019,Summer,b
+118,CS,2100,2019,Fall,a
+181,CS,2100,2019,Fall,a
+223,CS,2100,2019,Fall,a
+386,CS,2100,2019,Fall,a
+118,CS,2100,2019,Fall,b
+178,CS,2100,2019,Fall,b
+235,CS,2100,2019,Fall,b
+321,CS,2100,2019,Fall,b
+397,CS,2100,2019,Fall,b
+118,CS,2100,2019,Fall,c
+146,CS,2100,2019,Fall,c
+220,CS,2100,2019,Fall,c
+260,CS,2100,2019,Fall,c
+318,CS,2100,2019,Fall,c
+397,CS,2100,2019,Fall,c
+120,CS,2100,2019,Fall,d
+146,CS,2100,2019,Fall,d
+181,CS,2100,2019,Fall,d
+183,CS,2100,2019,Fall,d
+316,CS,2100,2019,Fall,d
+152,CS,2100,2020,Spring,a
+167,CS,2100,2020,Spring,a
+228,CS,2100,2020,Spring,a
+122,CS,2100,2020,Fall,a
+171,CS,2100,2020,Fall,a
+177,CS,2100,2020,Fall,a
+191,CS,2100,2020,Fall,a
+219,CS,2100,2020,Fall,a
+247,CS,2100,2020,Fall,a
+289,CS,2100,2020,Fall,a
+333,CS,2100,2020,Fall,a
+138,CS,2420,2015,Spring,a
+277,CS,2420,2015,Spring,a
+377,CS,2420,2015,Spring,a
+160,CS,2420,2015,Summer,a
+204,CS,2420,2015,Summer,a
+140,CS,2420,2015,Summer,c
+302,CS,2420,2015,Summer,c
+276,CS,2420,2015,Fall,a
+115,CS,2420,2016,Spring,a
+312,CS,2420,2016,Spring,a
+348,CS,2420,2016,Spring,a
+385,CS,2420,2016,Spring,a
+389,CS,2420,2016,Spring,a
+172,CS,2420,2016,Summer,a
+195,CS,2420,2016,Summer,a
+314,CS,2420,2016,Summer,a
+321,CS,2420,2016,Summer,a
+163,CS,2420,2016,Fall,a
+177,CS,2420,2016,Fall,a
+229,CS,2420,2016,Fall,a
+245,CS,2420,2016,Fall,a
+282,CS,2420,2016,Fall,a
+313,CS,2420,2016,Fall,a
+369,CS,2420,2016,Fall,a
+392,CS,2420,2016,Fall,a
+105,CS,2420,2016,Fall,b
+117,CS,2420,2016,Fall,b
+151,CS,2420,2016,Fall,b
+215,CS,2420,2016,Fall,b
+262,CS,2420,2016,Fall,b
+268,CS,2420,2016,Fall,b
+295,CS,2420,2016,Fall,b
+329,CS,2420,2016,Fall,b
+243,CS,2420,2016,Fall,c
+270,CS,2420,2016,Fall,c
+397,CS,2420,2016,Fall,c
+119,CS,2420,2017,Summer,a
+353,CS,2420,2017,Summer,a
+361,CS,2420,2017,Summer,a
+132,CS,2420,2017,Summer,b
+285,CS,2420,2017,Summer,b
+299,CS,2420,2017,Summer,b
+309,CS,2420,2017,Summer,b
+179,CS,2420,2017,Summer,c
+208,CS,2420,2017,Summer,c
+261,CS,2420,2017,Summer,c
+288,CS,2420,2017,Summer,c
+311,CS,2420,2017,Summer,c
+372,CS,2420,2017,Summer,c
+120,CS,2420,2017,Fall,a
+123,CS,2420,2017,Fall,a
+128,CS,2420,2017,Fall,a
+326,CS,2420,2017,Fall,a
+387,CS,2420,2017,Fall,a
+107,CS,2420,2018,Spring,a
+296,CS,2420,2018,Spring,a
+124,CS,2420,2019,Summer,a
+131,CS,2420,2019,Summer,a
+199,CS,2420,2019,Summer,a
+356,CS,2420,2019,Summer,a
+390,CS,2420,2019,Summer,a
+133,CS,2420,2020,Summer,a
+153,CS,2420,2020,Summer,a
+167,CS,2420,2020,Summer,a
+219,CS,2420,2020,Summer,a
+220,CS,2420,2020,Summer,a
+231,CS,2420,2020,Summer,a
+233,CS,2420,2020,Summer,a
+263,CS,2420,2020,Summer,a
+365,CS,2420,2020,Summer,a
+368,CS,2420,2020,Summer,a
+168,CS,2420,2020,Fall,a
+222,CS,2420,2020,Fall,a
+225,CS,2420,2020,Fall,a
+230,CS,2420,2020,Fall,a
+345,CS,2420,2020,Fall,a
+163,CS,3100,2015,Summer,a
+172,CS,3100,2015,Summer,a
+276,CS,3100,2015,Summer,a
+302,CS,3100,2015,Summer,a
+215,CS,3100,2015,Summer,b
+214,CS,3100,2016,Spring,a
+243,CS,3100,2016,Spring,a
+120,CS,3100,2016,Spring,b
+138,CS,3100,2016,Spring,b
+285,CS,3100,2016,Spring,b
+374,CS,3100,2016,Spring,b
+134,CS,3100,2016,Spring,d
+138,CS,3100,2016,Spring,d
+192,CS,3100,2016,Spring,d
+195,CS,3100,2016,Spring,d
+207,CS,3100,2016,Summer,a
+182,CS,3100,2016,Fall,a
+213,CS,3100,2016,Fall,a
+277,CS,3100,2016,Fall,a
+314,CS,3100,2016,Fall,a
+378,CS,3100,2016,Fall,a
+392,CS,3100,2016,Fall,a
+210,CS,3100,2017,Spring,a
+261,CS,3100,2017,Spring,a
+210,CS,3100,2017,Spring,b
+255,CS,3100,2017,Spring,b
+355,CS,3100,2017,Spring,b
+385,CS,3100,2017,Spring,b
+393,CS,3100,2017,Summer,a
+123,CS,3100,2017,Fall,a
+124,CS,3100,2017,Fall,a
+139,CS,3100,2017,Fall,a
+237,CS,3100,2017,Fall,a
+260,CS,3100,2017,Fall,a
+264,CS,3100,2017,Fall,a
+296,CS,3100,2017,Fall,a
+391,CS,3100,2017,Fall,a
+397,CS,3100,2017,Fall,a
+196,CS,3100,2019,Spring,a
+129,CS,3100,2019,Spring,b
+288,CS,3100,2019,Spring,b
+348,CS,3100,2019,Spring,b
+366,CS,3100,2019,Spring,b
+399,CS,3100,2019,Spring,b
+211,CS,3200,2015,Spring,b
+138,CS,3200,2015,Fall,a
+249,CS,3200,2015,Fall,a
+134,CS,3200,2015,Fall,b
+179,CS,3200,2015,Fall,b
+312,CS,3200,2015,Fall,c
+336,CS,3200,2015,Fall,c
+282,CS,3200,2015,Fall,d
+295,CS,3200,2015,Fall,d
+182,CS,3200,2016,Summer,a
+246,CS,3200,2016,Summer,a
+270,CS,3200,2016,Summer,a
+290,CS,3200,2016,Summer,a
+357,CS,3200,2016,Summer,a
+373,CS,3200,2016,Summer,a
+379,CS,3200,2016,Summer,a
+176,CS,3200,2016,Summer,b
+207,CS,3200,2016,Summer,b
+246,CS,3200,2016,Summer,b
+120,CS,3200,2016,Fall,a
+268,CS,3200,2016,Fall,a
+102,CS,3200,2016,Fall,b
+313,CS,3200,2016,Fall,b
+348,CS,3200,2016,Fall,b
+123,CS,3200,2016,Fall,c
+229,CS,3200,2016,Fall,c
+291,CS,3200,2016,Fall,c
+105,CS,3200,2016,Fall,d
+107,CS,3200,2016,Fall,d
+151,CS,3200,2016,Fall,d
+369,CS,3200,2016,Fall,d
+385,CS,3200,2016,Fall,d
+116,CS,3200,2017,Spring,a
+264,CS,3200,2017,Spring,a
+377,CS,3200,2017,Spring,a
+397,CS,3200,2017,Spring,a
+133,CS,3200,2018,Spring,a
+165,CS,3200,2018,Spring,a
+197,CS,3200,2018,Spring,a
+257,CS,3200,2018,Spring,a
+274,CS,3200,2018,Spring,a
+255,CS,3200,2018,Spring,b
+276,CS,3200,2018,Spring,b
+391,CS,3200,2018,Spring,b
+109,CS,3200,2018,Spring,c
+285,CS,3200,2018,Spring,c
+388,CS,3200,2018,Spring,c
+139,CS,3200,2019,Spring,a
+164,CS,3200,2019,Spring,a
+277,CS,3200,2019,Spring,a
+372,CS,3200,2019,Spring,a
+131,CS,3200,2020,Spring,a
+194,CS,3200,2020,Spring,a
+228,CS,3200,2020,Spring,a
+303,CS,3200,2020,Spring,a
+342,CS,3200,2020,Spring,a
+187,CS,3200,2020,Spring,b
+108,CS,3200,2020,Spring,c
+248,CS,3200,2020,Spring,c
+325,CS,3200,2020,Spring,c
+332,CS,3200,2020,Spring,c
+378,CS,3200,2020,Spring,c
+398,CS,3200,2020,Spring,c
+112,CS,3200,2020,Summer,a
+113,CS,3200,2020,Summer,a
+177,CS,3200,2020,Summer,a
+185,CS,3200,2020,Summer,a
+231,CS,3200,2020,Summer,a
+242,CS,3200,2020,Summer,a
+254,CS,3200,2020,Summer,a
+260,CS,3200,2020,Summer,a
+292,CS,3200,2020,Summer,a
+306,CS,3200,2020,Summer,a
+311,CS,3200,2020,Summer,a
+375,CS,3200,2020,Summer,a
+124,CS,3200,2020,Fall,a
+135,CS,3200,2020,Fall,a
+161,CS,3200,2020,Fall,a
+178,CS,3200,2020,Fall,a
+230,CS,3200,2020,Fall,a
+345,CS,3200,2020,Fall,a
+376,CS,3200,2020,Fall,a
+149,CS,3500,2015,Fall,b
+246,CS,3500,2015,Fall,b
+313,CS,3500,2015,Fall,b
+123,CS,3500,2016,Spring,a
+229,CS,3500,2016,Spring,a
+277,CS,3500,2016,Spring,a
+374,CS,3500,2016,Spring,a
+395,CS,3500,2016,Spring,a
+107,CS,3500,2016,Summer,a
+282,CS,3500,2016,Summer,a
+288,CS,3500,2016,Summer,a
+379,CS,3500,2016,Summer,a
+292,CS,3500,2017,Summer,a
+311,CS,3500,2017,Summer,a
+182,CS,3500,2017,Fall,a
+314,CS,3500,2017,Fall,a
+335,CS,3500,2017,Fall,a
+391,CS,3500,2017,Fall,a
+109,CS,3500,2017,Fall,b
+131,CS,3500,2017,Fall,b
+355,CS,3500,2017,Fall,b
+203,CS,3500,2017,Fall,c
+275,CS,3500,2017,Fall,c
+294,CS,3500,2017,Fall,c
+309,CS,3500,2017,Fall,c
+385,CS,3500,2017,Fall,c
+392,CS,3500,2017,Fall,c
+118,CS,3500,2019,Summer,a
+152,CS,3500,2019,Summer,a
+179,CS,3500,2019,Summer,a
+228,CS,3500,2019,Summer,a
+258,CS,3500,2019,Summer,a
+276,CS,3500,2019,Summer,a
+396,CS,3500,2019,Summer,a
+180,CS,3500,2019,Fall,a
+255,CS,3500,2019,Fall,a
+332,CS,3500,2019,Fall,a
+377,CS,3500,2019,Fall,a
+380,CS,3500,2019,Fall,a
+397,CS,3500,2019,Fall,a
+108,CS,3500,2019,Fall,b
+133,CS,3500,2019,Fall,b
+171,CS,3500,2019,Fall,b
+199,CS,3500,2019,Fall,b
+223,CS,3500,2019,Fall,b
+270,CS,3500,2019,Fall,b
+321,CS,3500,2019,Fall,b
+375,CS,3500,2019,Fall,b
+143,CS,3500,2019,Fall,c
+363,CS,3500,2019,Fall,c
+112,CS,3500,2020,Summer,a
+124,CS,3500,2020,Summer,a
+127,CS,3500,2020,Summer,a
+142,CS,3500,2020,Summer,a
+164,CS,3500,2020,Summer,a
+166,CS,3500,2020,Summer,a
+247,CS,3500,2020,Summer,a
+260,CS,3500,2020,Summer,a
+281,CS,3500,2020,Summer,a
+312,CS,3500,2020,Summer,a
+325,CS,3500,2020,Summer,a
+329,CS,3500,2020,Summer,a
+331,CS,3500,2020,Summer,a
+333,CS,3500,2020,Summer,a
+347,CS,3500,2020,Summer,a
+348,CS,3500,2020,Summer,a
+364,CS,3500,2020,Summer,a
+365,CS,3500,2020,Summer,a
+373,CS,3500,2020,Summer,a
+386,CS,3500,2020,Summer,a
+192,CS,3505,2015,Spring,a
+282,CS,3505,2015,Spring,a
+211,CS,3505,2015,Fall,a
+313,CS,3505,2015,Fall,a
+182,CS,3505,2015,Fall,b
+335,CS,3505,2015,Fall,b
+392,CS,3505,2015,Fall,b
+126,CS,3505,2015,Fall,c
+162,CS,3505,2015,Fall,c
+348,CS,3505,2015,Fall,d
+107,CS,3505,2016,Summer,a
+163,CS,3505,2016,Summer,a
+290,CS,3505,2016,Summer,a
+378,CS,3505,2016,Summer,a
+393,CS,3505,2016,Summer,a
+123,CS,3505,2016,Fall,a
+379,CS,3505,2016,Fall,a
+116,CS,3505,2016,Fall,b
+249,CS,3505,2016,Fall,b
+329,CS,3505,2016,Fall,b
+151,CS,3505,2017,Summer,a
+260,CS,3505,2017,Summer,a
+312,CS,3505,2017,Summer,a
+124,CS,3505,2017,Fall,a
+128,CS,3505,2017,Fall,a
+199,CS,3505,2017,Fall,a
+214,CS,3505,2017,Fall,a
+355,CS,3505,2017,Fall,a
+397,CS,3505,2017,Fall,a
+102,CS,3505,2017,Fall,b
+131,CS,3505,2017,Fall,b
+177,CS,3505,2017,Fall,b
+199,CS,3505,2017,Fall,b
+208,CS,3505,2017,Fall,b
+294,CS,3505,2017,Fall,b
+321,CS,3505,2017,Fall,b
+385,CS,3505,2017,Fall,b
+100,CS,3505,2018,Summer,a
+101,CS,3505,2018,Summer,a
+197,CS,3505,2018,Summer,a
+247,CS,3505,2018,Summer,a
+255,CS,3505,2018,Summer,a
+368,CS,3505,2018,Summer,a
+374,CS,3505,2018,Summer,a
+377,CS,3505,2018,Summer,a
+386,CS,3505,2018,Summer,a
+127,CS,3505,2018,Summer,b
+143,CS,3505,2018,Summer,b
+173,CS,3505,2018,Summer,b
+185,CS,3505,2018,Summer,b
+247,CS,3505,2018,Summer,b
+259,CS,3505,2018,Summer,b
+262,CS,3505,2018,Summer,b
+288,CS,3505,2018,Summer,b
+156,CS,3505,2018,Fall,a
+179,CS,3505,2018,Fall,a
+240,CS,3505,2018,Fall,a
+256,CS,3505,2018,Fall,a
+258,CS,3505,2018,Fall,a
+305,CS,3505,2018,Fall,a
+345,CS,3505,2018,Fall,a
+371,CS,3505,2018,Fall,a
+252,CS,3505,2018,Fall,b
+285,CS,3505,2018,Fall,c
+371,CS,3505,2018,Fall,c
+396,CS,3505,2018,Fall,c
+152,CS,3505,2019,Spring,a
+228,CS,3505,2019,Spring,a
+241,CS,3505,2019,Spring,a
+276,CS,3505,2019,Spring,a
+320,CS,3505,2019,Spring,a
+187,CS,3505,2019,Spring,b
+230,CS,3505,2019,Spring,b
+314,CS,3505,2019,Spring,b
+358,CS,3505,2019,Spring,b
+119,CS,3505,2019,Summer,a
+169,CS,3505,2019,Summer,a
+220,CS,3505,2019,Summer,a
+296,CS,3505,2019,Summer,a
+307,CS,3505,2019,Summer,a
+129,CS,3505,2019,Summer,b
+223,CS,3505,2019,Summer,b
+238,CS,3505,2019,Summer,b
+296,CS,3505,2019,Summer,b
+298,CS,3505,2019,Summer,b
+300,CS,3505,2019,Summer,b
+340,CS,3505,2019,Summer,b
+372,CS,3505,2019,Summer,b
+373,CS,3505,2019,Summer,b
+380,CS,3505,2019,Summer,b
+129,CS,3505,2019,Summer,c
+300,CS,3505,2019,Summer,c
+384,CS,3505,2019,Summer,c
+113,CS,3505,2019,Summer,d
+133,CS,3505,2019,Summer,d
+270,CS,3505,2019,Summer,d
+292,CS,3505,2019,Summer,d
+318,CS,3505,2019,Summer,d
+356,CS,3505,2019,Summer,d
+362,CS,3505,2019,Summer,d
+178,CS,3505,2019,Fall,a
+284,CS,3505,2019,Fall,a
+391,CS,3505,2019,Fall,a
+118,CS,3505,2019,Fall,b
+289,CS,3505,2019,Fall,b
+309,CS,3505,2019,Fall,b
+399,CS,3505,2019,Fall,b
+194,CS,3505,2019,Fall,c
+235,CS,3505,2019,Fall,c
+248,CS,3505,2019,Fall,c
+311,CS,3505,2019,Fall,c
+391,CS,3505,2019,Fall,c
+146,CS,3505,2020,Spring,a
+164,CS,3505,2020,Spring,a
+277,CS,3505,2020,Spring,a
+332,CS,3505,2020,Spring,a
+137,CS,3505,2020,Summer,a
+200,CS,3505,2020,Summer,a
+219,CS,3505,2020,Summer,a
+257,CS,3505,2020,Summer,a
+267,CS,3505,2020,Summer,a
+306,CS,3505,2020,Summer,a
+365,CS,3505,2020,Summer,a
+142,CS,3505,2020,Fall,a
+339,CS,3505,2020,Fall,a
+398,CS,3505,2020,Fall,a
+106,CS,3505,2020,Fall,b
+110,CS,3505,2020,Fall,b
+121,CS,3505,2020,Fall,b
+333,CS,3505,2020,Fall,b
+109,CS,3505,2020,Fall,c
+120,CS,3505,2020,Fall,c
+171,CS,3505,2020,Fall,c
+250,CS,3505,2020,Fall,c
+293,CS,3505,2020,Fall,c
+390,CS,3505,2020,Fall,c
+140,CS,3810,2015,Spring,a
+276,CS,3810,2015,Spring,a
+123,CS,3810,2016,Summer,a
+160,CS,3810,2016,Summer,a
+314,CS,3810,2016,Summer,a
+393,CS,3810,2016,Summer,a
+107,CS,3810,2016,Fall,a
+195,CS,3810,2016,Fall,a
+213,CS,3810,2016,Fall,a
+282,CS,3810,2016,Fall,a
+285,CS,3810,2016,Fall,a
+348,CS,3810,2016,Fall,a
+105,CS,3810,2016,Fall,b
+116,CS,3810,2016,Fall,b
+245,CS,3810,2016,Fall,b
+264,CS,3810,2016,Fall,b
+329,CS,3810,2016,Fall,b
+335,CS,3810,2016,Fall,b
+173,CS,3810,2018,Spring,a
+179,CS,3810,2018,Spring,a
+230,CS,3810,2018,Spring,a
+237,CS,3810,2018,Spring,a
+255,CS,3810,2018,Spring,a
+305,CS,3810,2018,Spring,a
+313,CS,3810,2018,Spring,a
+372,CS,3810,2018,Spring,a
+388,CS,3810,2018,Spring,a
+129,CS,3810,2018,Summer,a
+177,CS,3810,2018,Summer,a
+260,CS,3810,2018,Summer,a
+374,CS,3810,2018,Summer,a
+386,CS,3810,2018,Summer,a
+177,CS,3810,2018,Summer,b
+214,CS,3810,2018,Summer,b
+231,CS,3810,2018,Summer,b
+270,CS,3810,2018,Summer,b
+288,CS,3810,2018,Summer,b
+344,CS,3810,2018,Summer,b
+377,CS,3810,2018,Summer,b
+399,CS,3810,2018,Summer,b
+128,CS,3810,2018,Summer,c
+129,CS,3810,2018,Summer,c
+133,CS,3810,2018,Summer,c
+151,CS,3810,2018,Summer,c
+240,CS,3810,2018,Summer,c
+257,CS,3810,2018,Summer,c
+311,CS,3810,2018,Summer,c
+182,CS,3810,2018,Summer,d
+210,CS,3810,2018,Summer,d
+252,CS,3810,2018,Summer,d
+270,CS,3810,2018,Summer,d
+312,CS,3810,2018,Summer,d
+356,CS,3810,2018,Summer,d
+379,CS,3810,2018,Summer,d
+127,CS,3810,2019,Fall,a
+131,CS,3810,2019,Fall,a
+241,CS,3810,2019,Fall,a
+258,CS,3810,2019,Fall,a
+333,CS,3810,2019,Fall,a
+102,CS,3810,2019,Fall,b
+359,CS,3810,2019,Fall,b
+113,CS,3810,2020,Fall,a
+124,CS,3810,2020,Fall,a
+171,CS,3810,2020,Fall,a
+187,CS,3810,2020,Fall,a
+220,CS,3810,2020,Fall,a
+225,CS,3810,2020,Fall,a
+233,CS,3810,2020,Fall,a
+340,CS,3810,2020,Fall,a
+347,CS,3810,2020,Fall,a
+193,CS,4000,2015,Spring,a
+160,CS,4000,2015,Summer,a
+282,CS,4000,2015,Fall,a
+307,CS,4000,2015,Fall,a
+138,CS,4000,2016,Fall,a
+276,CS,4000,2016,Fall,a
+321,CS,4000,2016,Fall,a
+378,CS,4000,2016,Fall,a
+393,CS,4000,2016,Fall,a
+151,CS,4000,2017,Spring,a
+187,CS,4000,2017,Spring,a
+207,CS,4000,2017,Spring,a
+255,CS,4000,2017,Spring,a
+134,CS,4000,2017,Summer,a
+139,CS,4000,2017,Summer,a
+179,CS,4000,2017,Summer,a
+259,CS,4000,2017,Summer,a
+318,CS,4000,2017,Summer,a
+373,CS,4000,2017,Summer,a
+107,CS,4000,2017,Fall,a
+163,CS,4000,2017,Fall,a
+252,CS,4000,2017,Fall,a
+262,CS,4000,2017,Fall,a
+291,CS,4000,2017,Fall,a
+342,CS,4000,2017,Fall,a
+361,CS,4000,2017,Fall,a
+163,CS,4000,2017,Fall,b
+329,CS,4000,2017,Fall,b
+345,CS,4000,2017,Fall,b
+361,CS,4000,2017,Fall,b
+164,CS,4000,2018,Spring,a
+173,CS,4000,2018,Spring,a
+203,CS,4000,2018,Spring,a
+275,CS,4000,2018,Spring,a
+313,CS,4000,2018,Spring,a
+385,CS,4000,2018,Spring,a
+127,CS,4000,2019,Spring,a
+256,CS,4000,2019,Spring,a
+169,CS,4000,2020,Spring,a
+181,CS,4000,2020,Spring,a
+254,CS,4000,2020,Spring,a
+257,CS,4000,2020,Spring,a
+285,CS,4000,2020,Spring,a
+312,CS,4000,2020,Spring,a
+364,CS,4000,2020,Spring,a
+375,CS,4000,2020,Spring,a
+386,CS,4000,2020,Spring,a
+123,CS,4000,2020,Spring,b
+152,CS,4000,2020,Spring,b
+181,CS,4000,2020,Spring,b
+257,CS,4000,2020,Spring,b
+309,CS,4000,2020,Spring,b
+311,CS,4000,2020,Spring,b
+371,CS,4000,2020,Spring,b
+109,CS,4000,2020,Fall,a
+110,CS,4000,2020,Fall,a
+118,CS,4000,2020,Fall,a
+120,CS,4000,2020,Fall,a
+131,CS,4000,2020,Fall,a
+161,CS,4000,2020,Fall,a
+185,CS,4000,2020,Fall,a
+277,CS,4000,2020,Fall,a
+292,CS,4000,2020,Fall,a
+341,CS,4000,2020,Fall,a
+348,CS,4000,2020,Fall,a
+366,CS,4000,2020,Fall,a
+368,CS,4000,2020,Fall,a
+376,CS,4000,2020,Fall,a
+397,CS,4000,2020,Fall,a
+162,CS,4150,2015,Summer,a
+176,CS,4150,2015,Summer,a
+192,CS,4150,2015,Summer,a
+204,CS,4150,2015,Summer,a
+348,CS,4150,2015,Summer,b
+163,CS,4150,2016,Summer,a
+245,CS,4150,2016,Summer,a
+249,CS,4150,2016,Summer,a
+378,CS,4150,2016,Summer,a
+249,CS,4150,2016,Summer,b
+264,CS,4150,2016,Summer,b
+285,CS,4150,2016,Summer,b
+288,CS,4150,2016,Summer,b
+131,CS,4150,2018,Fall,a
+240,CS,4150,2018,Fall,a
+270,CS,4150,2018,Fall,a
+292,CS,4150,2018,Fall,a
+362,CS,4150,2018,Fall,a
+391,CS,4150,2018,Fall,a
+255,CS,4150,2018,Fall,b
+371,CS,4150,2018,Fall,b
+102,CS,4150,2019,Spring,a
+210,CS,4150,2019,Spring,a
+260,CS,4150,2019,Spring,a
+106,CS,4150,2020,Spring,a
+120,CS,4150,2020,Spring,a
+123,CS,4150,2020,Spring,a
+125,CS,4150,2020,Spring,a
+179,CS,4150,2020,Spring,a
+277,CS,4150,2020,Spring,a
+314,CS,4150,2020,Spring,a
+396,CS,4150,2020,Spring,a
+397,CS,4150,2020,Spring,a
+135,CS,4150,2020,Fall,a
+148,CS,4150,2020,Fall,a
+235,CS,4150,2020,Fall,a
+309,CS,4150,2020,Fall,a
+329,CS,4150,2020,Fall,a
+339,CS,4150,2020,Fall,a
+347,CS,4150,2020,Fall,a
+386,CS,4150,2020,Fall,a
+120,CS,4400,2015,Summer,a
+140,CS,4400,2015,Summer,a
+215,CS,4400,2015,Summer,a
+277,CS,4400,2015,Summer,a
+290,CS,4400,2015,Summer,a
+392,CS,4400,2015,Fall,b
+282,CS,4400,2015,Fall,c
+373,CS,4400,2015,Fall,c
+149,CS,4400,2016,Spring,a
+307,CS,4400,2016,Spring,a
+179,CS,4400,2016,Summer,a
+262,CS,4400,2016,Summer,a
+138,CS,4400,2016,Fall,a
+102,CS,4400,2017,Spring,a
+246,CS,4400,2017,Spring,a
+249,CS,4400,2017,Spring,a
+329,CS,4400,2017,Spring,a
+369,CS,4400,2017,Spring,a
+231,CS,4400,2017,Spring,b
+255,CS,4400,2017,Spring,b
+309,CS,4400,2017,Spring,b
+276,CS,4400,2017,Spring,c
+313,CS,4400,2017,Spring,c
+388,CS,4400,2017,Spring,c
+321,CS,4400,2019,Spring,a
+333,CS,4400,2019,Spring,a
+379,CS,4400,2019,Spring,a
+109,CS,4400,2019,Spring,b
+128,CS,4400,2019,Spring,b
+151,CS,4400,2019,Spring,b
+275,CS,4400,2019,Spring,b
+169,CS,4400,2019,Spring,c
+187,CS,4400,2019,Spring,c
+248,CS,4400,2019,Spring,c
+257,CS,4400,2019,Spring,d
+312,CS,4400,2019,Spring,d
+345,CS,4400,2019,Spring,d
+146,CS,4400,2019,Summer,a
+167,CS,4400,2019,Summer,a
+173,CS,4400,2019,Summer,a
+234,CS,4400,2019,Summer,a
+285,CS,4400,2019,Summer,a
+287,CS,4400,2019,Summer,a
+294,CS,4400,2019,Summer,a
+325,CS,4400,2019,Summer,a
+397,CS,4400,2019,Summer,a
+398,CS,4400,2019,Summer,a
+135,CS,4400,2019,Summer,b
+143,CS,4400,2019,Summer,b
+177,CS,4400,2019,Summer,b
+267,CS,4400,2019,Summer,b
+285,CS,4400,2019,Summer,b
+298,CS,4400,2019,Summer,b
+332,CS,4400,2019,Summer,b
+368,CS,4400,2019,Summer,b
+391,CS,4400,2019,Summer,b
+183,CS,4400,2019,Fall,a
+241,CS,4400,2019,Fall,a
+124,CS,4400,2019,Fall,b
+259,CS,4400,2019,Fall,b
+364,CS,4400,2019,Fall,b
+377,CS,4400,2019,Fall,b
+113,CS,4400,2020,Spring,a
+170,CS,4400,2020,Spring,a
+199,CS,4400,2020,Spring,a
+228,CS,4400,2020,Spring,a
+348,CS,4400,2020,Spring,a
+390,CS,4400,2020,Spring,a
+119,CS,4400,2020,Fall,a
+123,CS,4400,2020,Fall,a
+131,CS,4400,2020,Fall,a
+152,CS,4400,2020,Fall,a
+230,CS,4400,2020,Fall,a
+258,CS,4400,2020,Fall,a
+272,CS,4400,2020,Fall,a
+378,CS,4400,2020,Fall,a
+106,CS,4400,2020,Fall,b
+127,CS,4400,2020,Fall,b
+185,CS,4400,2020,Fall,b
+202,CS,4400,2020,Fall,b
+235,CS,4400,2020,Fall,b
+292,CS,4400,2020,Fall,b
+340,CS,4400,2020,Fall,b
+276,CS,4500,2015,Summer,a
+290,CS,4500,2015,Summer,b
+215,CS,4500,2016,Spring,a
+317,CS,4500,2016,Spring,a
+119,CS,4500,2016,Spring,b
+138,CS,4500,2016,Spring,b
+149,CS,4500,2016,Spring,b
+162,CS,4500,2016,Spring,b
+179,CS,4500,2016,Spring,b
+215,CS,4500,2016,Spring,b
+285,CS,4500,2016,Spring,b
+301,CS,4500,2016,Spring,b
+307,CS,4500,2016,Spring,b
+321,CS,4500,2016,Spring,b
+357,CS,4500,2016,Spring,b
+117,CS,4500,2016,Fall,a
+176,CS,4500,2016,Fall,a
+177,CS,4500,2016,Fall,a
+309,CS,4500,2016,Fall,a
+139,CS,4500,2017,Summer,a
+207,CS,4500,2017,Summer,a
+335,CS,4500,2017,Summer,a
+348,CS,4500,2017,Summer,a
+378,CS,4500,2017,Summer,a
+101,CS,4500,2018,Spring,a
+128,CS,4500,2018,Spring,a
+132,CS,4500,2018,Spring,a
+182,CS,4500,2018,Spring,a
+203,CS,4500,2018,Spring,a
+231,CS,4500,2018,Spring,a
+294,CS,4500,2018,Spring,a
+329,CS,4500,2018,Spring,a
+361,CS,4500,2018,Spring,a
+132,CS,4500,2018,Spring,b
+270,CS,4500,2018,Spring,b
+305,CS,4500,2018,Spring,b
+318,CS,4500,2018,Spring,b
+379,CS,4500,2018,Spring,b
+133,CS,4500,2018,Spring,c
+164,CS,4500,2018,Spring,c
+312,CS,4500,2018,Spring,c
+369,CS,4500,2018,Spring,c
+128,CS,4500,2018,Spring,d
+313,CS,4500,2018,Spring,d
+345,CS,4500,2018,Spring,d
+366,CS,4500,2018,Spring,d
+391,CS,4500,2018,Spring,d
+107,CS,4500,2019,Summer,a
+123,CS,4500,2019,Summer,a
+185,CS,4500,2019,Summer,a
+248,CS,4500,2019,Summer,a
+333,CS,4500,2019,Summer,a
+340,CS,4500,2019,Summer,a
+371,CS,4500,2019,Summer,a
+386,CS,4500,2019,Summer,a
+256,CS,4500,2019,Fall,a
+260,CS,4500,2019,Fall,a
+293,CS,4500,2019,Fall,a
+303,CS,4500,2019,Fall,a
+131,CS,4500,2019,Fall,b
+173,CS,4500,2019,Fall,b
+250,CS,4500,2019,Fall,b
+255,CS,4500,2019,Fall,b
+300,CS,4500,2019,Fall,b
+398,CS,4500,2019,Fall,b
+131,CS,4500,2019,Fall,c
+143,CS,4500,2019,Fall,c
+256,CS,4500,2019,Fall,c
+274,CS,4500,2019,Fall,c
+316,CS,4500,2019,Fall,c
+109,CS,4500,2019,Fall,d
+194,CS,4500,2019,Fall,d
+220,CS,4500,2019,Fall,d
+254,CS,4500,2019,Fall,d
+255,CS,4500,2019,Fall,d
+296,CS,4500,2019,Fall,d
+341,CS,4500,2019,Fall,d
+365,CS,4500,2019,Fall,d
+108,CS,4500,2020,Spring,a
+142,CS,4500,2020,Spring,a
+169,CS,4500,2020,Spring,a
+200,CS,4500,2020,Spring,a
+364,CS,4500,2020,Spring,a
+373,CS,4500,2020,Spring,a
+127,CS,4500,2020,Summer,a
+152,CS,4500,2020,Summer,a
+167,CS,4500,2020,Summer,a
+240,CS,4500,2020,Summer,a
+368,CS,4500,2020,Summer,a
+397,CS,4500,2020,Summer,a
+138,CS,4940,2015,Summer,a
+117,CS,4940,2017,Fall,a
+143,CS,4940,2017,Fall,a
+260,CS,4940,2017,Fall,a
+294,CS,4940,2017,Fall,a
+311,CS,4940,2017,Fall,a
+326,CS,4940,2017,Fall,a
+119,CS,4940,2017,Fall,b
+379,CS,4940,2017,Fall,b
+167,CS,4940,2019,Fall,a
+220,CS,4940,2019,Fall,a
+255,CS,4940,2019,Fall,a
+256,CS,4940,2019,Fall,a
+285,CS,4940,2019,Fall,a
+314,CS,4940,2019,Fall,a
+398,CS,4940,2019,Fall,a
+100,CS,4940,2020,Summer,a
+170,CS,4940,2020,Summer,a
+200,CS,4940,2020,Summer,a
+228,CS,4940,2020,Summer,a
+251,CS,4940,2020,Summer,a
+258,CS,4940,2020,Summer,a
+277,CS,4940,2020,Summer,a
+292,CS,4940,2020,Summer,a
+313,CS,4940,2020,Summer,a
+331,CS,4940,2020,Summer,a
+362,CS,4940,2020,Summer,a
+378,CS,4940,2020,Summer,a
+386,CS,4940,2020,Summer,a
+391,CS,4940,2020,Summer,a
+397,CS,4940,2020,Summer,a
+100,CS,4940,2020,Summer,b
+123,CS,4940,2020,Summer,b
+127,CS,4940,2020,Summer,b
+171,CS,4940,2020,Summer,b
+177,CS,4940,2020,Summer,b
+194,CS,4940,2020,Summer,b
+231,CS,4940,2020,Summer,b
+233,CS,4940,2020,Summer,b
+247,CS,4940,2020,Summer,b
+250,CS,4940,2020,Summer,b
+251,CS,4940,2020,Summer,b
+258,CS,4940,2020,Summer,b
+271,CS,4940,2020,Summer,b
+277,CS,4940,2020,Summer,b
+300,CS,4940,2020,Summer,b
+312,CS,4940,2020,Summer,b
+321,CS,4940,2020,Summer,b
+339,CS,4940,2020,Summer,b
+345,CS,4940,2020,Summer,b
+391,CS,4940,2020,Summer,b
+397,CS,4940,2020,Summer,b
+107,CS,4970,2016,Fall,a
+123,CS,4970,2016,Fall,a
+145,CS,4970,2016,Fall,a
+268,CS,4970,2016,Fall,a
+276,CS,4970,2016,Fall,a
+285,CS,4970,2016,Fall,a
+335,CS,4970,2016,Fall,a
+394,CS,4970,2016,Fall,a
+177,CS,4970,2016,Fall,b
+179,CS,4970,2016,Fall,b
+249,CS,4970,2016,Fall,b
+276,CS,4970,2016,Fall,b
+285,CS,4970,2016,Fall,b
+291,CS,4970,2016,Fall,b
+312,CS,4970,2016,Fall,b
+313,CS,4970,2016,Fall,b
+397,CS,4970,2016,Fall,b
+116,CS,4970,2017,Spring,a
+120,CS,4970,2017,Spring,a
+282,CS,4970,2017,Spring,a
+295,CS,4970,2017,Spring,a
+314,CS,4970,2017,Spring,a
+393,CS,4970,2017,Spring,a
+117,CS,4970,2017,Summer,a
+261,CS,4970,2017,Summer,a
+288,CS,4970,2017,Summer,a
+231,CS,4970,2018,Summer,a
+270,CS,4970,2018,Summer,a
+277,CS,4970,2018,Summer,a
+344,CS,4970,2018,Summer,a
+398,CS,4970,2018,Summer,a
+100,CS,4970,2018,Summer,b
+105,CS,4970,2018,Summer,b
+132,CS,4970,2018,Summer,b
+227,CS,4970,2018,Summer,b
+277,CS,4970,2018,Summer,b
+348,CS,4970,2018,Summer,b
+133,CS,4970,2018,Summer,c
+163,CS,4970,2018,Summer,c
+185,CS,4970,2018,Summer,c
+214,CS,4970,2018,Summer,c
+220,CS,4970,2018,Summer,c
+372,CS,4970,2018,Summer,c
+387,CS,4970,2018,Summer,c
+392,CS,4970,2018,Summer,c
+274,CS,4970,2018,Fall,a
+128,CS,4970,2018,Fall,b
+247,CS,4970,2018,Fall,b
+262,CS,4970,2018,Fall,b
+267,CS,4970,2018,Fall,b
+386,CS,4970,2018,Fall,b
+121,CS,4970,2018,Fall,c
+143,CS,4970,2018,Fall,c
+196,CS,4970,2018,Fall,c
+102,CS,4970,2018,Fall,d
+121,CS,4970,2018,Fall,d
+178,CS,4970,2018,Fall,d
+255,CS,4970,2018,Fall,d
+267,CS,4970,2018,Fall,d
+342,CS,4970,2018,Fall,d
+356,CS,4970,2018,Fall,d
+165,CS,4970,2019,Spring,a
+275,CS,4970,2019,Spring,a
+351,CS,4970,2019,Spring,a
+366,CS,4970,2019,Spring,a
+311,CS,4970,2019,Spring,b
+345,CS,4970,2019,Spring,b
+364,CS,4970,2019,Spring,b
+124,CS,4970,2019,Summer,a
+199,CS,4970,2019,Summer,a
+289,CS,4970,2019,Summer,a
+300,CS,4970,2019,Summer,a
+368,CS,4970,2019,Summer,a
+378,CS,4970,2019,Summer,a
+113,CS,4970,2019,Summer,b
+164,CS,4970,2019,Summer,b
+298,CS,4970,2019,Summer,b
+325,CS,4970,2019,Summer,b
+359,CS,4970,2019,Summer,b
+378,CS,4970,2019,Summer,b
+391,CS,4970,2019,Summer,b
+173,CS,4970,2019,Summer,c
+333,CS,4970,2019,Summer,c
+363,CS,4970,2019,Summer,c
+119,CS,4970,2019,Summer,d
+135,CS,4970,2019,Summer,d
+164,CS,4970,2019,Summer,d
+294,CS,4970,2019,Summer,d
+303,CS,4970,2019,Summer,d
+329,CS,4970,2019,Summer,d
+362,CS,4970,2019,Summer,d
+399,CS,4970,2019,Summer,d
+194,CS,4970,2019,Fall,a
+235,CS,4970,2019,Fall,a
+250,CS,4970,2019,Fall,a
+127,CS,4970,2019,Fall,b
+131,CS,4970,2019,Fall,b
+293,CS,4970,2019,Fall,b
+321,CS,4970,2019,Fall,b
+152,CS,4970,2019,Fall,c
+200,CS,4970,2019,Fall,c
+259,CS,4970,2019,Fall,c
+318,CS,4970,2019,Fall,d
+340,CS,4970,2019,Fall,d
+347,CS,4970,2019,Fall,d
+112,CS,4970,2020,Summer,a
+221,CS,4970,2020,Summer,a
+242,CS,4970,2020,Summer,a
+251,CS,4970,2020,Summer,a
+257,CS,4970,2020,Summer,a
+118,CS,4970,2020,Summer,b
+151,CS,4970,2020,Summer,b
+187,CS,4970,2020,Summer,b
+219,CS,4970,2020,Summer,b
+221,CS,4970,2020,Summer,b
+222,CS,4970,2020,Summer,b
+309,CS,4970,2020,Summer,b
+373,CS,4970,2020,Summer,b
+379,CS,4970,2020,Summer,b
+146,CS,4970,2020,Summer,c
+233,CS,4970,2020,Summer,c
+257,CS,4970,2020,Summer,c
+260,CS,4970,2020,Summer,c
+292,CS,4970,2020,Summer,c
+339,CS,4970,2020,Summer,c
+379,CS,4970,2020,Summer,c
+384,CS,4970,2020,Summer,c
+109,CS,4970,2020,Summer,d
+146,CS,4970,2020,Summer,d
+151,CS,4970,2020,Summer,d
+171,CS,4970,2020,Summer,d
+228,CS,4970,2020,Summer,d
+254,CS,4970,2020,Summer,d
+307,CS,4970,2020,Summer,d
+309,CS,4970,2020,Summer,d
+379,CS,4970,2020,Summer,d
+390,CS,4970,2020,Summer,d
+122,CS,4970,2020,Fall,a
+191,CS,4970,2020,Fall,a
+136,CS,4970,2020,Fall,b
+283,CS,4970,2020,Fall,b
+130,CS,4970,2020,Fall,c
+148,CS,4970,2020,Fall,c
+281,CS,4970,2020,Fall,c
+186,CS,4970,2020,Fall,d
+202,CS,4970,2020,Fall,d
+323,CS,4970,2020,Fall,d
+341,CS,4970,2020,Fall,d
+120,MATH,1210,2015,Summer,a
+138,MATH,1210,2015,Summer,a
+117,MATH,1210,2016,Spring,a
+119,MATH,1210,2016,Spring,a
+144,MATH,1210,2016,Spring,a
+270,MATH,1210,2016,Spring,a
+276,MATH,1210,2016,Spring,a
+229,MATH,1210,2016,Spring,b
+295,MATH,1210,2016,Spring,b
+335,MATH,1210,2016,Spring,b
+182,MATH,1210,2016,Spring,c
+277,MATH,1210,2016,Spring,c
+179,MATH,1210,2016,Spring,d
+273,MATH,1210,2016,Spring,d
+277,MATH,1210,2016,Spring,d
+295,MATH,1210,2016,Spring,d
+214,MATH,1210,2016,Fall,a
+249,MATH,1210,2016,Fall,a
+397,MATH,1210,2016,Fall,a
+215,MATH,1210,2016,Fall,b
+278,MATH,1210,2016,Fall,b
+357,MATH,1210,2016,Fall,b
+378,MATH,1210,2016,Fall,b
+107,MATH,1210,2016,Fall,c
+195,MATH,1210,2016,Fall,c
+285,MATH,1210,2016,Fall,c
+369,MATH,1210,2016,Fall,c
+379,MATH,1210,2016,Fall,c
+195,MATH,1210,2016,Fall,d
+385,MATH,1210,2016,Fall,d
+356,MATH,1210,2017,Spring,a
+394,MATH,1210,2017,Spring,a
+345,MATH,1210,2017,Summer,a
+230,MATH,1210,2017,Summer,b
+210,MATH,1210,2017,Summer,c
+342,MATH,1210,2017,Summer,c
+387,MATH,1210,2017,Summer,c
+392,MATH,1210,2017,Summer,c
+102,MATH,1210,2018,Spring,a
+199,MATH,1210,2018,Spring,a
+372,MATH,1210,2018,Spring,a
+257,MATH,1210,2018,Summer,a
+279,MATH,1210,2018,Summer,a
+288,MATH,1210,2018,Summer,a
+368,MATH,1210,2018,Summer,a
+371,MATH,1210,2018,Summer,a
+398,MATH,1210,2018,Summer,a
+167,MATH,1210,2018,Fall,a
+177,MATH,1210,2018,Fall,a
+185,MATH,1210,2018,Fall,a
+231,MATH,1210,2018,Fall,a
+311,MATH,1210,2018,Fall,a
+312,MATH,1210,2018,Fall,a
+384,MATH,1210,2018,Fall,a
+104,MATH,1210,2018,Fall,b
+128,MATH,1210,2018,Fall,b
+163,MATH,1210,2018,Fall,b
+178,MATH,1210,2018,Fall,b
+133,MATH,1210,2019,Spring,a
+294,MATH,1210,2019,Spring,a
+307,MATH,1210,2019,Spring,a
+332,MATH,1210,2019,Spring,a
+333,MATH,1210,2019,Spring,a
+348,MATH,1210,2019,Spring,a
+351,MATH,1210,2019,Spring,a
+275,MATH,1210,2019,Spring,b
+123,MATH,1210,2019,Summer,a
+124,MATH,1210,2019,Summer,a
+228,MATH,1210,2019,Summer,a
+255,MATH,1210,2019,Summer,a
+313,MATH,1210,2019,Summer,a
+135,MATH,1210,2020,Spring,a
+220,MATH,1210,2020,Spring,a
+310,MATH,1210,2020,Spring,a
+373,MATH,1210,2020,Spring,a
+390,MATH,1210,2020,Spring,a
+106,MATH,1210,2020,Spring,b
+108,MATH,1210,2020,Spring,b
+260,MATH,1210,2020,Spring,b
+386,MATH,1210,2020,Spring,b
+192,MATH,1220,2015,Summer,a
+211,MATH,1220,2015,Summer,a
+162,MATH,1220,2015,Summer,b
+270,MATH,1220,2015,Summer,b
+280,MATH,1220,2015,Summer,b
+195,MATH,1220,2015,Summer,c
+245,MATH,1220,2015,Summer,c
+282,MATH,1220,2015,Summer,c
+377,MATH,1220,2015,Summer,c
+210,MATH,1220,2016,Spring,a
+307,MATH,1220,2016,Spring,a
+313,MATH,1220,2016,Spring,a
+357,MATH,1220,2016,Spring,a
+389,MATH,1220,2016,Spring,a
+116,MATH,1220,2017,Spring,a
+187,MATH,1220,2017,Spring,a
+256,MATH,1220,2017,Spring,a
+299,MATH,1220,2017,Spring,a
+117,MATH,1220,2017,Spring,b
+163,MATH,1220,2017,Spring,b
+179,MATH,1220,2017,Spring,b
+182,MATH,1220,2017,Spring,b
+259,MATH,1220,2017,Spring,b
+260,MATH,1220,2017,Spring,b
+285,MATH,1220,2017,Spring,b
+314,MATH,1220,2017,Spring,b
+388,MATH,1220,2017,Spring,b
+393,MATH,1220,2017,Spring,b
+117,MATH,1220,2017,Spring,c
+145,MATH,1220,2017,Spring,c
+277,MATH,1220,2017,Spring,c
+355,MATH,1220,2017,Spring,c
+385,MATH,1220,2017,Spring,c
+105,MATH,1220,2017,Spring,d
+260,MATH,1220,2017,Spring,d
+378,MATH,1220,2017,Spring,d
+215,MATH,1220,2017,Summer,a
+165,MATH,1220,2018,Spring,a
+173,MATH,1220,2018,Spring,a
+276,MATH,1220,2018,Spring,a
+312,MATH,1220,2018,Spring,a
+332,MATH,1220,2018,Spring,a
+375,MATH,1220,2018,Spring,a
+131,MATH,1220,2018,Spring,b
+169,MATH,1220,2018,Spring,b
+309,MATH,1220,2018,Spring,b
+362,MATH,1220,2018,Spring,b
+139,MATH,1220,2018,Summer,a
+185,MATH,1220,2018,Summer,a
+348,MATH,1220,2018,Summer,a
+127,MATH,1220,2019,Fall,a
+133,MATH,1220,2019,Fall,a
+181,MATH,1220,2019,Fall,a
+231,MATH,1220,2019,Fall,a
+234,MATH,1220,2019,Fall,a
+248,MATH,1220,2019,Fall,a
+254,MATH,1220,2019,Fall,a
+323,MATH,1220,2019,Fall,a
+341,MATH,1220,2019,Fall,a
+102,MATH,1220,2019,Fall,b
+120,MATH,1220,2019,Fall,b
+123,MATH,1220,2019,Fall,b
+152,MATH,1220,2019,Fall,b
+180,MATH,1220,2019,Fall,b
+274,MATH,1220,2019,Fall,b
+321,MATH,1220,2019,Fall,b
+366,MATH,1220,2019,Fall,b
+135,MATH,1220,2019,Fall,c
+247,MATH,1220,2019,Fall,c
+358,MATH,1220,2019,Fall,c
+390,MATH,1220,2019,Fall,c
+396,MATH,1220,2019,Fall,c
+100,MATH,1220,2020,Spring,a
+151,MATH,1220,2020,Spring,a
+178,MATH,1220,2020,Spring,a
+228,MATH,1220,2020,Spring,a
+118,MATH,1220,2020,Summer,a
+164,MATH,1220,2020,Summer,a
+281,MATH,1220,2020,Summer,a
+293,MATH,1220,2020,Summer,a
+329,MATH,1220,2020,Summer,a
+397,MATH,1220,2020,Summer,a
+211,MATH,1250,2015,Spring,c
+276,MATH,1250,2015,Spring,c
+149,MATH,1250,2015,Fall,a
+172,MATH,1250,2015,Fall,a
+335,MATH,1250,2015,Fall,a
+214,MATH,1250,2016,Spring,a
+290,MATH,1250,2016,Spring,a
+377,MATH,1250,2016,Spring,a
+270,MATH,1250,2016,Summer,a
+285,MATH,1250,2016,Summer,a
+373,MATH,1250,2016,Summer,a
+215,MATH,1250,2016,Fall,a
+138,MATH,1250,2016,Fall,b
+182,MATH,1250,2016,Fall,b
+120,MATH,1250,2016,Fall,c
+374,MATH,1250,2016,Fall,c
+127,MATH,1250,2017,Summer,a
+173,MATH,1250,2017,Summer,a
+292,MATH,1250,2017,Summer,a
+355,MATH,1250,2017,Summer,a
+127,MATH,1250,2017,Summer,b
+210,MATH,1250,2017,Summer,b
+311,MATH,1250,2017,Summer,b
+230,MATH,1250,2017,Summer,c
+257,MATH,1250,2017,Summer,c
+117,MATH,1250,2017,Summer,d
+208,MATH,1250,2017,Summer,d
+109,MATH,1250,2018,Spring,a
+123,MATH,1250,2018,Spring,a
+260,MATH,1250,2018,Spring,a
+274,MATH,1250,2018,Spring,a
+345,MATH,1250,2018,Spring,a
+361,MATH,1250,2018,Spring,a
+379,MATH,1250,2018,Spring,a
+385,MATH,1250,2018,Spring,a
+392,MATH,1250,2018,Spring,a
+102,MATH,1250,2018,Summer,a
+247,MATH,1250,2018,Summer,a
+255,MATH,1250,2018,Summer,a
+312,MATH,1250,2018,Summer,a
+332,MATH,1250,2018,Summer,a
+356,MATH,1250,2018,Summer,a
+372,MATH,1250,2018,Summer,a
+101,MATH,1250,2018,Summer,b
+119,MATH,1250,2018,Summer,b
+239,MATH,1250,2018,Summer,b
+313,MATH,1250,2018,Summer,b
+321,MATH,1250,2018,Summer,b
+368,MATH,1250,2018,Summer,b
+100,MATH,1250,2018,Summer,c
+139,MATH,1250,2018,Summer,c
+158,MATH,1250,2018,Summer,c
+197,MATH,1250,2018,Summer,c
+207,MATH,1250,2018,Summer,c
+261,MATH,1250,2018,Summer,c
+277,MATH,1250,2018,Summer,c
+288,MATH,1250,2018,Summer,c
+321,MATH,1250,2018,Summer,c
+362,MATH,1250,2018,Summer,c
+106,MATH,1250,2020,Summer,a
+108,MATH,1250,2020,Summer,a
+133,MATH,1250,2020,Summer,a
+135,MATH,1250,2020,Summer,a
+151,MATH,1250,2020,Summer,a
+167,MATH,1250,2020,Summer,a
+185,MATH,1250,2020,Summer,a
+231,MATH,1250,2020,Summer,a
+281,MATH,1250,2020,Summer,a
+289,MATH,1250,2020,Summer,a
+309,MATH,1250,2020,Summer,a
+342,MATH,1250,2020,Summer,a
+378,MATH,1250,2020,Summer,a
+384,MATH,1250,2020,Summer,a
+386,MATH,1250,2020,Summer,a
+391,MATH,1250,2020,Summer,a
+177,MATH,1260,2015,Spring,c
+144,MATH,1260,2015,Summer,a
+162,MATH,1260,2015,Summer,a
+211,MATH,1260,2015,Summer,a
+229,MATH,1260,2016,Fall,a
+278,MATH,1260,2016,Fall,a
+304,MATH,1260,2017,Summer,a
+353,MATH,1260,2017,Summer,a
+361,MATH,1260,2017,Summer,a
+252,MATH,1260,2017,Fall,a
+260,MATH,1260,2017,Fall,a
+291,MATH,1260,2017,Fall,a
+133,MATH,1260,2019,Spring,a
+256,MATH,1260,2019,Spring,a
+347,MATH,1260,2019,Spring,a
+152,MATH,1260,2019,Spring,b
+169,MATH,1260,2019,Spring,b
+179,MATH,1260,2019,Spring,b
+187,MATH,1260,2019,Spring,b
+247,MATH,1260,2019,Spring,b
+277,MATH,1260,2019,Spring,b
+285,MATH,1260,2019,Spring,b
+313,MATH,1260,2019,Spring,b
+356,MATH,1260,2019,Spring,b
+102,MATH,1260,2019,Spring,c
+165,MATH,1260,2019,Spring,c
+293,MATH,1260,2019,Spring,c
+321,MATH,1260,2019,Spring,c
+113,MATH,1260,2019,Summer,a
+118,MATH,1260,2019,Summer,a
+124,MATH,1260,2019,Summer,a
+131,MATH,1260,2019,Summer,a
+185,MATH,1260,2019,Summer,a
+257,MATH,1260,2019,Summer,a
+276,MATH,1260,2019,Summer,a
+318,MATH,1260,2019,Summer,a
+391,MATH,1260,2019,Summer,a
+397,MATH,1260,2019,Summer,a
+120,MATH,1260,2019,Summer,b
+123,MATH,1260,2019,Summer,b
+194,MATH,1260,2019,Summer,b
+276,MATH,1260,2019,Summer,b
+303,MATH,1260,2019,Summer,b
+314,MATH,1260,2019,Summer,b
+377,MATH,1260,2019,Summer,b
+100,MATH,1260,2019,Fall,a
+108,MATH,1260,2019,Fall,a
+258,MATH,1260,2019,Fall,a
+309,MATH,1260,2019,Fall,a
+364,MATH,1260,2019,Fall,a
+375,MATH,1260,2019,Fall,a
+164,MATH,1260,2020,Spring,a
+173,MATH,1260,2020,Spring,a
+231,MATH,1260,2020,Spring,a
+235,MATH,1260,2020,Spring,a
+242,MATH,1260,2020,Spring,a
+276,MATH,2210,2015,Spring,b
+120,MATH,2210,2015,Summer,c
+212,MATH,2210,2015,Summer,c
+348,MATH,2210,2015,Summer,c
+172,MATH,2210,2015,Fall,a
+182,MATH,2210,2015,Fall,a
+373,MATH,2210,2015,Fall,a
+176,MATH,2210,2017,Spring,a
+208,MATH,2210,2017,Spring,a
+215,MATH,2210,2017,Spring,a
+249,MATH,2210,2017,Spring,a
+261,MATH,2210,2017,Spring,a
+270,MATH,2210,2017,Spring,a
+314,MATH,2210,2017,Spring,a
+128,MATH,2210,2017,Summer,a
+277,MATH,2210,2017,Summer,a
+361,MATH,2210,2017,Summer,a
+387,MATH,2210,2017,Summer,a
+392,MATH,2210,2017,Summer,a
+117,MATH,2210,2018,Spring,a
+123,MATH,2210,2018,Spring,a
+262,MATH,2210,2018,Spring,a
+391,MATH,2210,2018,Spring,a
+131,MATH,2210,2018,Spring,b
+185,MATH,2210,2018,Spring,b
+197,MATH,2210,2018,Spring,b
+199,MATH,2210,2018,Spring,b
+229,MATH,2210,2018,Spring,b
+230,MATH,2210,2018,Spring,b
+231,MATH,2210,2018,Spring,b
+239,MATH,2210,2018,Spring,b
+256,MATH,2210,2018,Spring,b
+275,MATH,2210,2018,Spring,b
+309,MATH,2210,2018,Spring,b
+369,MATH,2210,2018,Spring,b
+102,MATH,2210,2019,Spring,a
+169,MATH,2210,2019,Spring,a
+285,MATH,2210,2019,Spring,a
+119,MATH,2210,2019,Spring,b
+173,MATH,2210,2019,Spring,b
+228,MATH,2210,2019,Spring,b
+285,MATH,2210,2019,Spring,b
+296,MATH,2210,2019,Spring,b
+305,MATH,2210,2019,Spring,b
+342,MATH,2210,2019,Spring,b
+375,MATH,2210,2019,Spring,b
+113,MATH,2210,2020,Spring,a
+255,MATH,2210,2020,Spring,a
+274,MATH,2210,2020,Spring,a
+347,MATH,2210,2020,Spring,a
+124,MATH,2210,2020,Spring,b
+170,MATH,2210,2020,Spring,b
+200,MATH,2210,2020,Spring,b
+241,MATH,2210,2020,Spring,c
+251,MATH,2210,2020,Spring,c
+274,MATH,2210,2020,Spring,c
+122,MATH,2210,2020,Fall,a
+136,MATH,2210,2020,Fall,a
+167,MATH,2210,2020,Fall,a
+175,MATH,2210,2020,Fall,a
+179,MATH,2210,2020,Fall,a
+225,MATH,2210,2020,Fall,a
+272,MATH,2210,2020,Fall,a
+281,MATH,2210,2020,Fall,a
+329,MATH,2210,2020,Fall,a
+345,MATH,2210,2020,Fall,a
+378,MATH,2210,2020,Fall,a
+384,MATH,2210,2020,Fall,a
+397,MATH,2210,2020,Fall,a
+179,MATH,2270,2015,Fall,a
+212,MATH,2270,2015,Fall,a
+210,MATH,2270,2015,Fall,b
+313,MATH,2270,2015,Fall,b
+132,MATH,2270,2017,Summer,a
+143,MATH,2270,2017,Summer,a
+277,MATH,2270,2017,Summer,a
+304,MATH,2270,2017,Summer,a
+318,MATH,2270,2017,Summer,a
+107,MATH,2270,2017,Fall,a
+109,MATH,2270,2017,Fall,a
+292,MATH,2270,2017,Fall,a
+329,MATH,2270,2017,Fall,a
+246,MATH,2270,2017,Fall,b
+259,MATH,2270,2017,Fall,b
+342,MATH,2270,2017,Fall,b
+356,MATH,2270,2017,Fall,b
+120,MATH,2270,2017,Fall,c
+131,MATH,2270,2017,Fall,c
+182,MATH,2270,2017,Fall,c
+394,MATH,2270,2017,Fall,c
+102,MATH,2270,2017,Fall,d
+107,MATH,2270,2017,Fall,d
+123,MATH,2270,2017,Fall,d
+124,MATH,2270,2017,Fall,d
+128,MATH,2270,2017,Fall,d
+182,MATH,2270,2017,Fall,d
+276,MATH,2270,2017,Fall,d
+291,MATH,2270,2017,Fall,d
+312,MATH,2270,2017,Fall,d
+314,MATH,2270,2017,Fall,d
+397,MATH,2270,2017,Fall,d
+255,MATH,2270,2019,Spring,a
+285,MATH,2270,2019,Spring,a
+366,MATH,2270,2019,Spring,a
+379,MATH,2270,2019,Spring,a
+139,MATH,2270,2019,Summer,a
+146,MATH,2270,2019,Summer,a
+173,MATH,2270,2019,Summer,a
+248,MATH,2270,2019,Summer,a
+377,MATH,2270,2019,Summer,a
+194,MATH,2270,2019,Summer,b
+303,MATH,2270,2019,Summer,b
+325,MATH,2270,2019,Summer,b
+378,MATH,2270,2019,Summer,b
+183,MATH,2270,2019,Summer,c
+345,MATH,2270,2019,Summer,c
+396,MATH,2270,2019,Summer,c
+399,MATH,2270,2019,Summer,c
+254,MATH,2270,2019,Fall,a
+333,MATH,2270,2019,Fall,a
+175,MATH,2270,2020,Spring,a
+178,MATH,2270,2020,Spring,a
+223,MATH,2270,2020,Spring,a
+258,MATH,2270,2020,Spring,a
+270,MATH,2270,2020,Spring,a
+309,MATH,2270,2020,Spring,a
+130,MATH,2270,2020,Fall,a
+152,MATH,2270,2020,Fall,a
+177,MATH,2270,2020,Fall,a
+181,MATH,2270,2020,Fall,a
+230,MATH,2270,2020,Fall,a
+240,MATH,2270,2020,Fall,a
+331,MATH,2270,2020,Fall,a
+348,MATH,2270,2020,Fall,a
+360,MATH,2270,2020,Fall,a
+373,MATH,2270,2020,Fall,a
+391,MATH,2270,2020,Fall,a
+398,MATH,2270,2020,Fall,a
+119,MATH,2270,2020,Fall,b
+127,MATH,2270,2020,Fall,b
+129,MATH,2270,2020,Fall,b
+135,MATH,2270,2020,Fall,b
+167,MATH,2270,2020,Fall,b
+186,MATH,2270,2020,Fall,b
+260,MATH,2270,2020,Fall,b
+321,MATH,2270,2020,Fall,b
+331,MATH,2270,2020,Fall,b
+348,MATH,2270,2020,Fall,b
+371,MATH,2270,2020,Fall,b
+391,MATH,2270,2020,Fall,b
+204,MATH,2280,2015,Summer,a
+249,MATH,2280,2015,Summer,a
+123,MATH,2280,2015,Fall,a
+276,MATH,2280,2015,Fall,a
+393,MATH,2280,2016,Fall,a
+182,MATH,2280,2018,Spring,a
+230,MATH,2280,2018,Spring,a
+238,MATH,2280,2018,Spring,a
+256,MATH,2280,2018,Spring,a
+262,MATH,2280,2018,Spring,a
+307,MATH,2280,2018,Spring,a
+387,MATH,2280,2018,Spring,a
+173,MATH,2280,2018,Fall,a
+220,MATH,2280,2018,Fall,a
+259,MATH,2280,2018,Fall,a
+342,MATH,2280,2018,Fall,a
+104,MATH,2280,2018,Fall,b
+119,MATH,2280,2018,Fall,b
+165,MATH,2280,2018,Fall,b
+227,MATH,2280,2018,Fall,b
+359,MATH,2280,2018,Fall,b
+119,MATH,2280,2018,Fall,c
+120,MATH,2280,2018,Fall,c
+178,MATH,2280,2018,Fall,c
+196,MATH,2280,2018,Fall,c
+309,MATH,2280,2018,Fall,c
+345,MATH,2280,2018,Fall,c
+100,MATH,2280,2019,Fall,a
+102,MATH,2280,2019,Fall,a
+270,MATH,2280,2019,Fall,a
+314,MATH,2280,2019,Fall,a
+133,MATH,2280,2019,Fall,b
+247,MATH,2280,2019,Fall,b
+267,MATH,2280,2019,Fall,b
+318,MATH,2280,2019,Fall,b
+379,MATH,2280,2019,Fall,b
+390,MATH,2280,2019,Fall,b
+146,MATH,2280,2019,Fall,c
+223,MATH,2280,2019,Fall,c
+234,MATH,2280,2019,Fall,c
+248,MATH,2280,2019,Fall,c
+270,MATH,2280,2019,Fall,c
+292,MATH,2280,2019,Fall,c
+107,MATH,2280,2020,Spring,a
+183,MATH,2280,2020,Spring,a
+210,MATH,2280,2020,Spring,a
+255,MATH,2280,2020,Spring,a
+285,MATH,2280,2020,Spring,a
+313,MATH,2280,2020,Spring,a
+106,MATH,2280,2020,Spring,b
+169,MATH,2280,2020,Spring,b
+285,MATH,2280,2020,Spring,b
+398,MATH,2280,2020,Spring,b
+177,MATH,3210,2015,Spring,b
+282,MATH,3210,2015,Spring,b
+394,MATH,3210,2015,Spring,b
+144,MATH,3210,2015,Summer,a
+210,MATH,3210,2015,Summer,a
+215,MATH,3210,2015,Summer,a
+301,MATH,3210,2015,Summer,a
+126,MATH,3210,2015,Fall,a
+172,MATH,3210,2015,Fall,a
+246,MATH,3210,2015,Fall,a
+307,MATH,3210,2015,Fall,a
+313,MATH,3210,2015,Fall,a
+374,MATH,3210,2015,Fall,a
+138,MATH,3210,2015,Fall,b
+192,MATH,3210,2015,Fall,c
+172,MATH,3210,2015,Fall,d
+335,MATH,3210,2015,Fall,d
+149,MATH,3210,2016,Spring,a
+229,MATH,3210,2016,Spring,a
+276,MATH,3210,2016,Spring,a
+102,MATH,3210,2016,Fall,a
+134,MATH,3210,2016,Fall,a
+195,MATH,3210,2016,Fall,a
+277,MATH,3210,2016,Fall,a
+120,MATH,3210,2017,Spring,a
+207,MATH,3210,2017,Spring,a
+304,MATH,3210,2017,Spring,a
+107,MATH,3210,2017,Summer,a
+292,MATH,3210,2017,Summer,a
+309,MATH,3210,2017,Summer,a
+372,MATH,3210,2017,Summer,a
+270,MATH,3210,2019,Spring,a
+348,MATH,3210,2019,Spring,a
+364,MATH,3210,2019,Spring,a
+378,MATH,3210,2019,Spring,a
+399,MATH,3210,2019,Spring,a
+259,MATH,3210,2019,Spring,b
+314,MATH,3210,2019,Spring,b
+321,MATH,3210,2019,Spring,b
+124,MATH,3210,2019,Fall,a
+223,MATH,3210,2019,Fall,a
+230,MATH,3210,2019,Fall,a
+248,MATH,3210,2019,Fall,a
+284,MATH,3210,2019,Fall,a
+285,MATH,3210,2019,Fall,a
+358,MATH,3210,2019,Fall,a
+123,MATH,3210,2020,Spring,a
+146,MATH,3210,2020,Spring,a
+181,MATH,3210,2020,Spring,a
+251,MATH,3210,2020,Spring,a
+113,MATH,3210,2020,Summer,a
+135,MATH,3210,2020,Summer,a
+166,MATH,3210,2020,Summer,a
+171,MATH,3210,2020,Summer,a
+187,MATH,3210,2020,Summer,a
+260,MATH,3210,2020,Summer,a
+312,MATH,3210,2020,Summer,a
+368,MATH,3210,2020,Summer,a
+391,MATH,3210,2020,Summer,a
+109,MATH,3210,2020,Fall,a
+200,MATH,3210,2020,Fall,a
+227,MATH,3210,2020,Fall,a
+255,MATH,3210,2020,Fall,a
+256,MATH,3210,2020,Fall,a
+289,MATH,3210,2020,Fall,a
+329,MATH,3210,2020,Fall,a
+365,MATH,3210,2020,Fall,a
+386,MATH,3210,2020,Fall,a
+397,MATH,3210,2020,Fall,a
+210,MATH,3220,2016,Spring,a
+285,MATH,3220,2016,Spring,a
+373,MATH,3220,2016,Spring,a
+195,MATH,3220,2016,Spring,b
+301,MATH,3220,2016,Spring,b
+392,MATH,3220,2016,Spring,b
+119,MATH,3220,2016,Spring,c
+216,MATH,3220,2016,Spring,c
+374,MATH,3220,2016,Spring,c
+192,MATH,3220,2016,Spring,d
+210,MATH,3220,2016,Spring,d
+290,MATH,3220,2016,Spring,d
+394,MATH,3220,2016,Spring,d
+163,MATH,3220,2016,Summer,a
+214,MATH,3220,2016,Summer,a
+270,MATH,3220,2016,Summer,a
+276,MATH,3220,2016,Summer,a
+278,MATH,3220,2016,Summer,a
+246,MATH,3220,2016,Fall,a
+277,MATH,3220,2016,Fall,a
+385,MATH,3220,2016,Fall,a
+134,MATH,3220,2016,Fall,b
+245,MATH,3220,2016,Fall,b
+264,MATH,3220,2016,Fall,b
+329,MATH,3220,2016,Fall,b
+123,MATH,3220,2017,Spring,a
+176,MATH,3220,2017,Spring,a
+391,MATH,3220,2017,Spring,a
+102,MATH,3220,2017,Fall,a
+107,MATH,3220,2017,Fall,a
+207,MATH,3220,2017,Fall,a
+266,MATH,3220,2017,Fall,a
+311,MATH,3220,2017,Fall,a
+377,MATH,3220,2017,Fall,a
+139,MATH,3220,2017,Fall,b
+261,MATH,3220,2017,Fall,b
+326,MATH,3220,2017,Fall,b
+366,MATH,3220,2017,Fall,b
+237,MATH,3220,2018,Spring,a
+292,MATH,3220,2018,Spring,a
+296,MATH,3220,2018,Spring,a
+345,MATH,3220,2018,Spring,a
+362,MATH,3220,2018,Spring,a
+379,MATH,3220,2018,Spring,a
+101,MATH,3220,2018,Spring,b
+132,MATH,3220,2018,Spring,b
+312,MATH,3220,2018,Spring,b
+387,MATH,3220,2018,Spring,b
+127,MATH,3220,2018,Spring,c
+131,MATH,3220,2018,Spring,c
+165,MATH,3220,2018,Spring,c
+229,MATH,3220,2018,Spring,c
+305,MATH,3220,2018,Spring,c
+309,MATH,3220,2018,Spring,c
+312,MATH,3220,2018,Spring,c
+129,MATH,3220,2018,Spring,d
+179,MATH,3220,2018,Spring,d
+203,MATH,3220,2018,Spring,d
+238,MATH,3220,2018,Spring,d
+177,PHYS,2040,2015,Spring,a
+192,PHYS,2040,2015,Spring,a
+245,PHYS,2040,2015,Fall,a
+149,PHYS,2040,2015,Fall,b
+295,PHYS,2040,2015,Fall,b
+312,PHYS,2040,2015,Fall,b
+373,PHYS,2040,2015,Fall,b
+374,PHYS,2040,2015,Fall,b
+210,PHYS,2040,2015,Fall,c
+212,PHYS,2040,2015,Fall,c
+307,PHYS,2040,2015,Fall,c
+387,PHYS,2040,2015,Fall,c
+321,PHYS,2040,2016,Spring,a
+389,PHYS,2040,2016,Spring,a
+292,PHYS,2040,2017,Summer,a
+203,PHYS,2040,2017,Fall,a
+237,PHYS,2040,2017,Fall,a
+259,PHYS,2040,2017,Fall,a
+314,PHYS,2040,2017,Fall,a
+379,PHYS,2040,2017,Fall,a
+119,PHYS,2040,2017,Fall,b
+256,PHYS,2040,2017,Fall,b
+285,PHYS,2040,2017,Fall,b
+132,PHYS,2040,2017,Fall,c
+187,PHYS,2040,2017,Fall,c
+214,PHYS,2040,2017,Fall,c
+230,PHYS,2040,2017,Fall,c
+266,PHYS,2040,2017,Fall,c
+270,PHYS,2040,2017,Fall,c
+314,PHYS,2040,2017,Fall,c
+348,PHYS,2040,2017,Fall,c
+101,PHYS,2040,2018,Spring,a
+105,PHYS,2040,2018,Spring,a
+123,PHYS,2040,2018,Spring,a
+169,PHYS,2040,2018,Spring,a
+227,PHYS,2040,2018,Spring,a
+342,PHYS,2040,2018,Spring,a
+178,PHYS,2040,2019,Spring,a
+275,PHYS,2040,2019,Spring,a
+296,PHYS,2040,2019,Spring,a
+372,PHYS,2040,2019,Spring,a
+391,PHYS,2040,2019,Spring,a
+399,PHYS,2040,2019,Spring,a
+152,PHYS,2040,2019,Spring,b
+305,PHYS,2040,2019,Spring,b
+120,PHYS,2040,2020,Spring,a
+125,PHYS,2040,2020,Spring,a
+128,PHYS,2040,2020,Spring,a
+131,PHYS,2040,2020,Spring,a
+194,PHYS,2040,2020,Spring,a
+267,PHYS,2040,2020,Spring,a
+313,PHYS,2040,2020,Spring,a
+377,PHYS,2060,2015,Spring,a
+115,PHYS,2060,2016,Spring,a
+195,PHYS,2060,2016,Spring,a
+229,PHYS,2060,2016,Spring,a
+355,PHYS,2060,2016,Spring,a
+379,PHYS,2060,2016,Spring,a
+392,PHYS,2060,2016,Spring,a
+163,PHYS,2060,2016,Spring,b
+290,PHYS,2060,2016,Spring,b
+262,PHYS,2060,2016,Summer,a
+264,PHYS,2060,2016,Summer,a
+278,PHYS,2060,2016,Summer,a
+373,PHYS,2060,2016,Summer,a
+393,PHYS,2060,2016,Summer,a
+276,PHYS,2060,2016,Summer,b
+282,PHYS,2060,2016,Summer,b
+285,PHYS,2060,2016,Summer,b
+348,PHYS,2060,2016,Summer,b
+374,PHYS,2060,2016,Summer,b
+102,PHYS,2060,2018,Summer,a
+131,PHYS,2060,2018,Summer,a
+120,PHYS,2060,2018,Fall,a
+156,PHYS,2060,2018,Fall,a
+239,PHYS,2060,2018,Fall,a
+298,PHYS,2060,2018,Fall,a
+399,PHYS,2060,2018,Fall,a
+127,PHYS,2060,2018,Fall,b
+158,PHYS,2060,2018,Fall,b
+247,PHYS,2060,2018,Fall,b
+248,PHYS,2060,2018,Fall,b
+257,PHYS,2060,2018,Fall,b
+261,PHYS,2060,2018,Fall,b
+270,PHYS,2060,2018,Fall,b
+275,PHYS,2060,2018,Fall,b
+311,PHYS,2060,2018,Fall,b
+329,PHYS,2060,2018,Fall,b
+127,PHYS,2060,2018,Fall,c
+165,PHYS,2060,2018,Fall,c
+217,PHYS,2060,2018,Fall,c
+275,PHYS,2060,2018,Fall,c
+311,PHYS,2060,2018,Fall,c
+318,PHYS,2060,2018,Fall,c
+329,PHYS,2060,2018,Fall,c
+231,PHYS,2060,2018,Fall,d
+252,PHYS,2060,2018,Fall,d
+259,PHYS,2060,2018,Fall,d
+288,PHYS,2060,2018,Fall,d
+311,PHYS,2060,2018,Fall,d
+230,PHYS,2060,2019,Summer,a
+238,PHYS,2060,2019,Summer,a
+277,PHYS,2060,2019,Summer,a
+307,PHYS,2060,2019,Summer,a
+312,PHYS,2060,2019,Summer,a
+398,PHYS,2060,2019,Summer,a
+106,PHYS,2060,2019,Summer,b
+121,PHYS,2060,2019,Summer,b
+179,PHYS,2060,2019,Summer,b
+194,PHYS,2060,2019,Summer,b
+294,PHYS,2060,2019,Summer,b
+313,PHYS,2060,2019,Summer,b
+366,PHYS,2060,2019,Summer,b
+384,PHYS,2060,2019,Summer,b
+397,PHYS,2060,2019,Summer,b
+108,PHYS,2060,2019,Fall,a
+185,PHYS,2060,2019,Fall,a
+210,PHYS,2060,2019,Fall,a
+359,PHYS,2060,2019,Fall,a
+380,PHYS,2060,2019,Fall,a
+171,PHYS,2060,2019,Fall,b
+241,PHYS,2060,2019,Fall,b
+274,PHYS,2060,2019,Fall,b
+341,PHYS,2060,2019,Fall,b
+368,PHYS,2060,2019,Fall,b
+100,PHYS,2060,2019,Fall,c
+123,PHYS,2060,2019,Fall,c
+151,PHYS,2060,2019,Fall,c
+177,PHYS,2060,2019,Fall,c
+375,PHYS,2060,2019,Fall,c
+122,PHYS,2060,2020,Spring,a
+167,PHYS,2060,2020,Spring,a
+223,PHYS,2060,2020,Spring,a
+255,PHYS,2060,2020,Spring,a
+310,PHYS,2060,2020,Spring,a
+321,PHYS,2060,2020,Spring,a
+153,PHYS,2060,2020,Spring,b
+221,PHYS,2060,2020,Spring,b
+240,PHYS,2060,2020,Spring,b
+269,PHYS,2060,2020,Spring,b
+292,PHYS,2060,2020,Spring,b
+293,PHYS,2060,2020,Spring,b
+321,PHYS,2060,2020,Spring,b
+391,PHYS,2060,2020,Spring,b
+112,PHYS,2060,2020,Fall,a
+142,PHYS,2060,2020,Fall,a
+178,PHYS,2060,2020,Fall,a
+181,PHYS,2060,2020,Fall,a
+187,PHYS,2060,2020,Fall,a
+250,PHYS,2060,2020,Fall,a
+371,PHYS,2060,2020,Fall,a
+376,PHYS,2060,2020,Fall,a
+390,PHYS,2060,2020,Fall,a
+193,PHYS,2100,2015,Spring,a
+277,PHYS,2100,2015,Spring,b
+321,PHYS,2100,2015,Spring,b
+120,PHYS,2100,2016,Fall,a
+312,PHYS,2100,2016,Fall,a
+314,PHYS,2100,2016,Fall,a
+392,PHYS,2100,2016,Fall,a
+176,PHYS,2100,2016,Fall,b
+179,PHYS,2100,2016,Fall,b
+278,PHYS,2100,2016,Fall,b
+177,PHYS,2100,2017,Summer,a
+262,PHYS,2100,2017,Summer,a
+276,PHYS,2100,2017,Summer,a
+375,PHYS,2100,2017,Summer,a
+117,PHYS,2100,2017,Summer,b
+177,PHYS,2100,2017,Summer,b
+215,PHYS,2100,2017,Summer,b
+307,PHYS,2100,2017,Summer,b
+377,PHYS,2100,2017,Summer,b
+378,PHYS,2100,2017,Summer,b
+151,PHYS,2100,2017,Summer,c
+173,PHYS,2100,2017,Summer,c
+215,PHYS,2100,2017,Summer,c
+264,PHYS,2100,2017,Summer,c
+353,PHYS,2100,2017,Summer,c
+355,PHYS,2100,2017,Summer,c
+246,PHYS,2100,2017,Fall,a
+374,PHYS,2100,2017,Fall,a
+387,PHYS,2100,2017,Fall,a
+128,PHYS,2100,2018,Fall,a
+158,PHYS,2100,2018,Fall,a
+185,PHYS,2100,2018,Fall,a
+285,PHYS,2100,2018,Fall,a
+288,PHYS,2100,2018,Fall,a
+366,PHYS,2100,2019,Summer,a
+386,PHYS,2100,2019,Summer,a
+399,PHYS,2100,2019,Summer,a
+282,PHYS,2140,2015,Spring,a
+192,PHYS,2140,2015,Spring,b
+394,PHYS,2140,2015,Spring,b
+140,PHYS,2140,2015,Summer,a
+172,PHYS,2140,2015,Summer,b
+176,PHYS,2140,2015,Summer,b
+270,PHYS,2140,2015,Summer,b
+138,PHYS,2140,2015,Summer,c
+246,PHYS,2140,2015,Summer,c
+373,PHYS,2140,2015,Summer,c
+120,PHYS,2140,2015,Fall,a
+276,PHYS,2140,2015,Fall,a
+123,PHYS,2140,2016,Spring,a
+117,PHYS,2140,2016,Spring,b
+313,PHYS,2140,2016,Spring,b
+134,PHYS,2140,2016,Spring,c
+215,PHYS,2140,2016,Spring,c
+307,PHYS,2140,2016,Spring,c
+312,PHYS,2140,2016,Summer,a
+317,PHYS,2140,2016,Summer,a
+277,PHYS,2140,2016,Summer,b
+392,PHYS,2140,2016,Summer,b
+116,PHYS,2140,2016,Fall,a
+335,PHYS,2140,2016,Fall,a
+387,PHYS,2140,2016,Fall,a
+177,PHYS,2140,2017,Summer,a
+255,PHYS,2140,2017,Summer,a
+285,PHYS,2140,2017,Summer,a
+314,PHYS,2140,2017,Summer,a
+187,PHYS,2140,2017,Fall,a
+259,PHYS,2140,2017,Fall,a
+361,PHYS,2140,2017,Fall,b
+379,PHYS,2140,2017,Fall,b
+101,PHYS,2140,2018,Summer,a
+105,PHYS,2140,2018,Summer,a
+113,PHYS,2140,2018,Summer,a
+128,PHYS,2140,2018,Summer,a
+143,PHYS,2140,2018,Summer,a
+151,PHYS,2140,2018,Summer,a
+231,PHYS,2140,2018,Summer,a
+298,PHYS,2140,2018,Summer,a
+199,PHYS,2140,2018,Summer,b
+305,PHYS,2140,2018,Summer,b
+369,PHYS,2140,2018,Summer,b
+163,PHYS,2140,2018,Fall,a
+253,PHYS,2140,2018,Fall,a
+386,PHYS,2140,2018,Fall,a
+129,PHYS,2140,2019,Fall,a
+167,PHYS,2140,2019,Fall,a
+227,PHYS,2140,2019,Fall,a
+329,PHYS,2140,2019,Fall,a
+366,PHYS,2140,2019,Fall,a
+371,PHYS,2140,2019,Fall,a
+289,PHYS,2140,2019,Fall,b
+318,PHYS,2140,2019,Fall,b
+362,PHYS,2140,2019,Fall,b
+377,PHYS,2140,2019,Fall,b
+119,PHYS,2140,2020,Fall,a
+131,PHYS,2140,2020,Fall,a
+136,PHYS,2140,2020,Fall,a
+146,PHYS,2140,2020,Fall,a
+175,PHYS,2140,2020,Fall,a
+185,PHYS,2140,2020,Fall,a
+222,PHYS,2140,2020,Fall,a
+235,PHYS,2140,2020,Fall,a
+267,PHYS,2140,2020,Fall,a
+292,PHYS,2140,2020,Fall,a
+297,PHYS,2140,2020,Fall,a
+309,PHYS,2140,2020,Fall,a
+345,PHYS,2140,2020,Fall,a
+391,PHYS,2140,2020,Fall,a
+246,PHYS,2210,2015,Fall,a
+374,PHYS,2210,2015,Fall,b
+392,PHYS,2210,2015,Fall,b
+379,PHYS,2210,2015,Fall,c
+177,PHYS,2210,2017,Summer,a
+230,PHYS,2210,2017,Summer,a
+231,PHYS,2210,2017,Summer,a
+373,PHYS,2210,2017,Summer,a
+179,PHYS,2210,2017,Summer,b
+285,PHYS,2210,2017,Summer,b
+326,PHYS,2210,2017,Summer,b
+127,PHYS,2210,2017,Summer,c
+342,PHYS,2210,2017,Summer,c
+208,PHYS,2210,2017,Summer,d
+261,PHYS,2210,2017,Summer,d
+304,PHYS,2210,2017,Summer,d
+373,PHYS,2210,2017,Summer,d
+101,PHYS,2210,2018,Fall,a
+113,PHYS,2210,2018,Fall,a
+183,PHYS,2210,2018,Fall,a
+296,PHYS,2210,2018,Fall,a
+329,PHYS,2210,2018,Fall,a
+113,PHYS,2210,2018,Fall,b
+120,PHYS,2210,2018,Fall,b
+133,PHYS,2210,2018,Fall,b
+151,PHYS,2210,2018,Fall,b
+270,PHYS,2210,2018,Fall,b
+274,PHYS,2210,2018,Fall,b
+288,PHYS,2210,2018,Fall,b
+378,PHYS,2210,2018,Fall,b
+120,PHYS,2210,2018,Fall,c
+124,PHYS,2210,2018,Fall,c
+332,PHYS,2210,2018,Fall,c
+362,PHYS,2210,2018,Fall,c
+119,PHYS,2210,2019,Spring,a
+238,PHYS,2210,2019,Spring,a
+255,PHYS,2210,2019,Spring,a
+305,PHYS,2210,2019,Spring,a
+311,PHYS,2210,2019,Spring,a
+157,PHYS,2210,2019,Spring,b
+199,PHYS,2210,2019,Spring,b
+238,PHYS,2210,2019,Spring,b
+102,PHYS,2210,2019,Spring,c
+165,PHYS,2210,2019,Spring,c
+253,PHYS,2210,2019,Spring,c
+292,PHYS,2210,2019,Spring,c
+368,PHYS,2210,2019,Spring,c
+391,PHYS,2210,2019,Spring,c
+187,PHYS,2210,2019,Spring,d
+255,PHYS,2210,2019,Spring,d
+257,PHYS,2210,2019,Spring,d
+391,PHYS,2210,2019,Spring,d
+128,PHYS,2210,2019,Summer,a
+256,PHYS,2210,2019,Summer,a
+289,PHYS,2210,2019,Summer,a
+359,PHYS,2210,2019,Summer,a
+397,PHYS,2210,2019,Summer,a
+123,PHYS,2210,2019,Fall,a
+135,PHYS,2210,2019,Fall,a
+143,PHYS,2210,2019,Fall,a
+241,PHYS,2210,2019,Fall,a
+340,PHYS,2210,2019,Fall,a
+108,PHYS,2210,2019,Fall,b
+171,PHYS,2210,2019,Fall,b
+200,PHYS,2210,2019,Fall,b
+309,PHYS,2210,2019,Fall,b
+312,PHYS,2210,2019,Fall,b
+333,PHYS,2210,2019,Fall,b
+345,PHYS,2210,2019,Fall,b
+363,PHYS,2210,2019,Fall,b
+366,PHYS,2210,2019,Fall,b
+396,PHYS,2210,2019,Fall,b
+123,PHYS,2210,2019,Fall,c
+221,PHYS,2210,2019,Fall,c
+276,PHYS,2210,2019,Fall,c
+347,PHYS,2210,2019,Fall,c
+371,PHYS,2210,2019,Fall,c
+390,PHYS,2210,2019,Fall,c
+303,PHYS,2210,2019,Fall,d
+374,PHYS,2220,2015,Spring,a
+179,PHYS,2220,2015,Fall,a
+276,PHYS,2220,2015,Fall,a
+321,PHYS,2220,2015,Fall,a
+282,PHYS,2220,2015,Fall,b
+172,PHYS,2220,2016,Summer,a
+317,PHYS,2220,2016,Summer,a
+378,PHYS,2220,2016,Summer,a
+391,PHYS,2220,2016,Summer,a
+245,PHYS,2220,2016,Fall,a
+295,PHYS,2220,2016,Fall,a
+356,PHYS,2220,2016,Fall,a
+385,PHYS,2220,2016,Fall,a
+119,PHYS,2220,2017,Spring,a
+176,PHYS,2220,2017,Spring,a
+187,PHYS,2220,2017,Spring,a
+256,PHYS,2220,2017,Spring,a
+313,PHYS,2220,2017,Spring,a
+372,PHYS,2220,2017,Spring,a
+120,PHYS,2220,2017,Spring,b
+312,PHYS,2220,2017,Spring,b
+355,PHYS,2220,2017,Spring,b
+151,PHYS,2220,2017,Spring,c
+187,PHYS,2220,2017,Spring,c
+270,PHYS,2220,2017,Spring,c
+277,PHYS,2220,2017,Spring,c
+119,PHYS,2220,2017,Spring,d
+163,PHYS,2220,2017,Spring,d
+249,PHYS,2220,2017,Spring,d
+288,PHYS,2220,2017,Spring,d
+312,PHYS,2220,2017,Spring,d
+102,PHYS,2220,2018,Spring,a
+105,PHYS,2220,2018,Spring,a
+107,PHYS,2220,2018,Spring,a
+128,PHYS,2220,2018,Spring,a
+132,PHYS,2220,2018,Spring,a
+134,PHYS,2220,2018,Spring,a
+210,PHYS,2220,2018,Spring,a
+214,PHYS,2220,2018,Spring,a
+227,PHYS,2220,2018,Spring,a
+237,PHYS,2220,2018,Spring,a
+239,PHYS,2220,2018,Spring,a
+305,PHYS,2220,2018,Spring,a
+231,PHYS,2220,2018,Summer,a
+255,PHYS,2220,2018,Summer,a
+257,PHYS,2220,2018,Summer,a
+342,PHYS,2220,2018,Summer,a
+344,PHYS,2220,2018,Summer,a
+373,PHYS,2220,2018,Summer,a
+393,PHYS,2220,2018,Summer,a
+123,PHYS,2220,2018,Fall,a
+133,PHYS,2220,2018,Fall,a
+177,PHYS,2220,2018,Fall,a
+178,PHYS,2220,2018,Fall,a
+196,PHYS,2220,2018,Fall,a
+267,PHYS,2220,2018,Fall,a
+285,PHYS,2220,2018,Fall,a
+292,PHYS,2220,2018,Fall,a
+332,PHYS,2220,2018,Fall,a
+241,PHYS,2220,2019,Spring,a
+113,PHYS,2220,2020,Spring,a
+124,PHYS,2220,2020,Spring,a
+175,PHYS,2220,2020,Spring,a
+235,PHYS,2220,2020,Spring,a
+106,PHYS,2220,2020,Summer,a
+118,PHYS,2220,2020,Summer,a
+121,PHYS,2220,2020,Summer,a
+127,PHYS,2220,2020,Summer,a
+194,PHYS,2220,2020,Summer,a
+247,PHYS,2220,2020,Summer,a
+293,PHYS,2220,2020,Summer,a
+296,PHYS,2220,2020,Summer,a
+309,PHYS,2220,2020,Summer,a
+311,PHYS,2220,2020,Summer,a
+339,PHYS,2220,2020,Summer,a
+345,PHYS,2220,2020,Summer,a
+164,PHYS,2220,2020,Summer,b
+242,PHYS,2220,2020,Summer,b
+289,PHYS,2220,2020,Summer,b
+300,PHYS,2220,2020,Summer,b
+323,PHYS,2220,2020,Summer,b
+390,PHYS,2220,2020,Summer,b
+109,PHYS,2220,2020,Fall,a
+228,PHYS,2220,2020,Fall,a
+386,PHYS,2220,2020,Fall,a
+107,PHYS,3210,2016,Summer,a
+249,PHYS,3210,2016,Summer,a
+134,PHYS,3210,2016,Summer,b
+172,PHYS,3210,2016,Summer,b
+249,PHYS,3210,2016,Summer,b
+314,PHYS,3210,2016,Summer,b
+123,PHYS,3210,2016,Fall,a
+260,PHYS,3210,2016,Fall,a
+321,PHYS,3210,2016,Fall,a
+139,PHYS,3210,2017,Summer,a
+179,PHYS,3210,2017,Summer,a
+230,PHYS,3210,2017,Summer,a
+246,PHYS,3210,2017,Summer,a
+373,PHYS,3210,2017,Summer,a
+378,PHYS,3210,2017,Summer,a
+391,PHYS,3210,2017,Summer,a
+393,PHYS,3210,2017,Summer,a
+208,PHYS,3210,2017,Summer,b
+264,PHYS,3210,2017,Summer,b
+379,PHYS,3210,2017,Summer,b
+155,PHYS,3210,2017,Fall,a
+262,PHYS,3210,2017,Fall,a
+270,PHYS,3210,2017,Fall,a
+335,PHYS,3210,2017,Fall,a
+377,PHYS,3210,2017,Fall,a
+397,PHYS,3210,2017,Fall,a
+119,PHYS,3210,2018,Spring,a
+229,PHYS,3210,2018,Spring,a
+277,PHYS,3210,2018,Spring,a
+294,PHYS,3210,2018,Spring,a
+385,PHYS,3210,2018,Spring,a
+274,PHYS,3210,2018,Spring,b
+372,PHYS,3210,2018,Spring,b
+102,PHYS,3210,2018,Spring,c
+105,PHYS,3210,2018,Spring,c
+197,PHYS,3210,2018,Spring,c
+209,PHYS,3210,2018,Spring,c
+374,PHYS,3210,2018,Spring,c
+381,PHYS,3210,2018,Spring,c
+101,PHYS,3210,2018,Fall,a
+109,PHYS,3210,2018,Fall,a
+227,PHYS,3210,2018,Fall,a
+276,PHYS,3210,2018,Fall,a
+285,PHYS,3210,2018,Fall,a
+113,PHYS,3210,2019,Spring,a
+258,PHYS,3210,2019,Spring,a
+329,PHYS,3210,2019,Spring,a
+351,PHYS,3210,2019,Spring,a
+356,PHYS,3210,2019,Spring,a
+384,PHYS,3210,2019,Spring,a
+217,PHYS,3210,2019,Spring,b
+312,PHYS,3210,2019,Spring,b
+351,PHYS,3210,2019,Spring,b
+231,PHYS,3210,2019,Spring,c
+258,PHYS,3210,2019,Spring,c
+292,PHYS,3210,2019,Spring,c
+329,PHYS,3210,2019,Spring,c
+375,PHYS,3210,2019,Spring,c
+156,PHYS,3210,2019,Spring,d
+173,PHYS,3210,2019,Spring,d
+128,PHYS,3210,2019,Summer,a
+133,PHYS,3210,2019,Summer,a
+146,PHYS,3210,2019,Summer,a
+177,PHYS,3210,2019,Summer,a
+199,PHYS,3210,2019,Summer,a
+133,PHYS,3210,2019,Summer,b
+152,PHYS,3210,2019,Summer,b
+255,PHYS,3210,2019,Summer,b
+287,PHYS,3210,2019,Summer,b
+313,PHYS,3210,2019,Summer,b
+362,PHYS,3210,2019,Summer,b
+366,PHYS,3210,2019,Summer,b
+106,PHYS,3210,2019,Summer,c
+152,PHYS,3210,2019,Summer,c
+167,PHYS,3210,2019,Summer,c
+188,PHYS,3210,2019,Summer,c
+307,PHYS,3210,2019,Summer,c
+309,PHYS,3210,2019,Summer,c
+333,PHYS,3210,2019,Summer,c
+345,PHYS,3210,2019,Summer,c
+100,PHYS,3210,2019,Fall,a
+178,PHYS,3210,2019,Fall,a
+125,PHYS,3210,2020,Spring,a
+131,PHYS,3210,2020,Spring,a
+183,PHYS,3210,2020,Spring,a
+185,PHYS,3210,2020,Spring,a
+254,PHYS,3210,2020,Spring,a
+310,PHYS,3210,2020,Spring,a
+348,PHYS,3210,2020,Spring,a
+390,PHYS,3210,2020,Spring,a
+175,PHYS,3210,2020,Summer,a
+187,PHYS,3210,2020,Summer,a
+240,PHYS,3210,2020,Summer,a
+300,PHYS,3210,2020,Summer,a
+136,PHYS,3210,2020,Fall,a
+153,PHYS,3210,2020,Fall,a
+228,PHYS,3210,2020,Fall,a
+289,PHYS,3210,2020,Fall,a
+293,PHYS,3210,2020,Fall,a
+297,PHYS,3210,2020,Fall,a
+306,PHYS,3210,2020,Fall,a
+339,PHYS,3210,2020,Fall,a
+342,PHYS,3210,2020,Fall,a
+121,PHYS,3210,2020,Fall,b
+129,PHYS,3210,2020,Fall,b
+200,PHYS,3210,2020,Fall,b
+228,PHYS,3210,2020,Fall,b
+256,PHYS,3210,2020,Fall,b
+130,PHYS,3210,2020,Fall,c
+331,PHYS,3210,2020,Fall,c
+115,PHYS,3220,2016,Summer,a
+195,PHYS,3220,2016,Summer,a
+285,PHYS,3220,2016,Summer,a
+312,PHYS,3220,2016,Summer,a
+107,PHYS,3220,2016,Summer,b
+123,PHYS,3220,2016,Summer,b
+277,PHYS,3220,2016,Summer,b
+119,PHYS,3220,2017,Summer,a
+139,PHYS,3220,2017,Summer,a
+215,PHYS,3220,2017,Summer,a
+329,PHYS,3220,2017,Summer,a
+392,PHYS,3220,2017,Summer,a
+120,PHYS,3220,2017,Fall,a
+131,PHYS,3220,2017,Fall,a
+155,PHYS,3220,2017,Fall,a
+214,PHYS,3220,2017,Fall,a
+237,PHYS,3220,2017,Fall,a
+109,PHYS,3220,2017,Fall,b
+203,PHYS,3220,2017,Fall,b
+345,PHYS,3220,2017,Fall,b
+213,PHYS,3220,2017,Fall,c
+230,PHYS,3220,2017,Fall,c
+307,PHYS,3220,2017,Fall,c
+127,PHYS,3220,2017,Fall,d
+187,PHYS,3220,2017,Fall,d
+252,PHYS,3220,2017,Fall,d
+270,PHYS,3220,2017,Fall,d
+276,PHYS,3220,2017,Fall,d
+288,PHYS,3220,2017,Fall,d
+128,PHYS,3220,2018,Summer,a
+143,PHYS,3220,2018,Summer,a
+260,PHYS,3220,2018,Summer,a
+377,PHYS,3220,2018,Summer,a
+379,PHYS,3220,2018,Summer,a
+398,PHYS,3220,2018,Summer,a
+102,PHYS,3220,2020,Spring,a
+133,PHYS,3220,2020,Spring,a
+170,PHYS,3220,2020,Spring,a
+267,PHYS,3220,2020,Spring,a
+310,PHYS,3220,2020,Spring,a
+227,PHYS,3220,2020,Spring,b
+241,PHYS,3220,2020,Spring,b
+251,PHYS,3220,2020,Spring,b
+255,PHYS,3220,2020,Spring,b
+269,PHYS,3220,2020,Spring,b
+321,PHYS,3220,2020,Spring,b
+348,PHYS,3220,2020,Spring,b
+106,PHYS,3220,2020,Spring,c
+152,PHYS,3220,2020,Spring,c
+185,PHYS,3220,2020,Spring,c
+194,PHYS,3220,2020,Spring,c
+200,PHYS,3220,2020,Spring,c
+241,PHYS,3220,2020,Spring,c
+251,PHYS,3220,2020,Spring,c
+271,PHYS,3220,2020,Spring,c
+296,PHYS,3220,2020,Spring,c
+325,PHYS,3220,2020,Spring,c
+365,PHYS,3220,2020,Spring,c
+124,PHYS,3220,2020,Spring,d
+167,PHYS,3220,2020,Spring,d
+185,PHYS,3220,2020,Spring,d
+227,PHYS,3220,2020,Spring,d
+303,PHYS,3220,2020,Spring,d
+341,PHYS,3220,2020,Spring,d
+342,PHYS,3220,2020,Spring,d
+373,PHYS,3220,2020,Spring,d
diff --git a/tests/integration/data/Grade.csv b/tests/integration/data/Grade.csv
new file mode 100644
index 000000000..8ba592194
--- /dev/null
+++ b/tests/integration/data/Grade.csv
@@ -0,0 +1,3028 @@
+student_id,dept,course,term_year,term,section,grade
+100,CS,1030,2020,Spring,a,A
+101,PHYS,2040,2018,Spring,a,A
+102,BIOL,1006,2018,Fall,a,A
+104,MATH,2280,2018,Fall,b,A
+105,PHYS,3210,2018,Spring,c,A
+107,MATH,3210,2017,Summer,a,A
+107,PHYS,2220,2018,Spring,a,A
+109,BIOL,2355,2019,Spring,d,A
+113,CS,3200,2020,Summer,a,A
+113,CS,3505,2019,Summer,d,A
+115,BIOL,1030,2017,Spring,a,A
+118,CS,2100,2019,Fall,b,A
+119,BIOL,2355,2018,Summer,d,A
+119,CS,3505,2019,Summer,a,A
+119,CS,4940,2017,Fall,b,A
+119,MATH,2280,2018,Fall,c,A
+119,PHYS,3210,2018,Spring,a,A
+120,PHYS,2060,2018,Fall,a,A
+122,CS,4970,2020,Fall,a,A
+123,BIOL,2030,2017,Spring,a,A
+123,BIOL,2325,2017,Fall,b,A
+123,BIOL,2355,2017,Summer,a,A
+123,CS,4940,2020,Summer,b,A
+123,MATH,3220,2017,Spring,a,A
+124,CS,2100,2018,Fall,c,A
+124,CS,2420,2019,Summer,a,A
+124,MATH,3210,2019,Fall,a,A
+125,BIOL,2330,2019,Fall,a,A
+127,BIOL,2355,2018,Fall,a,A
+127,PHYS,2060,2018,Fall,c,A
+127,PHYS,2220,2020,Summer,a,A
+128,BIOL,1006,2017,Fall,a,A
+128,BIOL,2010,2020,Summer,b,A
+128,CS,3505,2017,Fall,a,A
+128,CS,4500,2018,Spring,a,A
+132,BIOL,1030,2018,Summer,a,A
+132,CS,4500,2018,Spring,b,A
+132,CS,4970,2018,Summer,b,A
+135,CS,4400,2019,Summer,b,A
+139,BIOL,1006,2019,Summer,a,A
+139,CS,4000,2017,Summer,a,A
+140,CS,3810,2015,Spring,a,A
+140,CS,4400,2015,Summer,a,A
+143,CS,2100,2017,Fall,a,A
+145,MATH,1220,2017,Spring,c,A
+146,CS,4970,2020,Summer,c,A
+146,PHYS,2140,2020,Fall,a,A
+149,BIOL,2325,2015,Fall,c,A
+149,PHYS,2040,2015,Fall,b,A
+151,BIOL,2355,2019,Spring,b,A
+151,CS,4970,2020,Summer,b,A
+151,MATH,1220,2020,Spring,a,A
+152,BIOL,2021,2018,Fall,b,A
+155,PHYS,3210,2017,Fall,a,A
+155,PHYS,3220,2017,Fall,a,A
+165,BIOL,2330,2017,Fall,a,A
+165,MATH,1260,2019,Spring,c,A
+166,CS,3500,2020,Summer,a,A
+167,BIOL,2355,2020,Fall,a,A
+167,PHYS,3220,2020,Spring,d,A
+168,CS,2420,2020,Fall,a,A
+169,CS,2100,2019,Summer,b,A
+169,MATH,2280,2020,Spring,b,A
+169,PHYS,2040,2018,Spring,a,A
+170,CS,4940,2020,Summer,a,A
+173,BIOL,1006,2019,Fall,a,A
+173,MATH,2210,2019,Spring,b,A
+175,PHYS,3210,2020,Summer,a,A
+176,BIOL,1006,2016,Spring,a,A
+176,PHYS,2140,2015,Summer,b,A
+177,BIOL,2330,2016,Fall,a,A
+177,BIOL,2420,2015,Spring,a,A
+177,CS,3810,2018,Summer,b,A
+177,MATH,1260,2015,Spring,c,A
+179,CS,2100,2016,Summer,a,A
+179,PHYS,2060,2019,Summer,b,A
+185,MATH,1250,2020,Summer,a,A
+185,MATH,1260,2019,Summer,a,A
+186,MATH,2270,2020,Fall,b,A
+187,CS,4970,2020,Summer,b,A
+187,PHYS,3210,2020,Summer,a,A
+191,CS,4970,2020,Fall,a,A
+192,BIOL,2020,2015,Fall,d,A
+200,PHYS,3220,2020,Spring,c,A
+203,PHYS,3220,2017,Fall,b,A
+207,BIOL,2355,2018,Summer,d,A
+207,CS,1410,2016,Summer,a,A
+207,MATH,1250,2018,Summer,c,A
+210,MATH,3220,2016,Spring,d,A
+214,MATH,3220,2016,Summer,a,A
+215,CS,4500,2016,Spring,b,A
+215,PHYS,2140,2016,Spring,c,A
+216,CS,1410,2016,Spring,b,A
+217,BIOL,1010,2019,Spring,b,A
+217,PHYS,2060,2018,Fall,c,A
+223,PHYS,2060,2020,Spring,a,A
+224,BIOL,2420,2020,Fall,a,A
+227,BIOL,2330,2019,Fall,a,A
+228,CS,4970,2020,Summer,d,A
+229,CS,2420,2016,Fall,a,A
+230,CS,3505,2019,Spring,b,A
+230,MATH,1250,2017,Summer,c,A
+230,PHYS,2210,2017,Summer,a,A
+231,BIOL,2210,2017,Spring,a,A
+231,CS,2100,2018,Fall,c,A
+231,MATH,1220,2019,Fall,a,A
+234,CS,4400,2019,Summer,a,A
+237,CS,3810,2018,Spring,a,A
+238,BIOL,2021,2019,Spring,b,A
+240,MATH,2270,2020,Fall,a,A
+241,CS,2100,2019,Summer,a,A
+242,CS,4970,2020,Summer,a,A
+246,BIOL,2420,2015,Spring,b,A
+247,CS,3505,2018,Summer,a,A
+249,BIOL,1006,2015,Summer,b,A
+249,CS,4150,2016,Summer,a,A
+249,CS,4150,2016,Summer,b,A
+249,PHYS,3210,2016,Summer,b,A
+252,CS,3810,2018,Summer,d,A
+255,CS,2100,2018,Spring,a,A
+255,CS,4400,2017,Spring,b,A
+255,CS,4500,2019,Fall,d,A
+256,CS,4500,2019,Fall,a,A
+257,BIOL,1030,2017,Spring,c,A
+257,CS,3505,2020,Summer,a,A
+257,MATH,1250,2017,Summer,c,A
+260,CS,4150,2019,Spring,a,A
+262,CS,2420,2016,Fall,b,A
+262,CS,4400,2016,Summer,a,A
+262,CS,4970,2018,Fall,b,A
+264,BIOL,2420,2017,Summer,a,A
+264,PHYS,3210,2017,Summer,b,A
+267,PHYS,2040,2020,Spring,a,A
+269,PHYS,2060,2020,Spring,b,A
+270,PHYS,2060,2018,Fall,b,A
+271,CS,1030,2020,Fall,a,A
+273,BIOL,1030,2016,Spring,a,A
+274,PHYS,2060,2019,Fall,b,A
+275,BIOL,1210,2017,Summer,a,A
+275,BIOL,2210,2018,Spring,a,A
+275,MATH,2210,2018,Spring,b,A
+276,CS,3200,2018,Spring,b,A
+276,CS,4970,2016,Fall,b,A
+277,BIOL,2330,2017,Summer,a,A
+277,CS,4000,2020,Fall,a,A
+277,CS,4970,2018,Summer,a,A
+277,PHYS,2100,2015,Spring,b,A
+277,PHYS,2140,2016,Summer,b,A
+282,CS,4970,2017,Spring,a,A
+283,CS,4970,2020,Fall,b,A
+285,BIOL,1010,2018,Summer,b,A
+285,BIOL,2020,2018,Spring,a,A
+285,BIOL,2030,2017,Spring,d,A
+285,BIOL,2420,2020,Spring,a,A
+285,CS,4400,2019,Summer,a,A
+285,MATH,2280,2020,Spring,a,A
+285,PHYS,2220,2018,Fall,a,A
+288,MATH,1250,2018,Summer,c,A
+289,PHYS,2140,2019,Fall,b,A
+290,BIOL,1030,2016,Summer,a,A
+292,BIOL,2010,2020,Spring,b,A
+292,BIOL,2021,2017,Fall,a,A
+292,CS,3200,2020,Summer,a,A
+292,MATH,1250,2017,Summer,a,A
+292,PHYS,2140,2020,Fall,a,A
+293,BIOL,2210,2019,Fall,a,A
+293,CS,2100,2019,Summer,a,A
+293,PHYS,3210,2020,Fall,a,A
+295,MATH,1210,2016,Spring,b,A
+299,CS,2420,2017,Summer,b,A
+300,CS,3505,2019,Summer,c,A
+302,CS,2420,2015,Summer,c,A
+307,CS,4400,2016,Spring,a,A
+307,MATH,2280,2018,Spring,a,A
+307,PHYS,2060,2019,Summer,a,A
+310,PHYS,3220,2020,Spring,a,A
+311,BIOL,2030,2020,Spring,b,A
+311,BIOL,2420,2020,Summer,a,A
+311,CS,3810,2018,Summer,c,A
+312,BIOL,2330,2015,Fall,d,A
+312,PHYS,2060,2019,Summer,a,A
+313,BIOL,2420,2020,Summer,a,A
+313,PHYS,2220,2017,Spring,a,A
+314,BIOL,2030,2016,Fall,a,A
+314,CS,3810,2016,Summer,a,A
+314,MATH,1260,2019,Summer,b,A
+314,MATH,2210,2017,Spring,a,A
+318,BIOL,2355,2017,Summer,a,A
+321,CS,3500,2019,Fall,b,A
+321,CS,4400,2019,Spring,a,A
+321,MATH,1220,2019,Fall,b,A
+321,MATH,3210,2019,Spring,b,A
+323,PHYS,2220,2020,Summer,b,A
+329,BIOL,1006,2019,Summer,a,A
+329,CS,4400,2017,Spring,a,A
+331,PHYS,3210,2020,Fall,c,A
+333,CS,3500,2020,Summer,a,A
+333,CS,3810,2019,Fall,a,A
+335,PHYS,2140,2016,Fall,a,A
+336,BIOL,2010,2015,Fall,a,A
+340,BIOL,1010,2020,Summer,d,A
+340,BIOL,2021,2019,Fall,a,A
+342,BIOL,2030,2018,Summer,a,A
+342,PHYS,3220,2020,Spring,d,A
+345,CS,4400,2019,Spring,d,A
+345,PHYS,2210,2019,Fall,b,A
+347,BIOL,2210,2020,Fall,a,A
+347,BIOL,2420,2020,Summer,a,A
+348,BIOL,2355,2018,Summer,b,A
+348,CS,3200,2016,Fall,b,A
+348,MATH,1220,2018,Summer,a,A
+351,CS,4970,2019,Spring,a,A
+353,BIOL,1010,2017,Summer,a,A
+353,MATH,1260,2017,Summer,a,A
+356,MATH,1210,2017,Spring,a,A
+357,BIOL,2325,2016,Summer,a,A
+359,MATH,2280,2018,Fall,b,A
+362,BIOL,1006,2018,Spring,a,A
+362,BIOL,2030,2019,Summer,b,A
+362,PHYS,2140,2019,Fall,b,A
+364,MATH,3210,2019,Spring,a,A
+366,BIOL,2355,2017,Fall,a,A
+366,CS,1410,2018,Spring,d,A
+366,MATH,3220,2017,Fall,b,A
+366,PHYS,3210,2019,Summer,b,A
+368,CS,4500,2020,Summer,a,A
+369,CS,2420,2016,Fall,a,A
+369,CS,4400,2017,Spring,a,A
+371,CS,3505,2018,Fall,c,A
+372,MATH,1210,2018,Spring,a,A
+373,BIOL,2355,2017,Fall,b,A
+373,PHYS,2220,2018,Summer,a,A
+374,PHYS,2100,2017,Fall,a,A
+375,BIOL,2355,2017,Summer,a,A
+377,BIOL,1210,2017,Spring,a,A
+377,BIOL,2030,2017,Spring,a,A
+378,PHYS,2210,2018,Fall,b,A
+379,BIOL,2355,2018,Summer,b,A
+379,CS,4970,2020,Summer,b,A
+380,PHYS,2060,2019,Fall,a,A
+384,CS,4970,2020,Summer,c,A
+384,PHYS,3210,2019,Spring,a,A
+386,BIOL,2325,2018,Summer,a,A
+386,MATH,1250,2020,Summer,a,A
+387,BIOL,2020,2018,Fall,c,A
+387,MATH,2280,2018,Spring,a,A
+387,PHYS,2100,2017,Fall,a,A
+391,CS,4940,2020,Summer,a,A
+391,CS,4940,2020,Summer,b,A
+391,PHYS,2040,2019,Spring,a,A
+391,PHYS,2140,2020,Fall,a,A
+391,PHYS,2210,2019,Spring,d,A
+392,BIOL,1006,2017,Fall,a,A
+393,CS,3100,2017,Summer,a,A
+394,MATH,2270,2017,Fall,c,A
+394,PHYS,2140,2015,Spring,b,A
+396,CS,3500,2019,Summer,a,A
+397,BIOL,1010,2017,Spring,a,A
+397,CS,3500,2019,Fall,a,A
+397,CS,4940,2020,Summer,a,A
+397,PHYS,3210,2017,Fall,a,A
+399,PHYS,2060,2018,Fall,a,A
+399,PHYS,2100,2019,Summer,a,A
+100,MATH,1220,2020,Spring,a,A-
+102,BIOL,1030,2018,Fall,a,A-
+102,BIOL,2020,2019,Summer,a,A-
+102,BIOL,2021,2018,Spring,a,A-
+102,BIOL,2210,2019,Summer,a,A-
+102,CS,4150,2019,Spring,a,A-
+102,MATH,1250,2018,Summer,a,A-
+107,BIOL,2021,2019,Fall,a,A-
+107,CS,3505,2016,Summer,a,A-
+107,PHYS,3220,2016,Summer,b,A-
+108,BIOL,1010,2020,Summer,b,A-
+109,BIOL,1030,2020,Summer,a,A-
+109,CS,4970,2020,Summer,d,A-
+110,CS,3505,2020,Fall,b,A-
+113,BIOL,2030,2019,Summer,b,A-
+113,MATH,2210,2020,Spring,a,A-
+113,PHYS,2210,2018,Fall,a,A-
+113,PHYS,2210,2018,Fall,b,A-
+118,CS,4970,2020,Summer,b,A-
+120,CS,4970,2017,Spring,a,A-
+120,PHYS,2210,2018,Fall,c,A-
+120,PHYS,3220,2017,Fall,a,A-
+123,BIOL,1010,2015,Summer,b,A-
+123,CS,2100,2016,Summer,a,A-
+123,MATH,1250,2018,Spring,a,A-
+123,MATH,1260,2019,Summer,b,A-
+123,MATH,2270,2017,Fall,d,A-
+123,MATH,3210,2020,Spring,a,A-
+123,PHYS,2040,2018,Spring,a,A-
+123,PHYS,3220,2016,Summer,b,A-
+124,BIOL,2420,2020,Summer,a,A-
+124,MATH,1260,2019,Summer,a,A-
+126,BIOL,2020,2015,Fall,a,A-
+126,MATH,3210,2015,Fall,a,A-
+127,BIOL,2021,2018,Fall,a,A-
+127,PHYS,3220,2017,Fall,d,A-
+128,CS,1030,2018,Fall,a,A-
+128,CS,2420,2017,Fall,a,A-
+129,BIOL,2020,2018,Spring,a,A-
+130,CS,4970,2020,Fall,c,A-
+131,BIOL,1210,2018,Spring,a,A-
+131,MATH,2210,2018,Spring,b,A-
+133,MATH,1250,2020,Summer,a,A-
+138,CS,4940,2015,Summer,a,A-
+138,MATH,3210,2015,Fall,b,A-
+142,BIOL,1006,2020,Spring,a,A-
+142,CS,3500,2020,Summer,a,A-
+143,CS,3500,2019,Fall,c,A-
+143,CS,3505,2018,Summer,b,A-
+143,PHYS,2140,2018,Summer,a,A-
+144,BIOL,2020,2015,Summer,a,A-
+151,BIOL,1010,2017,Summer,a,A-
+151,CS,2420,2016,Fall,b,A-
+160,CS,2420,2015,Summer,a,A-
+162,MATH,1220,2015,Summer,b,A-
+169,CS,3505,2019,Summer,a,A-
+170,CS,4400,2020,Spring,a,A-
+171,CS,4940,2020,Summer,b,A-
+172,CS,2420,2016,Summer,a,A-
+173,BIOL,1210,2019,Spring,a,A-
+173,BIOL,2010,2017,Summer,a,A-
+173,CS,4500,2019,Fall,b,A-
+175,BIOL,2420,2020,Fall,a,A-
+178,BIOL,2010,2020,Spring,b,A-
+179,BIOL,1030,2019,Spring,a,A-
+179,CS,4500,2016,Spring,b,A-
+181,CS,4000,2020,Spring,b,A-
+181,MATH,3210,2020,Spring,a,A-
+182,MATH,1250,2016,Fall,b,A-
+183,CS,2100,2019,Fall,d,A-
+185,CS,1030,2019,Fall,b,A-
+185,PHYS,2100,2018,Fall,a,A-
+187,BIOL,2210,2017,Summer,a,A-
+187,CS,3810,2020,Fall,a,A-
+187,CS,4000,2017,Spring,a,A-
+187,PHYS,2220,2017,Spring,c,A-
+192,CS,3505,2015,Spring,a,A-
+193,BIOL,2010,2015,Spring,a,A-
+193,PHYS,2100,2015,Spring,a,A-
+194,CS,4970,2019,Fall,a,A-
+194,PHYS,2220,2020,Summer,a,A-
+195,CS,3100,2016,Spring,d,A-
+196,CS,3100,2019,Spring,a,A-
+197,MATH,1250,2018,Summer,c,A-
+199,BIOL,2020,2018,Fall,a,A-
+199,CS,2100,2018,Fall,d,A-
+202,CS,4400,2020,Fall,b,A-
+203,CS,4500,2018,Spring,a,A-
+204,CS,2420,2015,Summer,a,A-
+208,BIOL,2010,2017,Fall,a,A-
+208,MATH,2210,2017,Spring,a,A-
+210,PHYS,2220,2018,Spring,a,A-
+212,BIOL,2030,2015,Fall,a,A-
+212,PHYS,2040,2015,Fall,c,A-
+214,CS,4970,2018,Summer,c,A-
+215,CS,4400,2015,Summer,a,A-
+215,MATH,1250,2016,Fall,a,A-
+215,MATH,2210,2017,Spring,a,A-
+221,PHYS,2210,2019,Fall,c,A-
+228,BIOL,2210,2019,Summer,b,A-
+228,MATH,2210,2019,Spring,b,A-
+229,MATH,3220,2018,Spring,c,A-
+230,CS,4400,2020,Fall,a,A-
+231,BIOL,1010,2019,Spring,b,A-
+233,CS,4940,2020,Summer,b,A-
+235,CS,3505,2019,Fall,c,A-
+237,BIOL,2355,2017,Fall,a,A-
+237,PHYS,2220,2018,Spring,a,A-
+240,CS,3810,2018,Summer,c,A-
+240,CS,4150,2018,Fall,a,A-
+241,CS,3505,2019,Spring,a,A-
+243,BIOL,2030,2017,Spring,b,A-
+243,BIOL,2210,2016,Summer,a,A-
+243,BIOL,2355,2017,Spring,d,A-
+245,CS,3810,2016,Fall,b,A-
+246,MATH,3220,2016,Fall,a,A-
+247,BIOL,1006,2019,Summer,a,A-
+247,BIOL,2355,2019,Spring,a,A-
+248,CS,3505,2019,Fall,c,A-
+248,MATH,3210,2019,Fall,a,A-
+250,CS,4940,2020,Summer,b,A-
+252,CS,3505,2018,Fall,b,A-
+254,PHYS,3210,2020,Spring,a,A-
+255,CS,4150,2018,Fall,b,A-
+255,PHYS,2220,2018,Summer,a,A-
+257,CS,3200,2018,Spring,a,A-
+258,CS,4400,2020,Fall,a,A-
+260,CS,3100,2017,Fall,a,A-
+260,MATH,3210,2020,Summer,a,A-
+261,CS,2100,2018,Summer,a,A-
+261,MATH,3220,2017,Fall,b,A-
+262,BIOL,2010,2017,Fall,a,A-
+262,CS,3505,2018,Summer,b,A-
+262,PHYS,2060,2016,Summer,a,A-
+270,BIOL,2010,2018,Spring,a,A-
+270,BIOL,2021,2016,Fall,a,A-
+270,CS,3500,2019,Fall,b,A-
+271,CS,4940,2020,Summer,b,A-
+272,MATH,2210,2020,Fall,a,A-
+275,BIOL,2325,2018,Summer,a,A-
+276,BIOL,2020,2018,Fall,a,A-
+276,CS,4000,2016,Fall,a,A-
+276,PHYS,2060,2016,Summer,b,A-
+276,PHYS,2100,2017,Summer,a,A-
+277,BIOL,2355,2018,Spring,a,A-
+277,CS,1030,2016,Summer,a,A-
+277,CS,1410,2020,Spring,b,A-
+277,CS,2420,2015,Spring,a,A-
+277,CS,4150,2020,Spring,a,A-
+277,MATH,3210,2016,Fall,a,A-
+278,CS,2100,2016,Summer,c,A-
+279,MATH,1210,2018,Summer,a,A-
+282,BIOL,1030,2016,Spring,a,A-
+282,CS,2420,2016,Fall,a,A-
+282,PHYS,2060,2016,Summer,b,A-
+285,MATH,1250,2016,Summer,a,A-
+285,MATH,1260,2019,Spring,b,A-
+285,PHYS,3210,2018,Fall,a,A-
+287,PHYS,3210,2019,Summer,b,A-
+288,BIOL,2020,2018,Fall,d,A-
+288,CS,3100,2019,Spring,b,A-
+288,PHYS,3220,2017,Fall,d,A-
+289,PHYS,2220,2020,Summer,b,A-
+289,PHYS,3210,2020,Fall,a,A-
+290,BIOL,2330,2015,Fall,a,A-
+290,CS,3200,2016,Summer,a,A-
+292,CS,4400,2020,Fall,b,A-
+292,CS,4970,2020,Summer,c,A-
+292,PHYS,2220,2018,Fall,a,A-
+293,CS,4970,2019,Fall,b,A-
+293,PHYS,2060,2020,Spring,b,A-
+294,CS,4500,2018,Spring,a,A-
+295,CS,3200,2015,Fall,d,A-
+296,BIOL,2021,2018,Fall,c,A-
+296,MATH,2210,2019,Spring,b,A-
+296,PHYS,2220,2020,Summer,a,A-
+298,CS,4970,2019,Summer,b,A-
+300,BIOL,2330,2020,Spring,a,A-
+300,CS,4500,2019,Fall,b,A-
+300,CS,4940,2020,Summer,b,A-
+300,PHYS,3210,2020,Summer,a,A-
+301,MATH,3220,2016,Spring,b,A-
+305,BIOL,1210,2019,Spring,a,A-
+305,MATH,3220,2018,Spring,c,A-
+305,PHYS,2220,2018,Spring,a,A-
+307,PHYS,2140,2016,Spring,c,A-
+307,PHYS,3210,2019,Summer,c,A-
+311,CS,4000,2020,Spring,b,A-
+311,MATH,1250,2017,Summer,b,A-
+311,MATH,3220,2017,Fall,a,A-
+311,PHYS,2220,2020,Summer,a,A-
+312,BIOL,2021,2018,Summer,a,A-
+313,BIOL,1006,2020,Fall,b,A-
+313,CS,3505,2015,Fall,a,A-
+313,MATH,1250,2018,Summer,b,A-
+314,MATH,1220,2017,Spring,b,A-
+317,BIOL,1010,2016,Summer,a,A-
+317,PHYS,2220,2016,Summer,a,A-
+318,CS,4970,2019,Fall,d,A-
+321,MATH,1250,2018,Summer,b,A-
+321,MATH,1250,2018,Summer,c,A-
+325,CS,3200,2020,Spring,c,A-
+329,MATH,1220,2020,Summer,a,A-
+329,MATH,3220,2016,Fall,b,A-
+330,BIOL,1006,2020,Spring,a,A-
+332,BIOL,2355,2018,Summer,c,A-
+333,PHYS,2210,2019,Fall,b,A-
+335,BIOL,1030,2017,Spring,c,A-
+335,MATH,3210,2015,Fall,d,A-
+339,CS,3505,2020,Fall,a,A-
+340,BIOL,2330,2020,Spring,a,A-
+342,BIOL,2325,2019,Spring,b,A-
+342,BIOL,2355,2018,Summer,a,A-
+342,PHYS,3210,2020,Fall,a,A-
+344,CS,1030,2018,Fall,a,A-
+345,CS,2100,2018,Summer,c,A-
+345,CS,2420,2020,Fall,a,A-
+345,PHYS,2220,2020,Summer,a,A-
+347,CS,4150,2020,Fall,a,A-
+348,CS,1410,2018,Spring,a,A-
+348,CS,3500,2020,Summer,a,A-
+357,CS,4500,2016,Spring,b,A-
+359,CS,3810,2019,Fall,b,A-
+359,PHYS,2060,2019,Fall,a,A-
+361,CS,4500,2018,Spring,a,A-
+361,MATH,2210,2017,Summer,a,A-
+362,PHYS,3210,2019,Summer,b,A-
+363,CS,4970,2019,Summer,c,A-
+363,PHYS,2210,2019,Fall,b,A-
+366,CS,3100,2019,Spring,b,A-
+368,CS,2100,2019,Summer,b,A-
+369,BIOL,2325,2016,Summer,a,A-
+369,MATH,2210,2018,Spring,b,A-
+371,CS,2100,2018,Summer,c,A-
+372,BIOL,2355,2019,Spring,b,A-
+373,BIOL,2420,2020,Spring,a,A-
+373,CS,3200,2016,Summer,a,A-
+373,CS,4400,2015,Fall,c,A-
+373,PHYS,2060,2016,Summer,a,A-
+374,BIOL,2325,2018,Spring,a,A-
+374,CS,3100,2016,Spring,b,A-
+374,MATH,3220,2016,Spring,c,A-
+374,PHYS,2040,2015,Fall,b,A-
+377,CS,3810,2018,Summer,b,A-
+377,MATH,1260,2019,Summer,b,A-
+378,BIOL,2030,2017,Spring,c,A-
+378,PHYS,2220,2016,Summer,a,A-
+379,BIOL,2021,2016,Fall,a,A-
+379,CS,4940,2017,Fall,b,A-
+379,CS,4970,2020,Summer,d,A-
+379,PHYS,3220,2018,Summer,a,A-
+380,BIOL,2330,2019,Fall,a,A-
+384,MATH,1250,2020,Summer,a,A-
+385,PHYS,3210,2018,Spring,a,A-
+386,CS,3810,2018,Summer,a,A-
+386,CS,4500,2019,Summer,a,A-
+388,CS,3810,2018,Spring,a,A-
+391,BIOL,2420,2020,Fall,a,A-
+391,CS,3505,2019,Fall,a,A-
+392,CS,4970,2018,Summer,c,A-
+392,MATH,1210,2017,Summer,c,A-
+392,PHYS,2060,2016,Spring,a,A-
+393,BIOL,2355,2018,Spring,a,A-
+393,CS,3505,2016,Summer,a,A-
+395,CS,3500,2016,Spring,a,A-
+396,MATH,2270,2019,Summer,c,A-
+397,BIOL,1006,2018,Spring,a,A-
+397,BIOL,2030,2016,Fall,a,A-
+397,CS,3200,2017,Spring,a,A-
+398,BIOL,1006,2019,Fall,b,A-
+398,CS,4940,2019,Fall,a,A-
+398,MATH,1210,2018,Summer,a,A-
+399,CS,3810,2018,Summer,b,A-
+100,CS,4970,2018,Summer,b,B
+100,PHYS,3210,2019,Fall,a,B
+102,BIOL,1010,2018,Summer,a,B
+102,BIOL,2325,2017,Fall,b,B
+105,BIOL,2355,2017,Spring,b,B
+105,MATH,1220,2017,Spring,d,B
+105,PHYS,2140,2018,Summer,a,B
+106,MATH,1210,2020,Spring,b,B
+106,MATH,1250,2020,Summer,a,B
+106,PHYS,3220,2020,Spring,c,B
+107,CS,3500,2016,Summer,a,B
+107,PHYS,3210,2016,Summer,a,B
+108,CS,3200,2020,Spring,c,B
+108,MATH,1260,2019,Fall,a,B
+109,BIOL,1006,2019,Fall,a,B
+112,CS,3200,2020,Summer,a,B
+113,CS,3810,2020,Fall,a,B
+115,BIOL,2020,2016,Spring,a,B
+117,BIOL,1006,2018,Spring,a,B
+117,BIOL,2021,2018,Summer,a,B
+118,CS,2100,2019,Fall,a,B
+119,MATH,1210,2016,Spring,a,B
+119,MATH,3220,2016,Spring,c,B
+120,CS,1410,2018,Spring,b,B
+121,PHYS,2060,2019,Summer,b,B
+122,BIOL,1010,2020,Summer,b,B
+122,CS,2100,2020,Fall,a,B
+123,MATH,1210,2019,Summer,a,B
+123,MATH,2210,2018,Spring,a,B
+124,BIOL,2355,2020,Fall,a,B
+124,CS,4970,2019,Summer,a,B
+124,MATH,2270,2017,Fall,d,B
+127,CS,4970,2019,Fall,b,B
+127,MATH,1250,2017,Summer,a,B
+127,MATH,3220,2018,Spring,c,B
+128,BIOL,2210,2018,Spring,a,B
+128,BIOL,2420,2020,Summer,a,B
+129,CS,3505,2019,Summer,b,B
+131,MATH,3220,2018,Spring,c,B
+132,CS,4500,2018,Spring,a,B
+133,BIOL,2021,2018,Fall,d,B
+133,CS,3810,2018,Summer,c,B
+134,CS,4000,2017,Summer,a,B
+135,CS,3200,2020,Fall,a,B
+135,MATH,1220,2019,Fall,c,B
+139,MATH,1220,2018,Summer,a,B
+143,CS,4970,2018,Fall,c,B
+144,MATH,3210,2015,Summer,a,B
+146,CS,2100,2019,Fall,c,B
+149,CS,3500,2015,Fall,b,B
+151,BIOL,2325,2018,Summer,a,B
+151,BIOL,2420,2020,Summer,a,B
+151,CS,4400,2019,Spring,b,B
+151,MATH,1250,2020,Summer,a,B
+152,MATH,1260,2019,Spring,b,B
+153,BIOL,1010,2020,Summer,a,B
+158,MATH,1250,2018,Summer,c,B
+162,CS,4150,2015,Summer,a,B
+163,MATH,1210,2018,Fall,b,B
+164,BIOL,2030,2020,Spring,a,B
+164,CS,3500,2020,Summer,a,B
+164,CS,3505,2020,Spring,a,B
+167,CS,4500,2020,Summer,a,B
+169,CS,4000,2020,Spring,a,B
+169,CS,4500,2020,Spring,a,B
+170,MATH,2210,2020,Spring,b,B
+170,PHYS,3220,2020,Spring,a,B
+171,CS,3500,2019,Fall,b,B
+171,CS,3810,2020,Fall,a,B
+173,BIOL,1010,2018,Summer,b,B
+173,CS,3505,2018,Summer,b,B
+173,MATH,1250,2017,Summer,a,B
+176,BIOL,1010,2016,Summer,a,B
+176,BIOL,1030,2016,Fall,a,B
+177,BIOL,1010,2015,Summer,b,B
+177,CS,3810,2018,Summer,a,B
+178,PHYS,2040,2019,Spring,a,B
+179,CS,3500,2019,Summer,a,B
+179,CS,3810,2018,Spring,a,B
+179,MATH,1210,2016,Spring,d,B
+180,MATH,1220,2019,Fall,b,B
+181,CS,2100,2019,Fall,a,B
+181,CS,2100,2019,Fall,d,B
+181,CS,4000,2020,Spring,a,B
+182,BIOL,1010,2015,Summer,a,B
+185,BIOL,1010,2020,Summer,c,B
+185,BIOL,2210,2020,Fall,a,B
+187,PHYS,2040,2017,Fall,c,B
+192,CS,3100,2016,Spring,d,B
+199,BIOL,1006,2017,Fall,a,B
+199,BIOL,2330,2017,Fall,b,B
+199,CS,1410,2018,Spring,b,B
+199,CS,3500,2019,Fall,b,B
+200,BIOL,1010,2020,Summer,a,B
+200,CS,3505,2020,Summer,a,B
+204,BIOL,2325,2015,Fall,c,B
+207,BIOL,2030,2016,Summer,b,B
+207,CS,3200,2016,Summer,b,B
+207,MATH,3220,2017,Fall,a,B
+210,MATH,1220,2016,Spring,a,B
+210,MATH,1250,2017,Summer,b,B
+210,MATH,3220,2016,Spring,a,B
+211,MATH,1260,2015,Summer,a,B
+212,MATH,2210,2015,Summer,c,B
+214,BIOL,2355,2018,Spring,a,B
+214,MATH,1210,2016,Fall,a,B
+215,CS,4500,2016,Spring,a,B
+215,MATH,1210,2016,Fall,b,B
+215,PHYS,2100,2017,Summer,b,B
+216,CS,1410,2016,Spring,a,B
+221,PHYS,2060,2020,Spring,b,B
+227,BIOL,2210,2018,Summer,b,B
+229,CS,1410,2018,Spring,b,B
+229,CS,3500,2016,Spring,a,B
+230,MATH,2270,2020,Fall,a,B
+231,MATH,2210,2018,Spring,b,B
+231,PHYS,2210,2017,Summer,a,B
+234,BIOL,1006,2019,Summer,a,B
+235,CS,4150,2020,Fall,a,B
+238,MATH,2280,2018,Spring,a,B
+240,BIOL,1010,2019,Spring,c,B
+240,CS,3505,2018,Fall,a,B
+241,BIOL,2420,2020,Spring,b,B
+241,CS,3810,2019,Fall,a,B
+241,MATH,2210,2020,Spring,c,B
+246,CS,3200,2016,Summer,a,B
+246,MATH,3210,2015,Fall,a,B
+247,CS,4970,2018,Fall,b,B
+247,MATH,1250,2018,Summer,a,B
+248,BIOL,2021,2018,Fall,c,B
+248,MATH,1220,2019,Fall,a,B
+248,MATH,2270,2019,Summer,a,B
+249,BIOL,1010,2017,Spring,a,B
+249,BIOL,2030,2015,Fall,a,B
+251,CS,4970,2020,Summer,a,B
+251,MATH,2210,2020,Spring,c,B
+255,CS,3810,2018,Spring,a,B
+255,CS,4000,2017,Spring,a,B
+255,MATH,2270,2019,Spring,a,B
+255,PHYS,3210,2019,Summer,b,B
+257,BIOL,1030,2017,Spring,a,B
+258,BIOL,2355,2020,Summer,a,B
+258,CS,3505,2018,Fall,a,B
+258,CS,3810,2019,Fall,a,B
+258,PHYS,3210,2019,Spring,a,B
+260,BIOL,2210,2018,Summer,a,B
+260,CS,2100,2019,Fall,c,B
+264,PHYS,2060,2016,Summer,a,B
+264,PHYS,2100,2017,Summer,c,B
+267,CS,4400,2019,Summer,b,B
+267,PHYS,2140,2020,Fall,a,B
+267,PHYS,2220,2018,Fall,a,B
+268,CS,2420,2016,Fall,b,B
+270,BIOL,1210,2016,Spring,a,B
+270,CS,3200,2016,Summer,a,B
+270,CS,3810,2018,Summer,b,B
+270,MATH,2270,2020,Spring,a,B
+270,PHYS,2220,2017,Spring,c,B
+274,BIOL,2355,2018,Summer,c,B
+274,CS,3200,2018,Spring,a,B
+276,BIOL,2325,2019,Summer,a,B
+276,CS,1410,2015,Summer,b,B
+276,CS,2100,2016,Spring,a,B
+276,CS,2420,2015,Fall,a,B
+276,CS,4500,2015,Summer,a,B
+276,MATH,3220,2016,Summer,a,B
+277,MATH,1220,2017,Spring,c,B
+277,MATH,3220,2016,Fall,a,B
+277,PHYS,2220,2017,Spring,c,B
+277,PHYS,3210,2018,Spring,a,B
+278,MATH,1210,2016,Fall,b,B
+282,BIOL,2355,2017,Spring,c,B
+285,BIOL,2030,2017,Spring,b,B
+285,PHYS,2040,2017,Fall,b,B
+288,CS,3500,2016,Summer,a,B
+289,BIOL,1006,2020,Fall,c,B
+289,MATH,1250,2020,Summer,a,B
+290,BIOL,2021,2015,Summer,c,B
+290,CS,1410,2017,Spring,a,B
+292,CS,4150,2018,Fall,a,B
+292,PHYS,2060,2020,Spring,b,B
+292,PHYS,3210,2019,Spring,c,B
+293,CS,4500,2019,Fall,a,B
+294,CS,4970,2019,Summer,d,B
+296,BIOL,2021,2018,Fall,d,B
+296,CS,2100,2019,Summer,a,B
+296,CS,3505,2019,Summer,b,B
+297,BIOL,2210,2020,Fall,a,B
+305,CS,3810,2018,Spring,a,B
+306,PHYS,3210,2020,Fall,a,B
+307,BIOL,1210,2019,Spring,a,B
+307,MATH,1220,2016,Spring,a,B
+309,BIOL,2330,2017,Summer,a,B
+309,CS,4970,2020,Summer,d,B
+309,MATH,2270,2020,Spring,a,B
+309,MATH,3220,2018,Spring,c,B
+309,PHYS,2210,2019,Fall,b,B
+311,CS,3505,2019,Fall,c,B
+312,BIOL,1010,2017,Spring,a,B
+312,PHYS,2140,2016,Summer,a,B
+312,PHYS,2220,2017,Spring,b,B
+312,PHYS,2220,2017,Spring,d,B
+312,PHYS,3210,2019,Spring,b,B
+313,BIOL,1010,2018,Summer,b,B
+314,BIOL,2355,2018,Fall,a,B
+314,CS,2100,2019,Summer,a,B
+314,MATH,3210,2019,Spring,b,B
+314,PHYS,2140,2017,Summer,a,B
+316,CS,2100,2019,Fall,d,B
+318,BIOL,1030,2019,Spring,c,B
+318,BIOL,2325,2018,Summer,a,B
+318,CS,4500,2018,Spring,b,B
+321,BIOL,1030,2015,Summer,a,B
+321,CS,1030,2016,Fall,a,B
+321,CS,4000,2016,Fall,a,B
+321,CS,4500,2016,Spring,b,B
+321,CS,4970,2019,Fall,b,B
+321,PHYS,2040,2016,Spring,a,B
+321,PHYS,3220,2020,Spring,b,B
+323,BIOL,2355,2020,Summer,a,B
+326,MATH,3220,2017,Fall,b,B
+329,BIOL,2355,2017,Spring,b,B
+329,CS,2100,2018,Summer,b,B
+329,CS,3810,2016,Fall,b,B
+329,PHYS,2060,2018,Fall,b,B
+332,BIOL,2325,2018,Spring,a,B
+332,MATH,1210,2019,Spring,a,B
+333,BIOL,2355,2020,Summer,a,B
+333,CS,2100,2020,Fall,a,B
+333,MATH,2270,2019,Fall,a,B
+335,CS,1410,2016,Spring,b,B
+335,MATH,1250,2015,Fall,a,B
+341,CS,4000,2020,Fall,a,B
+342,MATH,1250,2020,Summer,a,B
+344,CS,4970,2018,Summer,a,B
+345,BIOL,2021,2017,Fall,a,B
+345,BIOL,2030,2019,Summer,d,B
+345,CS,4970,2019,Spring,b,B
+348,BIOL,1010,2020,Summer,b,B
+348,BIOL,2030,2017,Spring,b,B
+348,CS,2100,2017,Fall,a,B
+348,MATH,3210,2019,Spring,a,B
+351,MATH,1210,2019,Spring,a,B
+356,BIOL,2355,2019,Spring,a,B
+357,BIOL,2020,2016,Spring,a,B
+358,MATH,3210,2019,Fall,a,B
+360,MATH,2270,2020,Fall,a,B
+363,BIOL,2010,2020,Summer,b,B
+364,CS,3500,2020,Summer,a,B
+365,BIOL,2420,2020,Spring,b,B
+366,BIOL,2021,2018,Summer,a,B
+366,MATH,1220,2019,Fall,b,B
+368,BIOL,1010,2018,Summer,a,B
+368,CS,4000,2020,Fall,a,B
+368,PHYS,2210,2019,Spring,c,B
+369,BIOL,2210,2018,Summer,a,B
+371,BIOL,1010,2020,Summer,d,B
+372,CS,3810,2018,Spring,a,B
+372,CS,4970,2018,Summer,c,B
+373,PHYS,2040,2015,Fall,b,B
+373,PHYS,2210,2017,Summer,d,B
+375,BIOL,2210,2017,Summer,c,B
+378,BIOL,1030,2018,Summer,a,B
+378,BIOL,2330,2019,Fall,a,B
+378,MATH,1250,2020,Summer,a,B
+378,MATH,3210,2019,Spring,a,B
+379,CS,4500,2018,Spring,b,B
+379,MATH,2270,2019,Spring,a,B
+380,CS,3500,2019,Fall,a,B
+382,CS,1410,2015,Summer,d,B
+384,CS,2100,2018,Fall,b,B
+384,MATH,1210,2018,Fall,a,B
+385,CS,4000,2018,Spring,a,B
+386,CS,3500,2020,Summer,a,B
+387,CS,1030,2018,Fall,a,B
+390,CS,2100,2019,Summer,a,B
+390,CS,2420,2019,Summer,a,B
+390,CS,3505,2020,Fall,c,B
+390,MATH,1220,2019,Fall,c,B
+390,PHYS,2060,2020,Fall,a,B
+390,PHYS,2210,2019,Fall,c,B
+390,PHYS,2220,2020,Summer,b,B
+391,CS,2100,2018,Fall,d,B
+392,CS,4400,2015,Fall,b,B
+392,MATH,2210,2017,Summer,a,B
+397,MATH,1260,2019,Summer,a,B
+398,PHYS,2060,2019,Summer,a,B
+100,BIOL,2020,2018,Fall,b,B+
+100,MATH,1260,2019,Fall,a,B+
+101,PHYS,2140,2018,Summer,a,B+
+102,MATH,2270,2017,Fall,d,B+
+102,PHYS,2220,2018,Spring,a,B+
+105,CS,3200,2016,Fall,d,B+
+106,CS,3505,2020,Fall,b,B+
+107,BIOL,2355,2020,Spring,a,B+
+107,MATH,3220,2017,Fall,a,B+
+109,BIOL,2010,2020,Spring,a,B+
+110,CS,4000,2020,Fall,a,B+
+115,BIOL,1006,2016,Spring,a,B+
+115,BIOL,1210,2017,Spring,a,B+
+116,CS,3810,2016,Fall,b,B+
+117,MATH,1220,2017,Spring,c,B+
+117,MATH,2210,2018,Spring,a,B+
+118,CS,1030,2020,Spring,c,B+
+120,BIOL,2210,2017,Summer,b,B+
+120,CS,4400,2015,Summer,a,B+
+120,PHYS,2100,2016,Fall,a,B+
+120,PHYS,2140,2015,Fall,a,B+
+122,BIOL,1010,2020,Summer,a,B+
+123,BIOL,2420,2017,Summer,b,B+
+123,MATH,2280,2015,Fall,a,B+
+123,PHYS,2060,2019,Fall,c,B+
+124,CS,4400,2019,Fall,b,B+
+124,PHYS,2210,2018,Fall,c,B+
+127,CS,4000,2019,Spring,a,B+
+128,MATH,2210,2017,Summer,a,B+
+129,CS,3100,2019,Spring,b,B+
+129,CS,3505,2019,Summer,c,B+
+129,CS,3810,2018,Summer,c,B+
+131,CS,3200,2020,Spring,a,B+
+131,CS,3810,2019,Fall,a,B+
+131,CS,4500,2019,Fall,b,B+
+132,CS,2420,2017,Summer,b,B+
+134,CS,2100,2016,Summer,c,B+
+134,MATH,3220,2016,Fall,b,B+
+135,CS,4150,2020,Fall,a,B+
+135,MATH,3210,2020,Summer,a,B+
+140,BIOL,2030,2015,Fall,a,B+
+143,CS,4500,2019,Fall,c,B+
+143,CS,4940,2017,Fall,a,B+
+148,CS,4150,2020,Fall,a,B+
+151,BIOL,1210,2018,Fall,b,B+
+151,PHYS,2140,2018,Summer,a,B+
+152,CS,4970,2019,Fall,c,B+
+152,PHYS,3210,2019,Summer,b,B+
+153,PHYS,3210,2020,Fall,a,B+
+158,CS,2100,2018,Fall,a,B+
+160,BIOL,1030,2016,Summer,a,B+
+160,CS,3810,2016,Summer,a,B+
+163,BIOL,2325,2015,Fall,c,B+
+163,CS,4150,2016,Summer,a,B+
+163,MATH,3220,2016,Summer,a,B+
+166,BIOL,2010,2020,Summer,a,B+
+166,MATH,3210,2020,Summer,a,B+
+174,BIOL,2210,2018,Summer,a,B+
+176,CS,4150,2015,Summer,a,B+
+176,CS,4500,2016,Fall,a,B+
+177,BIOL,2021,2018,Spring,a,B+
+177,BIOL,2355,2020,Summer,b,B+
+179,CS,2420,2017,Summer,c,B+
+179,CS,4400,2016,Summer,a,B+
+179,MATH,3220,2018,Spring,d,B+
+179,PHYS,2100,2016,Fall,b,B+
+180,CS,3500,2019,Fall,a,B+
+181,MATH,1220,2019,Fall,a,B+
+182,BIOL,2020,2015,Fall,c,B+
+182,MATH,2270,2017,Fall,c,B+
+183,PHYS,2210,2018,Fall,a,B+
+185,PHYS,2060,2019,Fall,a,B+
+186,BIOL,2355,2020,Fall,a,B+
+187,BIOL,1006,2019,Fall,a,B+
+192,BIOL,2325,2015,Fall,c,B+
+192,CS,4150,2015,Summer,a,B+
+196,MATH,2280,2018,Fall,c,B+
+196,PHYS,2220,2018,Fall,a,B+
+197,CS,3200,2018,Spring,a,B+
+197,PHYS,3210,2018,Spring,c,B+
+200,MATH,3210,2020,Fall,a,B+
+207,CS,4500,2017,Summer,a,B+
+208,BIOL,2330,2017,Fall,a,B+
+210,MATH,2270,2015,Fall,b,B+
+210,MATH,2280,2020,Spring,a,B+
+210,PHYS,2040,2015,Fall,c,B+
+214,BIOL,1010,2018,Summer,a,B+
+214,BIOL,2020,2016,Spring,a,B+
+214,CS,1030,2016,Summer,a,B+
+214,MATH,1250,2016,Spring,a,B+
+215,BIOL,2210,2017,Spring,b,B+
+215,BIOL,2210,2017,Spring,c,B+
+217,BIOL,2325,2018,Fall,c,B+
+219,CS,2100,2020,Fall,a,B+
+220,CS,3810,2020,Fall,a,B+
+222,BIOL,1006,2020,Fall,a,B+
+222,CS,4970,2020,Summer,b,B+
+225,MATH,2210,2020,Fall,a,B+
+227,PHYS,2220,2018,Spring,a,B+
+227,PHYS,3220,2020,Spring,b,B+
+228,CS,4400,2020,Spring,a,B+
+228,MATH,1210,2019,Summer,a,B+
+228,PHYS,3210,2020,Fall,a,B+
+229,BIOL,2330,2017,Summer,a,B+
+229,PHYS,2060,2016,Spring,a,B+
+230,BIOL,2355,2018,Spring,a,B+
+231,BIOL,2020,2018,Fall,d,B+
+234,MATH,2280,2019,Fall,c,B+
+240,PHYS,3210,2020,Summer,a,B+
+243,CS,1030,2016,Fall,a,B+
+245,PHYS,2040,2015,Fall,a,B+
+246,BIOL,2030,2017,Spring,b,B+
+246,CS,4400,2017,Spring,a,B+
+246,PHYS,3210,2017,Summer,a,B+
+247,BIOL,1010,2019,Spring,d,B+
+247,CS,2100,2020,Fall,a,B+
+248,PHYS,2060,2018,Fall,b,B+
+249,CS,4400,2017,Spring,a,B+
+249,MATH,2210,2017,Spring,a,B+
+249,PHYS,3210,2016,Summer,a,B+
+254,BIOL,1010,2020,Summer,d,B+
+254,CS,3200,2020,Summer,a,B+
+255,CS,3200,2018,Spring,b,B+
+256,BIOL,1010,2020,Summer,a,B+
+256,CS,4000,2019,Spring,a,B+
+257,BIOL,1010,2020,Summer,b,B+
+257,CS,4000,2020,Spring,b,B+
+258,MATH,1260,2019,Fall,a,B+
+259,BIOL,1006,2019,Summer,a,B+
+259,MATH,3210,2019,Spring,b,B+
+259,PHYS,2040,2017,Fall,a,B+
+260,MATH,1210,2020,Spring,b,B+
+260,MATH,1250,2018,Spring,a,B+
+262,BIOL,2325,2018,Summer,a,B+
+262,MATH,2280,2018,Spring,a,B+
+263,CS,2420,2020,Summer,a,B+
+264,BIOL,2355,2017,Fall,b,B+
+264,CS,3100,2017,Fall,a,B+
+267,BIOL,1006,2020,Spring,a,B+
+269,PHYS,3220,2020,Spring,b,B+
+270,BIOL,1006,2018,Spring,b,B+
+270,BIOL,1010,2020,Summer,c,B+
+270,BIOL,1030,2016,Summer,a,B+
+270,BIOL,2020,2018,Fall,a,B+
+270,BIOL,2330,2016,Fall,a,B+
+270,BIOL,2420,2018,Spring,a,B+
+270,MATH,1220,2015,Summer,b,B+
+270,PHYS,2040,2017,Fall,c,B+
+270,PHYS,3210,2017,Fall,a,B+
+270,PHYS,3220,2017,Fall,d,B+
+271,BIOL,1006,2020,Fall,c,B+
+274,MATH,1220,2019,Fall,b,B+
+274,MATH,2210,2020,Spring,a,B+
+276,MATH,1210,2016,Spring,a,B+
+276,MATH,1220,2018,Spring,a,B+
+276,MATH,1260,2019,Summer,b,B+
+276,MATH,2210,2015,Spring,b,B+
+277,BIOL,1030,2016,Summer,a,B+
+277,BIOL,2010,2017,Summer,a,B+
+277,CS,4940,2020,Summer,a,B+
+278,BIOL,1210,2017,Spring,a,B+
+278,BIOL,2355,2017,Spring,a,B+
+281,MATH,2210,2020,Fall,a,B+
+282,BIOL,1210,2017,Summer,a,B+
+284,MATH,3210,2019,Fall,a,B+
+285,BIOL,2010,2018,Spring,a,B+
+285,CS,4150,2016,Summer,b,B+
+285,PHYS,2140,2017,Summer,a,B+
+288,PHYS,2210,2018,Fall,b,B+
+290,PHYS,2060,2016,Spring,b,B+
+292,MATH,3220,2018,Spring,a,B+
+293,BIOL,2020,2019,Summer,a,B+
+293,BIOL,2210,2019,Fall,b,B+
+293,MATH,1220,2020,Summer,a,B+
+294,PHYS,2060,2019,Summer,b,B+
+296,BIOL,1006,2018,Fall,a,B+
+296,BIOL,2010,2020,Summer,b,B+
+296,PHYS,3220,2020,Spring,c,B+
+300,BIOL,1010,2020,Summer,d,B+
+301,CS,4500,2016,Spring,b,B+
+301,MATH,3210,2015,Summer,a,B+
+303,MATH,1260,2019,Summer,b,B+
+304,MATH,2270,2017,Summer,a,B+
+306,CS,3200,2020,Summer,a,B+
+307,BIOL,2020,2019,Summer,a,B+
+309,BIOL,2021,2018,Fall,b,B+
+309,BIOL,2325,2018,Fall,a,B+
+309,CS,1030,2020,Spring,c,B+
+309,CS,2100,2018,Fall,b,B+
+310,PHYS,3210,2020,Spring,a,B+
+311,CS,2100,2017,Fall,a,B+
+311,PHYS,2210,2019,Spring,a,B+
+312,BIOL,1006,2016,Summer,a,B+
+312,CS,1030,2016,Spring,a,B+
+312,CS,1410,2020,Spring,a,B+
+312,CS,2100,2019,Spring,b,B+
+312,CS,3810,2018,Summer,d,B+
+312,MATH,1220,2018,Spring,a,B+
+312,MATH,3210,2020,Summer,a,B+
+313,CS,3810,2018,Spring,a,B+
+313,CS,4400,2017,Spring,c,B+
+313,PHYS,2140,2016,Spring,b,B+
+314,BIOL,1010,2019,Spring,d,B+
+314,CS,3505,2019,Spring,b,B+
+314,PHYS,2040,2017,Fall,c,B+
+317,PHYS,2140,2016,Summer,a,B+
+318,MATH,2280,2019,Fall,b,B+
+318,PHYS,2140,2019,Fall,b,B+
+321,PHYS,2100,2015,Spring,b,B+
+323,BIOL,1010,2020,Summer,d,B+
+326,BIOL,1006,2017,Fall,a,B+
+326,CS,2420,2017,Fall,a,B+
+329,CS,1410,2020,Spring,b,B+
+332,BIOL,1030,2020,Summer,a,B+
+332,PHYS,2210,2018,Fall,c,B+
+333,CS,3505,2020,Fall,b,B+
+333,PHYS,3210,2019,Summer,c,B+
+339,CS,4970,2020,Summer,c,B+
+340,CS,4970,2019,Fall,d,B+
+344,PHYS,2220,2018,Summer,a,B+
+345,BIOL,1006,2017,Fall,a,B+
+345,BIOL,1010,2018,Fall,a,B+
+345,CS,4500,2018,Spring,d,B+
+345,MATH,2270,2019,Summer,c,B+
+345,PHYS,3220,2017,Fall,b,B+
+348,BIOL,2420,2017,Summer,b,B+
+348,CS,2420,2016,Spring,a,B+
+348,MATH,2210,2015,Summer,c,B+
+355,BIOL,2030,2017,Spring,d,B+
+355,CS,3500,2017,Fall,b,B+
+355,PHYS,2060,2016,Spring,a,B+
+356,BIOL,2325,2018,Fall,c,B+
+357,MATH,1220,2016,Spring,a,B+
+359,CS,2100,2019,Summer,b,B+
+360,BIOL,2210,2020,Fall,a,B+
+361,CS,2100,2018,Spring,a,B+
+362,PHYS,2210,2018,Fall,c,B+
+364,CS,4000,2020,Spring,a,B+
+364,MATH,1260,2019,Fall,a,B+
+366,CS,1030,2018,Fall,a,B+
+366,CS,2100,2017,Fall,a,B+
+366,CS,4970,2019,Spring,a,B+
+368,CS,3505,2018,Summer,a,B+
+369,CS,3200,2016,Fall,d,B+
+371,CS,4000,2020,Spring,b,B+
+372,CS,3200,2019,Spring,a,B+
+372,CS,3505,2019,Summer,b,B+
+373,BIOL,1006,2018,Spring,b,B+
+373,BIOL,2325,2018,Spring,a,B+
+373,PHYS,2140,2015,Summer,c,B+
+374,MATH,3210,2015,Fall,a,B+
+374,PHYS,3210,2018,Spring,c,B+
+377,BIOL,2210,2019,Summer,a,B+
+377,CS,3505,2018,Summer,a,B+
+377,CS,4400,2019,Fall,b,B+
+378,BIOL,1006,2020,Fall,b,B+
+378,BIOL,2020,2018,Fall,b,B+
+378,CS,3100,2016,Fall,a,B+
+378,PHYS,3210,2017,Summer,a,B+
+379,BIOL,1030,2015,Spring,d,B+
+379,CS,3200,2016,Summer,a,B+
+379,MATH,2280,2019,Fall,b,B+
+380,BIOL,1030,2019,Summer,a,B+
+380,BIOL,2210,2019,Fall,a,B+
+384,BIOL,1010,2020,Summer,b,B+
+384,BIOL,2021,2018,Fall,c,B+
+384,MATH,2210,2020,Fall,a,B+
+385,BIOL,2325,2017,Fall,a,B+
+385,CS,3500,2017,Fall,c,B+
+385,MATH,1220,2017,Spring,c,B+
+388,CS,4400,2017,Spring,c,B+
+389,MATH,1220,2016,Spring,a,B+
+390,BIOL,1006,2020,Fall,a,B+
+390,BIOL,2010,2020,Summer,b,B+
+392,BIOL,1010,2018,Summer,a,B+
+392,PHYS,3220,2017,Summer,a,B+
+393,PHYS,3210,2017,Summer,a,B+
+394,BIOL,2021,2015,Spring,a,B+
+395,CS,1030,2016,Spring,a,B+
+396,BIOL,2030,2019,Summer,b,B+
+397,CS,4400,2019,Summer,a,B+
+397,MATH,1220,2020,Summer,a,B+
+397,PHYS,2210,2019,Summer,a,B+
+398,CS,1030,2019,Fall,a,B+
+399,BIOL,2030,2019,Summer,c,B+
+101,PHYS,2210,2018,Fall,a,B-
+102,CS,1030,2016,Fall,a,B-
+102,CS,3200,2016,Fall,b,B-
+106,CS,4400,2020,Fall,b,B-
+106,MATH,2280,2020,Spring,b,B-
+106,PHYS,2220,2020,Summer,a,B-
+107,CS,4970,2016,Fall,a,B-
+109,BIOL,2030,2019,Summer,c,B-
+109,CS,3200,2018,Spring,c,B-
+109,CS,3500,2017,Fall,b,B-
+109,MATH,1250,2018,Spring,a,B-
+109,MATH,2270,2017,Fall,a,B-
+113,BIOL,1006,2018,Fall,a,B-
+113,PHYS,2220,2020,Spring,a,B-
+115,BIOL,2021,2017,Summer,a,B-
+115,PHYS,2060,2016,Spring,a,B-
+116,CS,1030,2016,Fall,a,B-
+116,CS,4970,2017,Spring,a,B-
+117,BIOL,1030,2016,Spring,a,B-
+117,MATH,1250,2017,Summer,d,B-
+118,CS,3500,2019,Summer,a,B-
+119,CS,2420,2017,Summer,a,B-
+119,CS,4400,2020,Fall,a,B-
+119,MATH,2210,2019,Spring,b,B-
+120,BIOL,2010,2017,Fall,a,B-
+120,MATH,1210,2015,Summer,a,B-
+120,MATH,2210,2015,Summer,c,B-
+120,MATH,3210,2017,Spring,a,B-
+120,PHYS,2210,2018,Fall,b,B-
+122,PHYS,2060,2020,Spring,a,B-
+123,BIOL,1030,2020,Summer,a,B-
+123,CS,1030,2016,Summer,a,B-
+123,CS,3100,2017,Fall,a,B-
+123,CS,4150,2020,Spring,a,B-
+123,PHYS,2210,2019,Fall,c,B-
+124,CS,3810,2020,Fall,a,B-
+127,MATH,1250,2017,Summer,b,B-
+127,PHYS,2210,2017,Summer,c,B-
+128,PHYS,2210,2019,Summer,a,B-
+128,PHYS,2220,2018,Spring,a,B-
+131,CS,1030,2020,Fall,a,B-
+131,CS,4400,2020,Fall,a,B-
+131,MATH,2270,2017,Fall,c,B-
+133,CS,2420,2020,Summer,a,B-
+133,PHYS,3210,2019,Summer,a,B-
+134,PHYS,2220,2018,Spring,a,B-
+134,PHYS,3210,2016,Summer,b,B-
+135,BIOL,2010,2020,Spring,a,B-
+140,BIOL,2420,2015,Spring,c,B-
+144,MATH,1260,2015,Summer,a,B-
+146,BIOL,2355,2019,Spring,c,B-
+146,CS,4400,2019,Summer,a,B-
+151,CS,4000,2017,Spring,a,B-
+151,CS,4970,2020,Summer,d,B-
+152,BIOL,2325,2019,Spring,a,B-
+152,CS,2100,2020,Spring,a,B-
+152,CS,3505,2019,Spring,a,B-
+152,CS,4400,2020,Fall,a,B-
+153,PHYS,2060,2020,Spring,b,B-
+155,BIOL,2355,2017,Fall,b,B-
+156,CS,3505,2018,Fall,a,B-
+163,CS,4970,2018,Summer,c,B-
+164,CS,3200,2019,Spring,a,B-
+165,MATH,3220,2018,Spring,c,B-
+169,BIOL,2210,2018,Summer,a,B-
+169,MATH,2210,2019,Spring,a,B-
+170,BIOL,1030,2020,Summer,a,B-
+171,CS,4970,2020,Summer,d,B-
+173,MATH,1260,2020,Spring,a,B-
+177,CS,2420,2016,Fall,a,B-
+178,CS,2100,2019,Fall,b,B-
+179,CS,4970,2016,Fall,b,B-
+179,MATH,1220,2017,Spring,b,B-
+179,PHYS,2210,2017,Summer,b,B-
+182,BIOL,2420,2017,Summer,a,B-
+187,BIOL,2330,2017,Fall,b,B-
+187,CS,3505,2019,Spring,b,B-
+187,MATH,3210,2020,Summer,a,B-
+187,PHYS,2140,2017,Fall,a,B-
+192,MATH,1220,2015,Summer,a,B-
+194,CS,4500,2019,Fall,d,B-
+194,MATH,2270,2019,Summer,b,B-
+195,BIOL,1030,2016,Summer,a,B-
+195,BIOL,2010,2015,Summer,a,B-
+197,BIOL,1010,2018,Summer,b,B-
+199,BIOL,2021,2018,Fall,a,B-
+199,CS,4970,2019,Summer,a,B-
+200,CS,4970,2019,Fall,c,B-
+208,MATH,1250,2017,Summer,d,B-
+208,PHYS,2210,2017,Summer,d,B-
+210,BIOL,2420,2020,Spring,a,B-
+213,BIOL,1030,2016,Fall,a,B-
+213,CS,3100,2016,Fall,a,B-
+214,BIOL,2010,2018,Spring,a,B-
+215,BIOL,1030,2017,Spring,c,B-
+215,MATH,1220,2017,Summer,a,B-
+217,BIOL,1030,2019,Spring,b,B-
+220,CS,4970,2018,Summer,c,B-
+221,CS,4970,2020,Summer,a,B-
+223,MATH,2270,2020,Spring,a,B-
+228,BIOL,1010,2019,Spring,b,B-
+228,BIOL,2030,2019,Summer,b,B-
+228,CS,3500,2019,Summer,a,B-
+229,CS,3200,2016,Fall,c,B-
+229,MATH,1210,2016,Spring,b,B-
+230,CS,3810,2018,Spring,a,B-
+230,PHYS,2060,2019,Summer,a,B-
+230,PHYS,3220,2017,Fall,c,B-
+231,CS,1410,2018,Spring,a,B-
+231,CS,3200,2020,Summer,a,B-
+235,BIOL,2420,2020,Spring,a,B-
+235,CS,2100,2019,Fall,b,B-
+238,PHYS,2210,2019,Spring,b,B-
+239,MATH,1250,2018,Summer,b,B-
+239,PHYS,2060,2018,Fall,a,B-
+244,BIOL,1010,2020,Summer,d,B-
+244,BIOL,2355,2020,Summer,b,B-
+246,BIOL,2355,2015,Summer,a,B-
+246,CS,3500,2015,Fall,b,B-
+247,BIOL,2030,2019,Summer,c,B-
+247,PHYS,2220,2020,Summer,a,B-
+248,BIOL,2010,2020,Summer,a,B-
+248,MATH,2280,2019,Fall,c,B-
+252,PHYS,3220,2017,Fall,d,B-
+254,CS,4000,2020,Spring,a,B-
+255,BIOL,2325,2018,Summer,a,B-
+255,CS,4500,2019,Fall,b,B-
+256,BIOL,2355,2017,Fall,b,B-
+256,CS,4940,2019,Fall,a,B-
+256,MATH,1260,2019,Spring,a,B-
+258,BIOL,1010,2018,Fall,a,B-
+258,BIOL,2210,2018,Summer,c,B-
+258,CS,2100,2018,Summer,a,B-
+258,CS,4940,2020,Summer,b,B-
+259,CS,3505,2018,Summer,b,B-
+259,PHYS,2060,2018,Fall,d,B-
+260,BIOL,1010,2018,Summer,a,B-
+260,BIOL,1030,2019,Summer,a,B-
+260,CS,3200,2020,Summer,a,B-
+261,PHYS,2060,2018,Fall,b,B-
+264,BIOL,2021,2017,Fall,a,B-
+267,CS,3505,2020,Summer,a,B-
+267,PHYS,3220,2020,Spring,a,B-
+268,CS,4970,2016,Fall,a,B-
+270,BIOL,1010,2020,Summer,a,B-
+270,PHYS,2140,2015,Summer,b,B-
+271,BIOL,2210,2020,Fall,a,B-
+275,CS,4400,2019,Spring,b,B-
+276,BIOL,2210,2018,Spring,a,B-
+276,PHYS,2140,2015,Fall,a,B-
+277,CS,4400,2015,Summer,a,B-
+277,MATH,2210,2017,Summer,a,B-
+277,PHYS,3220,2016,Summer,b,B-
+282,BIOL,2021,2015,Spring,a,B-
+282,CS,3810,2016,Fall,a,B-
+282,MATH,1220,2015,Summer,c,B-
+285,BIOL,2210,2017,Summer,c,B-
+288,CS,4150,2016,Summer,b,B-
+290,BIOL,1006,2015,Summer,b,B-
+290,BIOL,1010,2015,Fall,b,B-
+290,BIOL,2420,2015,Fall,a,B-
+290,MATH,1250,2016,Spring,a,B-
+292,CS,3500,2017,Summer,a,B-
+296,CS,2420,2018,Spring,a,B-
+296,PHYS,2040,2019,Spring,a,B-
+298,CS,4400,2019,Summer,b,B-
+299,BIOL,1210,2017,Spring,a,B-
+300,CS,3505,2019,Summer,b,B-
+303,CS,1030,2019,Fall,b,B-
+306,BIOL,1010,2020,Summer,b,B-
+306,BIOL,2010,2020,Summer,b,B-
+309,MATH,1250,2020,Summer,a,B-
+309,MATH,2210,2018,Spring,b,B-
+309,PHYS,2220,2020,Summer,a,B-
+310,PHYS,2060,2020,Spring,a,B-
+312,CS,3500,2020,Summer,a,B-
+312,CS,4940,2020,Summer,b,B-
+313,CS,2100,2015,Summer,a,B-
+313,CS,4000,2018,Spring,a,B-
+313,CS,4500,2018,Spring,d,B-
+314,CS,3500,2017,Fall,a,B-
+314,CS,4150,2020,Spring,a,B-
+318,MATH,1260,2019,Summer,a,B-
+321,BIOL,2020,2018,Spring,a,B-
+321,BIOL,2325,2015,Spring,a,B-
+321,BIOL,2355,2016,Spring,b,B-
+321,CS,2420,2016,Summer,a,B-
+321,PHYS,3210,2016,Fall,a,B-
+325,BIOL,1030,2020,Spring,a,B-
+329,MATH,3210,2020,Fall,a,B-
+329,PHYS,3220,2017,Summer,a,B-
+332,BIOL,1010,2019,Spring,c,B-
+332,BIOL,1210,2018,Spring,a,B-
+332,CS,2100,2018,Summer,c,B-
+336,CS,3200,2015,Fall,c,B-
+341,CS,4970,2020,Fall,d,B-
+341,PHYS,3220,2020,Spring,d,B-
+342,BIOL,2020,2018,Fall,d,B-
+342,BIOL,2021,2018,Fall,c,B-
+342,CS,4000,2017,Fall,a,B-
+345,BIOL,2020,2018,Fall,b,B-
+345,BIOL,2355,2019,Spring,c,B-
+347,BIOL,1030,2019,Summer,a,B-
+347,CS,2100,2019,Summer,a,B-
+348,BIOL,2021,2017,Summer,a,B-
+348,BIOL,2210,2017,Spring,b,B-
+348,MATH,1210,2019,Spring,a,B-
+348,PHYS,3210,2020,Spring,a,B-
+348,PHYS,3220,2020,Spring,b,B-
+353,PHYS,2100,2017,Summer,c,B-
+355,BIOL,2330,2017,Fall,a,B-
+356,BIOL,1006,2019,Summer,a,B-
+356,CS,3505,2019,Summer,d,B-
+356,MATH,1250,2018,Summer,a,B-
+356,MATH,1260,2019,Spring,b,B-
+359,CS,4970,2019,Summer,b,B-
+360,BIOL,1030,2020,Summer,a,B-
+361,CS,4000,2017,Fall,b,B-
+361,MATH,1250,2018,Spring,a,B-
+362,BIOL,2020,2018,Fall,c,B-
+362,CS,4940,2020,Summer,a,B-
+362,MATH,1250,2018,Summer,c,B-
+364,CS,4500,2020,Spring,a,B-
+365,CS,4500,2019,Fall,d,B-
+366,BIOL,2210,2020,Fall,a,B-
+368,BIOL,2420,2020,Summer,a,B-
+369,MATH,1210,2016,Fall,c,B-
+371,BIOL,2210,2020,Fall,a,B-
+373,BIOL,2010,2018,Spring,a,B-
+373,CS,2100,2018,Fall,a,B-
+373,CS,4970,2020,Summer,b,B-
+374,BIOL,2210,2017,Summer,c,B-
+374,CS,2100,2016,Summer,b,B-
+374,CS,3505,2018,Summer,a,B-
+374,PHYS,2210,2015,Fall,b,B-
+375,BIOL,1010,2019,Spring,a,B-
+375,CS,3200,2020,Summer,a,B-
+375,MATH,1260,2019,Fall,a,B-
+376,PHYS,2060,2020,Fall,a,B-
+377,MATH,1250,2016,Spring,a,B-
+377,PHYS,3220,2018,Summer,a,B-
+378,BIOL,1006,2020,Fall,c,B-
+378,BIOL,1010,2018,Summer,b,B-
+378,BIOL,2210,2017,Summer,b,B-
+378,CS,4970,2019,Summer,a,B-
+379,BIOL,2020,2018,Fall,d,B-
+385,CS,2420,2016,Spring,a,B-
+390,CS,4970,2020,Summer,d,B-
+391,BIOL,2210,2018,Spring,a,B-
+391,CS,3100,2017,Fall,a,B-
+391,MATH,1260,2019,Summer,a,B-
+391,MATH,3210,2020,Summer,a,B-
+394,MATH,3220,2016,Spring,d,B-
+397,CS,4000,2020,Fall,a,B-
+398,CS,3505,2020,Fall,a,B-
+398,CS,4970,2018,Summer,a,B-
+100,BIOL,1030,2020,Spring,a,C
+100,CS,1410,2018,Spring,b,C
+102,MATH,1210,2018,Spring,a,C
+102,MATH,1260,2019,Spring,c,C
+106,BIOL,2355,2020,Summer,a,C
+107,CS,3810,2016,Fall,a,C
+107,MATH,2270,2017,Fall,a,C
+109,CS,4400,2019,Spring,b,C
+109,PHYS,2220,2020,Fall,a,C
+109,PHYS,3210,2018,Fall,a,C
+112,CS,4970,2020,Summer,a,C
+115,PHYS,3220,2016,Summer,a,C
+116,CS,3200,2017,Spring,a,C
+117,CS,4500,2016,Fall,a,C
+119,BIOL,2030,2016,Summer,a,C
+119,BIOL,2355,2018,Summer,a,C
+120,CS,3100,2016,Spring,b,C
+120,CS,4000,2020,Fall,a,C
+120,MATH,1220,2019,Fall,b,C
+123,CS,3200,2016,Fall,c,C
+123,CS,4500,2019,Summer,a,C
+124,BIOL,2325,2018,Fall,a,C
+124,CS,3100,2017,Fall,a,C
+124,MATH,1210,2019,Summer,a,C
+126,CS,3505,2015,Fall,c,C
+127,CS,3505,2018,Summer,b,C
+127,CS,3810,2019,Fall,a,C
+128,CS,3810,2018,Summer,c,C
+130,PHYS,3210,2020,Fall,c,C
+131,BIOL,1006,2018,Fall,a,C
+131,BIOL,2355,2018,Summer,a,C
+131,CS,4970,2019,Fall,b,C
+131,PHYS,2140,2020,Fall,a,C
+131,PHYS,3220,2017,Fall,a,C
+133,BIOL,2325,2018,Fall,a,C
+133,CS,3200,2018,Spring,a,C
+133,CS,4500,2018,Spring,c,C
+133,PHYS,2220,2018,Fall,a,C
+134,CS,3100,2016,Spring,d,C
+135,BIOL,2030,2019,Summer,c,C
+135,MATH,2270,2020,Fall,b,C
+135,PHYS,2210,2019,Fall,a,C
+136,MATH,2210,2020,Fall,a,C
+138,BIOL,1006,2015,Summer,a,C
+139,MATH,3220,2017,Fall,b,C
+143,MATH,2270,2017,Summer,a,C
+146,CS,2100,2019,Fall,d,C
+146,MATH,3210,2020,Spring,a,C
+151,CS,3810,2018,Summer,c,C
+152,CS,3500,2019,Summer,a,C
+152,CS,4500,2020,Summer,a,C
+153,CS,2420,2020,Summer,a,C
+157,PHYS,2210,2019,Spring,b,C
+163,BIOL,1010,2015,Summer,d,C
+163,CS,2100,2017,Fall,a,C
+163,CS,3505,2016,Summer,a,C
+163,CS,4000,2017,Fall,b,C
+164,BIOL,1006,2018,Spring,a,C
+164,BIOL,2010,2020,Spring,b,C
+164,BIOL,2420,2017,Summer,a,C
+164,CS,4500,2018,Spring,c,C
+164,MATH,1260,2020,Spring,a,C
+165,BIOL,2020,2018,Fall,a,C
+165,MATH,2280,2018,Fall,b,C
+167,MATH,1250,2020,Summer,a,C
+167,MATH,2210,2020,Fall,a,C
+169,BIOL,2010,2020,Spring,a,C
+169,BIOL,2021,2018,Summer,a,C
+169,CS,4400,2019,Spring,c,C
+171,BIOL,2030,2020,Spring,a,C
+171,CS,2100,2020,Fall,a,C
+171,PHYS,2060,2019,Fall,b,C
+172,MATH,1250,2015,Fall,a,C
+172,PHYS,2140,2015,Summer,b,C
+172,PHYS,2220,2016,Summer,a,C
+172,PHYS,3210,2016,Summer,b,C
+175,BIOL,2010,2020,Summer,a,C
+175,CS,1030,2020,Spring,a,C
+177,MATH,3210,2015,Spring,b,C
+178,MATH,1210,2018,Fall,b,C
+178,MATH,2270,2020,Spring,a,C
+179,BIOL,1210,2018,Fall,b,C
+179,CS,2100,2016,Summer,b,C
+179,MATH,2270,2015,Fall,a,C
+181,BIOL,2355,2020,Fall,a,C
+181,PHYS,2060,2020,Fall,a,C
+182,BIOL,2030,2017,Spring,a,C
+182,BIOL,2325,2015,Fall,a,C
+182,CS,3500,2017,Fall,a,C
+182,MATH,2270,2017,Fall,d,C
+183,BIOL,2330,2020,Spring,a,C
+185,CS,2100,2018,Spring,a,C
+185,MATH,1210,2018,Fall,a,C
+186,CS,4970,2020,Fall,d,C
+187,MATH,1260,2019,Spring,b,C
+187,PHYS,2220,2017,Spring,a,C
+191,CS,2100,2020,Fall,a,C
+192,BIOL,1010,2016,Summer,a,C
+194,MATH,1260,2019,Summer,b,C
+195,BIOL,2330,2016,Spring,a,C
+202,CS,4970,2020,Fall,d,C
+203,CS,4000,2018,Spring,a,C
+207,CS,3100,2016,Summer,a,C
+210,BIOL,2020,2015,Summer,a,C
+210,MATH,3210,2015,Summer,a,C
+211,BIOL,1010,2015,Fall,b,C
+212,BIOL,2020,2016,Spring,a,C
+214,CS,3505,2017,Fall,a,C
+214,CS,3810,2018,Summer,b,C
+215,BIOL,2030,2015,Fall,a,C
+215,PHYS,2100,2017,Summer,c,C
+219,BIOL,2210,2020,Fall,a,C
+220,CS,4940,2019,Fall,a,C
+223,CS,3505,2019,Summer,b,C
+227,PHYS,3210,2018,Fall,a,C
+228,PHYS,2220,2020,Fall,a,C
+229,MATH,3210,2016,Spring,a,C
+230,MATH,3210,2019,Fall,a,C
+230,PHYS,2040,2017,Fall,c,C
+231,CS,3810,2018,Summer,b,C
+231,MATH,1250,2020,Summer,a,C
+237,CS,3100,2017,Fall,a,C
+237,PHYS,2040,2017,Fall,a,C
+239,BIOL,2210,2018,Summer,a,C
+239,MATH,2210,2018,Spring,b,C
+240,BIOL,2210,2020,Fall,a,C
+241,PHYS,2060,2019,Fall,b,C
+241,PHYS,2220,2019,Spring,a,C
+241,PHYS,3220,2020,Spring,b,C
+242,BIOL,2420,2020,Spring,a,C
+248,CS,4500,2019,Summer,a,C
+249,MATH,2280,2015,Summer,a,C
+250,CS,4970,2019,Fall,a,C
+251,BIOL,2010,2020,Summer,a,C
+252,CS,2100,2018,Fall,c,C
+252,PHYS,2060,2018,Fall,d,C
+255,BIOL,2020,2018,Fall,a,C
+255,CS,4940,2019,Fall,a,C
+255,PHYS,2140,2017,Summer,a,C
+256,PHYS,2220,2017,Spring,a,C
+258,BIOL,1030,2019,Spring,c,C
+259,MATH,2270,2017,Fall,b,C
+260,PHYS,3210,2016,Fall,a,C
+261,BIOL,1006,2018,Spring,b,C
+261,CS,4970,2017,Summer,a,C
+263,BIOL,1010,2020,Summer,d,C
+267,BIOL,2020,2018,Fall,b,C
+270,BIOL,2210,2017,Summer,b,C
+270,CS,3810,2018,Summer,d,C
+270,CS,4150,2018,Fall,a,C
+270,CS,4500,2018,Spring,b,C
+270,MATH,1250,2016,Summer,a,C
+274,MATH,1250,2018,Spring,a,C
+274,MATH,2210,2020,Spring,c,C
+275,BIOL,1030,2018,Fall,a,C
+275,MATH,1210,2019,Spring,b,C
+275,PHYS,2040,2019,Spring,a,C
+277,BIOL,2210,2017,Spring,c,C
+277,MATH,1210,2016,Spring,d,C
+277,PHYS,2060,2019,Summer,a,C
+281,MATH,1220,2020,Summer,a,C
+282,BIOL,1010,2016,Summer,a,C
+282,BIOL,2330,2016,Spring,a,C
+282,PHYS,2140,2015,Spring,a,C
+285,CS,1030,2019,Fall,a,C
+285,CS,4970,2016,Fall,a,C
+285,MATH,2210,2019,Spring,b,C
+288,CS,2420,2017,Summer,c,C
+289,MATH,3210,2020,Fall,a,C
+290,BIOL,2355,2017,Spring,a,C
+291,CS,4000,2017,Fall,a,C
+292,BIOL,2020,2018,Spring,a,C
+292,PHYS,2210,2019,Spring,c,C
+293,CS,3505,2020,Fall,c,C
+293,MATH,1260,2019,Spring,c,C
+295,CS,2420,2016,Fall,b,C
+295,MATH,1210,2016,Spring,d,C
+296,BIOL,2325,2017,Fall,b,C
+298,BIOL,1010,2018,Fall,b,C
+298,BIOL,1030,2019,Spring,c,C
+300,BIOL,2021,2019,Fall,a,C
+301,BIOL,1010,2015,Summer,c,C
+303,BIOL,2021,2019,Fall,a,C
+303,CS,4970,2019,Summer,d,C
+307,BIOL,2355,2020,Summer,b,C
+307,CS,1030,2020,Spring,a,C
+307,CS,3505,2019,Summer,a,C
+307,CS,4970,2020,Summer,d,C
+307,MATH,1210,2019,Spring,a,C
+307,PHYS,3220,2017,Fall,c,C
+309,BIOL,2030,2019,Summer,b,C
+309,BIOL,2355,2020,Spring,a,C
+309,CS,4150,2020,Fall,a,C
+309,MATH,2280,2018,Fall,c,C
+311,BIOL,2021,2018,Spring,a,C
+311,BIOL,2355,2018,Summer,b,C
+311,CS,3200,2020,Summer,a,C
+311,CS,4940,2017,Fall,a,C
+312,CS,3505,2017,Summer,a,C
+312,PHYS,2210,2019,Fall,b,C
+313,BIOL,1006,2020,Fall,a,C
+313,BIOL,1006,2020,Fall,c,C
+313,CS,3500,2015,Fall,b,C
+313,PHYS,3210,2019,Summer,b,C
+318,CS,2100,2019,Fall,c,C
+318,CS,3505,2019,Summer,d,C
+323,BIOL,2420,2020,Summer,a,C
+323,CS,4970,2020,Fall,d,C
+325,BIOL,2325,2019,Summer,a,C
+329,CS,3505,2016,Fall,b,C
+329,CS,4000,2017,Fall,b,C
+331,MATH,2270,2020,Fall,a,C
+332,CS,3200,2020,Spring,c,C
+333,BIOL,1006,2020,Fall,a,C
+333,BIOL,2010,2020,Summer,a,C
+333,MATH,1210,2019,Spring,a,C
+335,CS,2100,2016,Summer,b,C
+335,CS,3505,2015,Fall,b,C
+340,BIOL,1030,2020,Summer,a,C
+340,CS,3505,2019,Summer,b,C
+340,CS,3810,2020,Fall,a,C
+341,PHYS,2060,2019,Fall,b,C
+345,CS,3505,2018,Fall,a,C
+345,PHYS,2140,2020,Fall,a,C
+348,CS,3810,2016,Fall,a,C
+356,BIOL,2021,2018,Summer,a,C
+356,CS,2420,2019,Summer,a,C
+357,CS,3200,2016,Summer,a,C
+361,BIOL,2021,2018,Spring,a,C
+362,MATH,1220,2018,Spring,b,C
+363,BIOL,2355,2020,Summer,b,C
+364,CS,4970,2019,Spring,b,C
+365,CS,3500,2020,Summer,a,C
+366,BIOL,1010,2018,Summer,b,C
+369,BIOL,2330,2016,Fall,a,C
+371,BIOL,2030,2018,Summer,b,C
+371,CS,4150,2018,Fall,b,C
+372,BIOL,1030,2018,Summer,a,C
+372,BIOL,2030,2017,Spring,b,C
+372,MATH,3210,2017,Summer,a,C
+372,PHYS,2040,2019,Spring,a,C
+373,BIOL,2021,2018,Spring,a,C
+373,CS,4000,2017,Summer,a,C
+373,CS,4500,2020,Spring,a,C
+373,MATH,2270,2020,Fall,a,C
+373,PHYS,2210,2017,Summer,a,C
+374,BIOL,1010,2018,Summer,c,C
+374,CS,3500,2016,Spring,a,C
+374,PHYS,2060,2016,Summer,b,C
+374,PHYS,2220,2015,Spring,a,C
+375,BIOL,1006,2018,Spring,b,C
+375,CS,3500,2019,Fall,b,C
+377,CS,2100,2017,Spring,a,C
+378,BIOL,2010,2020,Summer,a,C
+378,CS,3505,2016,Summer,a,C
+378,CS,4150,2016,Summer,a,C
+378,MATH,1210,2016,Fall,b,C
+378,MATH,2270,2019,Summer,b,C
+379,CS,3505,2016,Fall,a,C
+379,PHYS,2140,2017,Fall,b,C
+379,PHYS,2210,2015,Fall,c,C
+381,CS,2100,2018,Summer,c,C
+382,BIOL,1010,2015,Summer,b,C
+385,CS,3100,2017,Spring,b,C
+385,MATH,1250,2018,Spring,a,C
+386,PHYS,2140,2018,Fall,a,C
+387,MATH,2210,2017,Summer,a,C
+387,PHYS,2040,2015,Fall,c,C
+387,PHYS,2140,2016,Fall,a,C
+388,MATH,1220,2017,Spring,b,C
+389,CS,2420,2016,Spring,a,C
+390,PHYS,3210,2020,Spring,a,C
+391,BIOL,1010,2017,Spring,a,C
+391,BIOL,1030,2018,Fall,a,C
+391,CS,1410,2017,Spring,a,C
+391,CS,4400,2019,Summer,b,C
+391,MATH,3220,2017,Spring,a,C
+392,BIOL,2210,2016,Summer,a,C
+392,CS,3505,2015,Fall,b,C
+392,PHYS,2210,2015,Fall,b,C
+393,CS,4000,2016,Fall,a,C
+393,PHYS,2220,2018,Summer,a,C
+394,BIOL,2325,2016,Summer,a,C
+394,CS,4970,2016,Fall,a,C
+396,PHYS,2210,2019,Fall,b,C
+397,BIOL,2325,2018,Summer,a,C
+397,CS,3505,2017,Fall,a,C
+397,MATH,1210,2016,Fall,a,C
+398,PHYS,3220,2018,Summer,a,C
+399,BIOL,2325,2018,Fall,c,C
+399,MATH,2270,2019,Summer,c,C
+100,BIOL,1010,2020,Summer,d,C+
+100,MATH,2280,2019,Fall,a,C+
+101,BIOL,2020,2018,Fall,d,C+
+102,BIOL,2030,2020,Spring,b,C+
+102,MATH,2210,2019,Spring,a,C+
+102,MATH,2280,2019,Fall,a,C+
+102,PHYS,3220,2020,Spring,a,C+
+105,BIOL,2325,2018,Spring,a,C+
+105,CS,2420,2016,Fall,b,C+
+105,PHYS,2040,2018,Spring,a,C+
+107,BIOL,2420,2017,Summer,b,C+
+107,CS,2100,2019,Spring,a,C+
+107,MATH,2270,2017,Fall,d,C+
+108,BIOL,2010,2020,Spring,b,C+
+108,MATH,1210,2020,Spring,b,C+
+108,PHYS,2210,2019,Fall,b,C+
+109,CS,4000,2020,Fall,a,C+
+109,PHYS,3220,2017,Fall,b,C+
+113,BIOL,2355,2018,Summer,c,C+
+113,PHYS,3210,2019,Spring,a,C+
+117,MATH,1220,2017,Spring,b,C+
+118,CS,3505,2019,Fall,b,C+
+118,PHYS,2220,2020,Summer,a,C+
+119,CS,4970,2019,Summer,d,C+
+119,PHYS,2220,2017,Spring,d,C+
+119,PHYS,3220,2017,Summer,a,C+
+120,BIOL,2030,2017,Spring,d,C+
+120,BIOL,2355,2020,Fall,a,C+
+120,CS,2420,2017,Fall,a,C+
+122,MATH,2210,2020,Fall,a,C+
+123,BIOL,1006,2016,Spring,b,C+
+123,CS,3500,2016,Spring,a,C+
+123,CS,3810,2016,Summer,a,C+
+123,PHYS,2220,2018,Fall,a,C+
+123,PHYS,3210,2016,Fall,a,C+
+124,PHYS,2220,2020,Spring,a,C+
+124,PHYS,3220,2020,Spring,d,C+
+127,BIOL,2010,2017,Summer,a,C+
+127,CS,3500,2020,Summer,a,C+
+128,MATH,1210,2018,Fall,b,C+
+131,CS,4500,2019,Fall,c,C+
+133,BIOL,1010,2018,Summer,b,C+
+133,MATH,1260,2019,Spring,a,C+
+134,BIOL,1210,2018,Spring,a,C+
+134,CS,3200,2015,Fall,b,C+
+134,PHYS,2140,2016,Spring,c,C+
+135,BIOL,1030,2020,Spring,a,C+
+135,CS,1030,2020,Spring,c,C+
+138,CS,1030,2016,Spring,a,C+
+138,CS,3100,2016,Spring,d,C+
+138,PHYS,2140,2015,Summer,c,C+
+139,CS,3100,2017,Fall,a,C+
+139,MATH,1250,2018,Summer,c,C+
+140,CS,2420,2015,Summer,c,C+
+140,PHYS,2140,2015,Summer,a,C+
+148,BIOL,1010,2020,Summer,a,C+
+149,CS,4400,2016,Spring,a,C+
+151,BIOL,1030,2017,Spring,c,C+
+151,BIOL,2030,2016,Fall,a,C+
+153,BIOL,1030,2020,Spring,a,C+
+155,BIOL,2330,2017,Fall,a,C+
+158,PHYS,2060,2018,Fall,b,C+
+163,CS,2420,2016,Fall,a,C+
+163,CS,3100,2015,Summer,a,C+
+164,BIOL,1030,2020,Summer,a,C+
+164,BIOL,2021,2019,Fall,a,C+
+164,CS,1410,2018,Spring,b,C+
+165,BIOL,1006,2017,Fall,b,C+
+165,BIOL,1010,2019,Spring,b,C+
+165,MATH,1220,2018,Spring,a,C+
+167,BIOL,1030,2019,Summer,a,C+
+167,MATH,1210,2018,Fall,a,C+
+169,BIOL,2420,2018,Spring,a,C+
+170,CS,1030,2020,Spring,b,C+
+171,MATH,3210,2020,Summer,a,C+
+173,BIOL,2030,2019,Summer,b,C+
+173,CS,4400,2019,Summer,a,C+
+175,BIOL,2355,2020,Fall,a,C+
+175,MATH,2210,2020,Fall,a,C+
+176,BIOL,2020,2015,Fall,c,C+
+176,PHYS,2100,2016,Fall,b,C+
+177,BIOL,1210,2018,Spring,a,C+
+177,BIOL,2010,2020,Summer,b,C+
+177,MATH,2270,2020,Fall,a,C+
+177,PHYS,2210,2017,Summer,a,C+
+178,BIOL,2355,2019,Spring,a,C+
+178,CS,3200,2020,Fall,a,C+
+178,PHYS,2060,2020,Fall,a,C+
+179,CS,3200,2015,Fall,b,C+
+179,MATH,2210,2020,Fall,a,C+
+182,BIOL,2210,2017,Spring,b,C+
+182,CS,3505,2015,Fall,b,C+
+182,CS,4500,2018,Spring,a,C+
+182,MATH,2280,2018,Spring,a,C+
+183,BIOL,1030,2018,Fall,a,C+
+183,BIOL,2020,2018,Fall,a,C+
+185,BIOL,1030,2020,Summer,a,C+
+185,CS,3505,2018,Summer,b,C+
+185,CS,4500,2019,Summer,a,C+
+187,MATH,1220,2017,Spring,a,C+
+187,PHYS,2060,2020,Fall,a,C+
+187,PHYS,3220,2017,Fall,d,C+
+194,CS,3505,2019,Fall,c,C+
+194,CS,4940,2020,Summer,b,C+
+195,MATH,1210,2016,Fall,c,C+
+196,CS,2100,2018,Fall,c,C+
+197,MATH,2210,2018,Spring,b,C+
+199,CS,2420,2019,Summer,a,C+
+200,PHYS,3210,2020,Fall,b,C+
+203,CS,3500,2017,Fall,c,C+
+204,BIOL,2330,2015,Fall,d,C+
+210,CS,1030,2019,Fall,b,C+
+210,PHYS,2060,2019,Fall,a,C+
+211,CS,3200,2015,Spring,b,C+
+213,BIOL,2030,2016,Fall,a,C+
+214,BIOL,1006,2016,Summer,d,C+
+214,BIOL,2325,2018,Spring,a,C+
+214,CS,2100,2016,Spring,a,C+
+215,CS,2100,2017,Fall,a,C+
+215,CS,2420,2016,Fall,b,C+
+219,CS,3505,2020,Summer,a,C+
+221,CS,1030,2020,Spring,a,C+
+223,BIOL,2010,2020,Spring,a,C+
+225,CS,2420,2020,Fall,a,C+
+225,CS,3810,2020,Fall,a,C+
+227,MATH,2280,2018,Fall,b,C+
+227,PHYS,2140,2019,Fall,a,C+
+227,PHYS,3220,2020,Spring,d,C+
+228,CS,2100,2020,Spring,a,C+
+229,MATH,1260,2016,Fall,a,C+
+229,MATH,2210,2018,Spring,b,C+
+231,BIOL,2010,2020,Spring,a,C+
+231,MATH,1260,2020,Spring,a,C+
+234,MATH,1220,2019,Fall,a,C+
+235,CS,4400,2020,Fall,b,C+
+238,MATH,3220,2018,Spring,d,C+
+241,CS,4400,2019,Fall,a,C+
+241,PHYS,3220,2020,Spring,c,C+
+242,BIOL,2010,2020,Summer,a,C+
+243,CS,2420,2016,Fall,c,C+
+245,CS,4150,2016,Summer,a,C+
+245,MATH,1220,2015,Summer,c,C+
+246,PHYS,2100,2017,Fall,a,C+
+246,PHYS,2210,2015,Fall,a,C+
+247,BIOL,2325,2018,Fall,a,C+
+247,MATH,2280,2019,Fall,b,C+
+248,BIOL,2355,2019,Spring,c,C+
+248,CS,3200,2020,Spring,c,C+
+249,CS,3505,2016,Fall,b,C+
+249,CS,4970,2016,Fall,b,C+
+249,PHYS,2220,2017,Spring,d,C+
+250,CS,3505,2020,Fall,c,C+
+253,CS,2100,2018,Fall,d,C+
+254,CS,4500,2019,Fall,d,C+
+255,BIOL,2010,2018,Spring,a,C+
+255,CS,3500,2019,Fall,a,C+
+255,MATH,1250,2018,Summer,a,C+
+255,PHYS,2210,2019,Spring,d,C+
+256,CS,4500,2019,Fall,c,C+
+256,PHYS,2040,2017,Fall,b,C+
+257,BIOL,2020,2018,Fall,a,C+
+257,BIOL,2021,2018,Summer,a,C+
+257,CS,4000,2020,Spring,a,C+
+257,MATH,1260,2019,Summer,a,C+
+257,PHYS,2060,2018,Fall,b,C+
+258,BIOL,1030,2019,Spring,b,C+
+258,CS,3500,2019,Summer,a,C+
+258,PHYS,3210,2019,Spring,c,C+
+260,BIOL,2325,2017,Fall,b,C+
+261,BIOL,2020,2018,Fall,a,C+
+262,BIOL,2020,2018,Fall,b,C+
+266,BIOL,2330,2017,Fall,b,C+
+270,BIOL,2355,2017,Spring,b,C+
+274,BIOL,2020,2018,Fall,a,C+
+275,CS,4970,2019,Spring,a,C+
+276,BIOL,1006,2016,Spring,a,C+
+276,CS,3100,2015,Summer,a,C+
+276,CS,3505,2019,Spring,a,C+
+277,BIOL,1010,2015,Summer,a,C+
+277,MATH,1210,2016,Spring,c,C+
+281,CS,4970,2020,Fall,c,C+
+282,CS,3505,2015,Spring,a,C+
+282,CS,4000,2015,Fall,a,C+
+285,MATH,1220,2017,Spring,b,C+
+285,MATH,3220,2016,Spring,a,C+
+285,PHYS,2210,2017,Summer,b,C+
+287,CS,4400,2019,Summer,a,C+
+289,BIOL,2210,2019,Fall,b,C+
+291,CS,1030,2016,Spring,a,C+
+291,CS,1410,2016,Spring,b,C+
+292,BIOL,1030,2020,Spring,a,C+
+292,MATH,2270,2017,Fall,a,C+
+292,MATH,3210,2017,Summer,a,C+
+295,CS,4970,2017,Spring,a,C+
+297,PHYS,2140,2020,Fall,a,C+
+298,CS,2100,2018,Summer,c,C+
+300,CS,4970,2019,Summer,a,C+
+304,MATH,3210,2017,Spring,a,C+
+307,BIOL,1030,2019,Spring,c,C+
+307,CS,1410,2018,Spring,d,C+
+309,BIOL,2210,2017,Spring,b,C+
+309,CS,2420,2017,Summer,b,C+
+309,CS,3500,2017,Fall,c,C+
+309,CS,4500,2016,Fall,a,C+
+309,MATH,1220,2018,Spring,b,C+
+309,MATH,3210,2017,Summer,a,C+
+311,BIOL,1010,2018,Summer,b,C+
+311,CS,4970,2019,Spring,b,C+
+312,PHYS,2100,2016,Fall,a,C+
+313,BIOL,1010,2018,Summer,c,C+
+313,BIOL,2010,2019,Fall,a,C+
+313,BIOL,2020,2016,Spring,a,C+
+313,MATH,1260,2019,Spring,b,C+
+314,BIOL,1030,2019,Summer,a,C+
+314,BIOL,2210,2019,Summer,a,C+
+314,CS,4970,2017,Spring,a,C+
+314,MATH,2270,2017,Fall,d,C+
+316,BIOL,2010,2019,Fall,a,C+
+318,BIOL,1010,2018,Summer,a,C+
+318,BIOL,2030,2019,Summer,a,C+
+318,BIOL,2210,2019,Summer,b,C+
+321,BIOL,2420,2020,Fall,a,C+
+321,CS,2100,2019,Fall,b,C+
+329,PHYS,3210,2019,Spring,c,C+
+331,MATH,2270,2020,Fall,b,C+
+332,BIOL,2355,2018,Summer,a,C+
+332,CS,4400,2019,Summer,b,C+
+332,MATH,1220,2018,Spring,a,C+
+333,BIOL,2325,2019,Spring,a,C+
+333,CS,4970,2019,Summer,c,C+
+335,CS,4970,2016,Fall,a,C+
+340,BIOL,1010,2020,Summer,a,C+
+342,BIOL,1210,2019,Spring,a,C+
+342,BIOL,2420,2020,Fall,a,C+
+348,BIOL,2330,2020,Spring,a,C+
+348,CS,4500,2017,Summer,a,C+
+348,MATH,2270,2020,Fall,a,C+
+348,PHYS,2040,2017,Fall,c,C+
+355,MATH,1220,2017,Spring,c,C+
+356,MATH,2270,2017,Fall,b,C+
+356,PHYS,2220,2016,Fall,a,C+
+366,BIOL,2030,2020,Spring,a,C+
+368,MATH,3210,2020,Summer,a,C+
+368,PHYS,2060,2019,Fall,b,C+
+369,PHYS,2140,2018,Summer,b,C+
+371,BIOL,1010,2020,Summer,c,C+
+371,PHYS,2140,2019,Fall,a,C+
+372,BIOL,2010,2017,Fall,a,C+
+372,PHYS,2220,2017,Spring,a,C+
+373,BIOL,2210,2020,Fall,a,C+
+374,CS,3810,2018,Summer,a,C+
+375,PHYS,3210,2019,Spring,c,C+
+377,BIOL,2020,2015,Fall,c,C+
+377,PHYS,2100,2017,Summer,b,C+
+378,CS,3200,2020,Spring,c,C+
+378,CS,4000,2016,Fall,a,C+
+378,MATH,1220,2017,Spring,d,C+
+379,BIOL,1006,2020,Fall,a,C+
+379,BIOL,2030,2015,Fall,a,C+
+380,BIOL,2355,2018,Fall,a,C+
+381,PHYS,3210,2018,Spring,c,C+
+382,BIOL,1010,2015,Summer,a,C+
+386,CS,4150,2020,Fall,a,C+
+387,CS,2100,2018,Spring,a,C+
+387,MATH,3220,2018,Spring,b,C+
+388,CS,2100,2016,Summer,b,C+
+389,BIOL,1006,2016,Summer,d,C+
+390,MATH,2280,2019,Fall,b,C+
+391,BIOL,1006,2018,Fall,a,C+
+391,CS,3505,2019,Fall,c,C+
+391,MATH,2210,2018,Spring,a,C+
+391,PHYS,2060,2020,Spring,b,C+
+392,BIOL,1030,2016,Spring,a,C+
+392,BIOL,2330,2017,Fall,b,C+
+392,CS,2100,2018,Summer,c,C+
+394,CS,1410,2016,Summer,a,C+
+395,BIOL,2355,2016,Spring,b,C+
+396,CS,4150,2020,Spring,a,C+
+397,BIOL,2420,2020,Spring,a,C+
+397,CS,2100,2019,Fall,b,C+
+397,CS,2100,2019,Fall,c,C+
+398,MATH,2270,2020,Fall,a,C+
+398,MATH,2280,2020,Spring,b,C+
+399,BIOL,2020,2018,Fall,a,C+
+399,BIOL,2021,2019,Spring,a,C+
+399,CS,2100,2018,Fall,d,C+
+399,MATH,3210,2019,Spring,a,C+
+100,CS,3505,2018,Summer,a,C-
+101,BIOL,2030,2018,Summer,b,C-
+102,MATH,1220,2019,Fall,b,C-
+105,BIOL,1010,2018,Summer,a,C-
+106,CS,2100,2019,Summer,b,C-
+107,BIOL,2210,2017,Spring,c,C-
+107,CS,4000,2017,Fall,a,C-
+108,CS,4500,2020,Spring,a,C-
+109,CS,1410,2018,Spring,b,C-
+109,CS,3505,2020,Fall,c,C-
+112,BIOL,1010,2020,Summer,c,C-
+113,CS,4400,2020,Spring,a,C-
+115,BIOL,2420,2017,Summer,a,C-
+118,BIOL,2355,2020,Spring,a,C-
+118,CS,2100,2019,Fall,c,C-
+118,MATH,1220,2020,Summer,a,C-
+119,MATH,2280,2018,Fall,b,C-
+120,BIOL,2020,2015,Summer,a,C-
+120,CS,4150,2020,Spring,a,C-
+120,PHYS,2040,2020,Spring,a,C-
+121,BIOL,1010,2020,Summer,d,C-
+121,BIOL,2420,2020,Spring,b,C-
+121,CS,4970,2018,Fall,d,C-
+121,PHYS,3210,2020,Fall,b,C-
+122,CS,1030,2020,Spring,a,C-
+123,BIOL,2010,2017,Summer,a,C-
+123,CS,4000,2020,Spring,b,C-
+123,MATH,1220,2019,Fall,b,C-
+124,CS,1030,2020,Spring,c,C-
+124,CS,3200,2020,Fall,a,C-
+125,PHYS,3210,2020,Spring,a,C-
+127,BIOL,1006,2019,Spring,a,C-
+127,PHYS,2060,2018,Fall,b,C-
+131,BIOL,2420,2020,Summer,a,C-
+133,CS,3500,2019,Fall,b,C-
+133,MATH,1220,2019,Fall,a,C-
+133,MATH,2280,2019,Fall,b,C-
+135,BIOL,2325,2019,Summer,a,C-
+136,CS,4970,2020,Fall,b,C-
+137,CS,3505,2020,Summer,a,C-
+138,CS,4000,2016,Fall,a,C-
+138,CS,4400,2016,Fall,a,C-
+138,MATH,1210,2015,Summer,a,C-
+139,BIOL,2021,2019,Spring,b,C-
+139,CS,4500,2017,Summer,a,C-
+139,PHYS,3210,2017,Summer,a,C-
+143,BIOL,1006,2019,Summer,a,C-
+143,CS,1030,2019,Fall,a,C-
+143,PHYS,3220,2018,Summer,a,C-
+145,CS,4970,2016,Fall,a,C-
+146,BIOL,2420,2020,Fall,a,C-
+151,CS,3505,2017,Summer,a,C-
+151,PHYS,2060,2019,Fall,c,C-
+151,PHYS,2100,2017,Summer,c,C-
+151,PHYS,2210,2018,Fall,b,C-
+151,PHYS,2220,2017,Spring,c,C-
+152,BIOL,2020,2018,Fall,a,C-
+152,BIOL,2021,2018,Fall,a,C-
+152,MATH,1220,2019,Fall,b,C-
+152,PHYS,3210,2019,Summer,c,C-
+160,BIOL,1006,2016,Spring,a,C-
+161,CS,3200,2020,Fall,a,C-
+163,CS,4000,2017,Fall,a,C-
+164,BIOL,2325,2018,Fall,a,C-
+164,CS,4000,2018,Spring,a,C-
+164,CS,4970,2019,Summer,d,C-
+165,PHYS,2060,2018,Fall,c,C-
+167,BIOL,2325,2018,Fall,b,C-
+167,PHYS,3210,2019,Summer,c,C-
+171,CS,3505,2020,Fall,c,C-
+172,MATH,2210,2015,Fall,a,C-
+172,MATH,3210,2015,Fall,d,C-
+173,CS,4000,2018,Spring,a,C-
+173,MATH,1220,2018,Spring,a,C-
+176,CS,3200,2016,Summer,b,C-
+176,MATH,3220,2017,Spring,a,C-
+177,BIOL,2020,2018,Fall,a,C-
+177,CS,3200,2020,Summer,a,C-
+177,CS,3505,2017,Fall,b,C-
+177,CS,4970,2016,Fall,b,C-
+177,PHYS,2140,2017,Summer,a,C-
+179,BIOL,2325,2019,Summer,a,C-
+179,MATH,1260,2019,Spring,b,C-
+179,PHYS,2220,2015,Fall,a,C-
+181,MATH,2270,2020,Fall,a,C-
+182,CS,3810,2018,Summer,d,C-
+182,MATH,1220,2017,Spring,b,C-
+185,CS,3200,2020,Summer,a,C-
+185,PHYS,3210,2020,Spring,a,C-
+187,CS,4400,2019,Spring,c,C-
+188,PHYS,3210,2019,Summer,c,C-
+189,BIOL,2420,2020,Summer,a,C-
+192,BIOL,2355,2015,Summer,a,C-
+194,CS,3200,2020,Spring,a,C-
+195,MATH,1210,2016,Fall,d,C-
+195,MATH,3210,2016,Fall,a,C-
+195,PHYS,2060,2016,Spring,a,C-
+196,BIOL,2021,2019,Spring,a,C-
+199,BIOL,1030,2018,Fall,a,C-
+200,CS,4940,2020,Summer,a,C-
+202,CS,1030,2020,Fall,a,C-
+203,MATH,3220,2018,Spring,d,C-
+204,BIOL,1030,2015,Summer,a,C-
+207,BIOL,2021,2017,Fall,a,C-
+210,CS,3100,2017,Spring,b,C-
+211,MATH,1220,2015,Summer,a,C-
+214,BIOL,2210,2017,Summer,a,C-
+217,PHYS,3210,2019,Spring,b,C-
+220,BIOL,2325,2018,Fall,a,C-
+221,BIOL,2010,2020,Summer,a,C-
+222,PHYS,2140,2020,Fall,a,C-
+223,CS,2100,2019,Fall,a,C-
+223,CS,3500,2019,Fall,b,C-
+227,BIOL,1006,2018,Spring,b,C-
+227,CS,4970,2018,Summer,b,C-
+228,CS,3200,2020,Spring,a,C-
+228,PHYS,3210,2020,Fall,b,C-
+230,BIOL,2030,2018,Summer,a,C-
+230,CS,2420,2020,Fall,a,C-
+231,CS,4940,2020,Summer,b,C-
+231,CS,4970,2018,Summer,a,C-
+231,PHYS,3210,2019,Spring,c,C-
+233,BIOL,2355,2020,Fall,a,C-
+233,CS,2420,2020,Summer,a,C-
+235,MATH,1260,2020,Spring,a,C-
+235,PHYS,2220,2020,Spring,a,C-
+237,PHYS,3220,2017,Fall,a,C-
+242,MATH,1260,2020,Spring,a,C-
+242,PHYS,2220,2020,Summer,b,C-
+243,BIOL,1030,2017,Spring,b,C-
+247,CS,4940,2020,Summer,b,C-
+248,BIOL,2420,2020,Spring,b,C-
+248,CS,4400,2019,Spring,c,C-
+249,MATH,1210,2016,Fall,a,C-
+251,CS,4940,2020,Summer,a,C-
+251,PHYS,3220,2020,Spring,b,C-
+253,BIOL,1030,2018,Fall,a,C-
+256,BIOL,1030,2019,Spring,c,C-
+256,MATH,2280,2018,Spring,a,C-
+257,CS,4970,2020,Summer,c,C-
+257,PHYS,2220,2018,Summer,a,C-
+259,BIOL,2010,2017,Summer,a,C-
+259,CS,4000,2017,Summer,a,C-
+259,MATH,2280,2018,Fall,a,C-
+260,CS,3810,2018,Summer,a,C-
+260,MATH,2270,2020,Fall,b,C-
+261,CS,2420,2017,Summer,c,C-
+261,CS,3100,2017,Spring,a,C-
+261,MATH,2210,2017,Spring,a,C-
+262,CS,2100,2016,Summer,b,C-
+266,MATH,3220,2017,Fall,a,C-
+267,MATH,2280,2019,Fall,b,C-
+268,CS,3200,2016,Fall,a,C-
+270,CS,1410,2015,Summer,d,C-
+270,MATH,2210,2017,Spring,a,C-
+270,MATH,2280,2019,Fall,a,C-
+270,MATH,2280,2019,Fall,c,C-
+270,MATH,3220,2016,Summer,a,C-
+270,PHYS,2210,2018,Fall,b,C-
+271,BIOL,2355,2020,Fall,a,C-
+271,PHYS,3220,2020,Spring,c,C-
+275,BIOL,1010,2018,Summer,b,C-
+275,BIOL,2355,2018,Summer,c,C-
+275,CS,1410,2018,Spring,d,C-
+275,CS,4000,2018,Spring,a,C-
+276,CS,3810,2015,Spring,a,C-
+276,MATH,1260,2019,Summer,a,C-
+276,MATH,3210,2016,Spring,a,C-
+276,PHYS,3210,2018,Fall,a,C-
+277,BIOL,1010,2015,Summer,c,C-
+277,CS,3100,2016,Fall,a,C-
+278,BIOL,2210,2016,Summer,a,C-
+278,MATH,1260,2016,Fall,a,C-
+281,MATH,1250,2020,Summer,a,C-
+285,CS,4970,2016,Fall,b,C-
+285,MATH,1210,2016,Fall,c,C-
+285,MATH,2270,2019,Spring,a,C-
+285,MATH,2280,2020,Spring,b,C-
+285,PHYS,2100,2018,Fall,a,C-
+285,PHYS,3220,2016,Summer,a,C-
+288,MATH,1210,2018,Summer,a,C-
+290,CS,3505,2016,Summer,a,C-
+290,CS,4400,2015,Summer,a,C-
+291,MATH,2270,2017,Fall,d,C-
+292,BIOL,1006,2018,Spring,b,C-
+294,CS,3500,2017,Fall,c,C-
+294,CS,3505,2017,Fall,b,C-
+294,CS,4940,2017,Fall,a,C-
+295,CS,2100,2016,Spring,a,C-
+296,CS,3100,2017,Fall,a,C-
+296,MATH,3220,2018,Spring,a,C-
+297,PHYS,3210,2020,Fall,a,C-
+300,PHYS,2220,2020,Summer,b,C-
+305,CS,1030,2018,Fall,a,C-
+307,BIOL,2330,2019,Fall,a,C-
+307,PHYS,2040,2015,Fall,c,C-
+309,MATH,1260,2019,Fall,a,C-
+309,PHYS,2140,2020,Fall,a,C-
+311,PHYS,2060,2018,Fall,c,C-
+311,PHYS,2060,2018,Fall,d,C-
+312,BIOL,2325,2015,Fall,c,C-
+313,BIOL,2030,2017,Spring,a,C-
+313,MATH,1210,2019,Summer,a,C-
+313,MATH,2270,2015,Fall,b,C-
+313,MATH,3210,2015,Fall,a,C-
+314,CS,4940,2019,Fall,a,C-
+314,PHYS,2040,2017,Fall,a,C-
+314,PHYS,2100,2016,Fall,a,C-
+317,CS,4500,2016,Spring,a,C-
+318,MATH,2270,2017,Summer,a,C-
+321,PHYS,2060,2020,Spring,a,C-
+321,PHYS,2060,2020,Spring,b,C-
+325,BIOL,2020,2018,Fall,d,C-
+325,BIOL,2355,2020,Summer,b,C-
+329,CS,1030,2019,Fall,b,C-
+329,CS,4500,2018,Spring,a,C-
+329,PHYS,2210,2018,Fall,a,C-
+332,CS,1030,2020,Spring,b,C-
+332,CS,3500,2019,Fall,a,C-
+335,CS,3810,2016,Fall,b,C-
+340,PHYS,2210,2019,Fall,a,C-
+341,CS,4500,2019,Fall,d,C-
+342,BIOL,2325,2019,Spring,a,C-
+342,BIOL,2330,2017,Fall,a,C-
+342,PHYS,2220,2018,Summer,a,C-
+344,BIOL,2020,2018,Fall,b,C-
+344,BIOL,2021,2018,Summer,a,C-
+347,BIOL,2030,2020,Spring,b,C-
+347,CS,4970,2019,Fall,d,C-
+348,BIOL,1010,2020,Summer,c,C-
+348,BIOL,2010,2018,Spring,a,C-
+348,CS,1030,2016,Spring,a,C-
+348,CS,3100,2019,Spring,b,C-
+351,PHYS,3210,2019,Spring,a,C-
+355,CS,1410,2016,Spring,a,C-
+355,MATH,1250,2017,Summer,a,C-
+356,CS,2100,2017,Fall,a,C-
+362,MATH,3220,2018,Spring,a,C-
+364,BIOL,2021,2019,Fall,a,C-
+364,CS,4400,2019,Fall,b,C-
+365,MATH,3210,2020,Fall,a,C-
+366,PHYS,2210,2019,Fall,b,C-
+368,CS,2420,2020,Summer,a,C-
+368,CS,4400,2019,Summer,b,C-
+368,MATH,1250,2018,Summer,b,C-
+369,BIOL,2355,2017,Spring,c,C-
+371,CS,4500,2019,Summer,a,C-
+371,MATH,1210,2018,Summer,a,C-
+371,PHYS,2210,2019,Fall,c,C-
+372,BIOL,1010,2019,Spring,a,C-
+373,BIOL,2030,2018,Summer,a,C-
+373,CS,3500,2020,Summer,a,C-
+373,MATH,1210,2020,Spring,a,C-
+373,MATH,2210,2015,Fall,a,C-
+374,BIOL,2420,2015,Summer,a,C-
+374,MATH,1250,2016,Fall,c,C-
+375,BIOL,1030,2019,Spring,c,C-
+375,BIOL,2010,2020,Summer,a,C-
+375,BIOL,2030,2020,Spring,b,C-
+375,CS,1410,2020,Spring,b,C-
+375,PHYS,2100,2017,Summer,a,C-
+376,BIOL,1006,2020,Fall,a,C-
+377,BIOL,2325,2019,Spring,a,C-
+377,BIOL,2355,2015,Summer,a,C-
+377,MATH,1220,2015,Summer,c,C-
+377,MATH,3220,2017,Fall,a,C-
+378,CS,4500,2017,Summer,a,C-
+378,CS,4970,2019,Summer,b,C-
+378,PHYS,2100,2017,Summer,b,C-
+379,CS,3500,2016,Summer,a,C-
+379,CS,3810,2018,Summer,d,C-
+384,BIOL,2010,2020,Spring,a,C-
+385,BIOL,2010,2018,Spring,a,C-
+385,CS,1030,2016,Summer,a,C-
+385,CS,3505,2017,Fall,b,C-
+385,PHYS,2220,2016,Fall,a,C-
+388,BIOL,2021,2017,Summer,a,C-
+388,CS,3200,2018,Spring,c,C-
+390,BIOL,1010,2020,Summer,c,C-
+391,BIOL,2325,2019,Spring,a,C-
+391,CS,4150,2018,Fall,a,C-
+392,BIOL,2355,2016,Spring,b,C-
+393,BIOL,2210,2017,Spring,c,C-
+394,MATH,1210,2017,Spring,a,C-
+396,CS,3505,2018,Fall,c,C-
+397,BIOL,1030,2019,Spring,c,C-
+397,CS,4970,2016,Fall,b,C-
+397,MATH,2210,2020,Fall,a,C-
+398,BIOL,1010,2020,Summer,c,C-
+398,BIOL,2355,2018,Summer,b,C-
+398,CS,4400,2019,Summer,a,C-
+399,CS,4970,2019,Summer,d,C-
+100,CS,4940,2020,Summer,a,D
+101,MATH,1250,2018,Summer,b,D
+106,CS,4150,2020,Spring,a,D
+107,CS,3200,2016,Fall,d,D
+107,MATH,2280,2020,Spring,a,D
+109,BIOL,1010,2019,Spring,b,D
+109,CS,4500,2019,Fall,d,D
+113,BIOL,2020,2018,Fall,b,D
+113,PHYS,2140,2018,Summer,a,D
+116,MATH,1220,2017,Spring,a,D
+117,BIOL,2020,2016,Spring,a,D
+117,CS,4940,2017,Fall,a,D
+117,PHYS,2140,2016,Spring,b,D
+118,CS,4000,2020,Fall,a,D
+119,CS,4500,2016,Spring,b,D
+119,MATH,1250,2018,Summer,b,D
+119,PHYS,2040,2017,Fall,b,D
+119,PHYS,2140,2020,Fall,a,D
+120,BIOL,2420,2020,Spring,a,D
+120,CS,3505,2020,Fall,c,D
+120,MATH,2270,2017,Fall,c,D
+121,PHYS,2220,2020,Summer,a,D
+123,BIOL,2330,2016,Spring,a,D
+123,PHYS,2140,2016,Spring,a,D
+125,CS,4150,2020,Spring,a,D
+129,BIOL,2325,2018,Fall,b,D
+129,BIOL,2325,2018,Fall,c,D
+131,CS,3500,2017,Fall,b,D
+131,PHYS,2060,2018,Summer,a,D
+132,BIOL,2420,2017,Summer,a,D
+132,MATH,3220,2018,Spring,b,D
+132,PHYS,2220,2018,Spring,a,D
+133,MATH,1210,2019,Spring,a,D
+134,BIOL,2010,2018,Spring,a,D
+136,PHYS,2140,2020,Fall,a,D
+138,BIOL,2330,2015,Fall,b,D
+138,CS,2420,2015,Spring,a,D
+138,CS,4500,2016,Spring,b,D
+139,BIOL,2325,2019,Summer,a,D
+143,CS,4400,2019,Summer,b,D
+144,BIOL,2355,2016,Spring,a,D
+146,BIOL,2010,2020,Summer,a,D
+148,CS,4970,2020,Fall,c,D
+151,CS,3200,2016,Fall,d,D
+152,CS,4000,2020,Spring,b,D
+152,PHYS,2040,2019,Spring,b,D
+160,CS,2100,2016,Summer,b,D
+162,CS,4500,2016,Spring,b,D
+163,BIOL,2020,2018,Fall,a,D
+163,BIOL,2355,2017,Spring,d,D
+163,MATH,1220,2017,Spring,b,D
+165,CS,3200,2018,Spring,a,D
+169,MATH,1220,2018,Spring,b,D
+170,BIOL,2010,2020,Summer,a,D
+171,PHYS,2210,2019,Fall,b,D
+172,CS,3100,2015,Summer,a,D
+172,MATH,3210,2015,Fall,a,D
+173,PHYS,2100,2017,Summer,c,D
+173,PHYS,3210,2019,Spring,d,D
+175,BIOL,1010,2020,Summer,b,D
+176,MATH,2210,2017,Spring,a,D
+177,CS,4500,2016,Fall,a,D
+177,PHYS,2100,2017,Summer,a,D
+178,BIOL,1010,2020,Summer,a,D
+178,MATH,1220,2020,Spring,a,D
+178,MATH,2280,2018,Fall,c,D
+179,BIOL,2010,2020,Spring,b,D
+179,BIOL,2021,2016,Fall,a,D
+182,CS,3100,2016,Fall,a,D
+182,MATH,1210,2016,Spring,c,D
+183,CS,4400,2019,Fall,a,D
+183,MATH,2280,2020,Spring,a,D
+183,PHYS,3210,2020,Spring,a,D
+185,BIOL,2355,2018,Summer,a,D
+185,CS,4400,2020,Fall,b,D
+185,MATH,2210,2018,Spring,b,D
+185,PHYS,3220,2020,Spring,c,D
+187,PHYS,2210,2019,Spring,d,D
+188,BIOL,2030,2019,Summer,c,D
+193,CS,4000,2015,Spring,a,D
+194,BIOL,1006,2020,Spring,a,D
+194,PHYS,2040,2020,Spring,a,D
+197,BIOL,2010,2018,Spring,a,D
+199,PHYS,2140,2018,Summer,b,D
+199,PHYS,2210,2019,Spring,b,D
+200,CS,4500,2020,Spring,a,D
+203,CS,1410,2018,Spring,b,D
+204,MATH,2280,2015,Summer,a,D
+208,CS,2420,2017,Summer,c,D
+208,PHYS,3210,2017,Summer,b,D
+210,BIOL,1010,2015,Fall,a,D
+214,PHYS,2040,2017,Fall,c,D
+214,PHYS,3220,2017,Fall,a,D
+216,MATH,3220,2016,Spring,c,D
+219,CS,2420,2020,Summer,a,D
+219,CS,4970,2020,Summer,b,D
+220,CS,4500,2019,Fall,d,D
+220,MATH,1210,2020,Spring,a,D
+228,BIOL,2010,2020,Summer,a,D
+228,BIOL,2010,2020,Summer,b,D
+229,BIOL,1006,2017,Fall,b,D
+230,BIOL,2420,2020,Spring,b,D
+231,CS,2420,2020,Summer,a,D
+231,PHYS,2220,2018,Summer,a,D
+233,CS,4970,2020,Summer,c,D
+235,BIOL,2010,2020,Summer,b,D
+235,PHYS,2140,2020,Fall,a,D
+238,CS,3505,2019,Summer,b,D
+239,BIOL,2325,2018,Fall,c,D
+240,BIOL,2330,2019,Fall,a,D
+240,PHYS,2060,2020,Spring,b,D
+241,BIOL,2030,2019,Summer,d,D
+242,CS,3200,2020,Summer,a,D
+244,BIOL,1010,2020,Summer,b,D
+245,CS,1030,2016,Summer,a,D
+245,CS,2420,2016,Fall,a,D
+245,MATH,3220,2016,Fall,b,D
+245,PHYS,2220,2016,Fall,a,D
+246,BIOL,1006,2015,Summer,a,D
+246,CS,3200,2016,Summer,b,D
+247,PHYS,2060,2018,Fall,b,D
+248,BIOL,1030,2019,Spring,c,D
+252,MATH,1260,2017,Fall,a,D
+253,PHYS,2140,2018,Fall,a,D
+253,PHYS,2210,2019,Spring,c,D
+254,CS,4970,2020,Summer,d,D
+254,MATH,2270,2019,Fall,a,D
+256,BIOL,1210,2019,Spring,a,D
+256,CS,1030,2018,Fall,a,D
+256,MATH,2210,2018,Spring,b,D
+257,BIOL,2030,2017,Spring,a,D
+257,BIOL,2210,2017,Summer,a,D
+257,CS,2100,2018,Fall,b,D
+257,CS,2100,2018,Fall,c,D
+257,CS,3810,2018,Summer,c,D
+257,CS,4400,2019,Spring,d,D
+258,MATH,2270,2020,Spring,a,D
+259,BIOL,2210,2017,Summer,b,D
+259,CS,4400,2019,Fall,b,D
+259,CS,4970,2019,Fall,c,D
+260,CS,4500,2019,Fall,a,D
+260,MATH,1220,2017,Spring,d,D
+262,PHYS,3210,2017,Fall,a,D
+270,CS,2100,2018,Summer,a,D
+274,PHYS,2210,2018,Fall,b,D
+274,PHYS,3210,2018,Spring,b,D
+276,BIOL,2030,2018,Summer,b,D
+276,PHYS,2220,2015,Fall,a,D
+277,BIOL,2030,2016,Fall,a,D
+277,CS,3500,2016,Spring,a,D
+277,MATH,1250,2018,Summer,c,D
+278,PHYS,2060,2016,Summer,a,D
+284,CS,3505,2019,Fall,a,D
+285,BIOL,2355,2017,Spring,d,D
+285,CS,4940,2019,Fall,a,D
+285,PHYS,2060,2016,Summer,b,D
+288,BIOL,1006,2017,Fall,a,D
+292,CS,3505,2019,Summer,d,D
+294,MATH,1210,2019,Spring,a,D
+297,BIOL,1006,2020,Fall,a,D
+298,CS,3505,2019,Summer,b,D
+298,PHYS,2060,2018,Fall,a,D
+301,BIOL,1210,2016,Spring,a,D
+303,CS,4500,2019,Fall,a,D
+303,PHYS,2210,2019,Fall,d,D
+304,PHYS,2210,2017,Summer,d,D
+305,CS,3505,2018,Fall,a,D
+307,CS,2100,2019,Spring,a,D
+307,CS,4500,2016,Spring,b,D
+307,MATH,3210,2015,Fall,a,D
+309,PHYS,3210,2019,Summer,c,D
+311,BIOL,2210,2018,Summer,b,D
+312,BIOL,2010,2019,Fall,a,D
+312,BIOL,2355,2017,Spring,a,D
+312,CS,4400,2019,Spring,d,D
+312,MATH,1250,2018,Summer,a,D
+313,BIOL,2021,2019,Spring,b,D
+313,BIOL,2210,2018,Summer,b,D
+313,CS,4940,2020,Summer,a,D
+313,MATH,1220,2016,Spring,a,D
+320,BIOL,2030,2019,Summer,c,D
+321,MATH,1260,2019,Spring,c,D
+321,PHYS,2220,2015,Fall,a,D
+325,MATH,2270,2019,Summer,b,D
+325,PHYS,3220,2020,Spring,c,D
+329,CS,2100,2018,Summer,c,D
+329,CS,2420,2016,Fall,b,D
+332,BIOL,1006,2019,Fall,b,D
+332,PHYS,2220,2018,Fall,a,D
+333,CS,4400,2019,Spring,a,D
+335,BIOL,2355,2017,Fall,a,D
+335,CS,1410,2016,Spring,a,D
+335,CS,4500,2017,Summer,a,D
+335,MATH,1210,2016,Spring,b,D
+339,PHYS,3210,2020,Fall,a,D
+341,BIOL,1030,2020,Spring,a,D
+342,MATH,2210,2019,Spring,b,D
+342,MATH,2270,2017,Fall,b,D
+342,PHYS,2210,2017,Summer,c,D
+344,CS,3810,2018,Summer,b,D
+345,CS,4940,2020,Summer,b,D
+345,MATH,1210,2017,Summer,a,D
+345,MATH,2210,2020,Fall,a,D
+347,CS,1030,2020,Fall,a,D
+347,MATH,1260,2019,Spring,a,D
+347,MATH,2210,2020,Spring,a,D
+348,CS,3505,2015,Fall,d,D
+348,CS,4150,2015,Summer,b,D
+348,CS,4400,2020,Spring,a,D
+348,CS,4970,2018,Summer,b,D
+355,CS,3505,2017,Fall,a,D
+358,MATH,1220,2019,Fall,c,D
+364,BIOL,1006,2019,Fall,a,D
+365,BIOL,1006,2020,Spring,a,D
+366,CS,4500,2018,Spring,d,D
+372,BIOL,1210,2017,Spring,a,D
+373,CS,3505,2019,Summer,b,D
+374,BIOL,2030,2017,Spring,a,D
+375,CS,4000,2020,Spring,a,D
+375,PHYS,2060,2019,Fall,c,D
+377,CS,2420,2015,Spring,a,D
+377,CS,3200,2017,Spring,a,D
+377,PHYS,2060,2015,Spring,a,D
+378,CS,2100,2017,Spring,a,D
+379,BIOL,1010,2019,Spring,d,D
+379,MATH,3220,2018,Spring,a,D
+379,PHYS,2060,2016,Spring,a,D
+379,PHYS,3210,2017,Summer,b,D
+385,CS,3200,2016,Fall,d,D
+386,BIOL,1030,2020,Summer,a,D
+386,BIOL,2010,2020,Spring,a,D
+386,CS,2100,2019,Fall,a,D
+386,CS,4000,2020,Spring,a,D
+386,CS,4940,2020,Summer,a,D
+387,BIOL,2210,2017,Summer,b,D
+387,BIOL,2330,2017,Fall,a,D
+391,MATH,2270,2020,Fall,b,D
+392,CS,1410,2018,Spring,d,D
+392,PHYS,2140,2016,Summer,b,D
+393,BIOL,2325,2018,Summer,a,D
+393,CS,2100,2018,Summer,c,D
+393,MATH,2280,2016,Fall,a,D
+393,PHYS,2060,2016,Summer,a,D
+397,BIOL,2355,2017,Spring,c,D
+397,MATH,2270,2017,Fall,d,D
+397,PHYS,2060,2019,Summer,b,D
+100,PHYS,2060,2019,Fall,c,D+
+102,BIOL,2355,2017,Spring,d,D+
+102,CS,4970,2018,Fall,d,D+
+102,PHYS,2210,2019,Spring,c,D+
+102,PHYS,3210,2018,Spring,c,D+
+105,BIOL,2355,2017,Spring,a,D+
+107,CS,1410,2018,Spring,b,D+
+107,CS,2420,2018,Spring,a,D+
+107,CS,4500,2019,Summer,a,D+
+109,CS,1410,2018,Spring,c,D+
+109,MATH,3210,2020,Fall,a,D+
+113,BIOL,2010,2020,Spring,a,D+
+113,MATH,1260,2019,Summer,a,D+
+118,BIOL,1006,2020,Fall,c,D+
+119,BIOL,2420,2016,Spring,a,D+
+119,PHYS,2210,2019,Spring,a,D+
+119,PHYS,2220,2017,Spring,a,D+
+120,BIOL,2325,2019,Summer,a,D+
+120,CS,1030,2020,Spring,c,D+
+120,CS,2100,2019,Fall,d,D+
+120,MATH,2280,2018,Fall,c,D+
+121,CS,3505,2020,Fall,b,D+
+122,BIOL,2355,2020,Summer,a,D+
+122,BIOL,2420,2020,Fall,a,D+
+124,CS,3500,2020,Summer,a,D+
+124,CS,3505,2017,Fall,a,D+
+125,PHYS,2040,2020,Spring,a,D+
+128,CS,4400,2019,Spring,b,D+
+128,MATH,2270,2017,Fall,d,D+
+128,PHYS,2140,2018,Summer,a,D+
+128,PHYS,3220,2018,Summer,a,D+
+129,MATH,3220,2018,Spring,d,D+
+130,MATH,2270,2020,Fall,a,D+
+131,BIOL,2030,2019,Summer,d,D+
+131,MATH,1220,2018,Spring,b,D+
+131,PHYS,2040,2020,Spring,a,D+
+132,MATH,2270,2017,Summer,a,D+
+132,PHYS,2040,2017,Fall,c,D+
+135,CS,4970,2019,Summer,d,D+
+135,MATH,1210,2020,Spring,a,D+
+135,MATH,1250,2020,Summer,a,D+
+138,CS,3100,2016,Spring,b,D+
+138,CS,3200,2015,Fall,a,D+
+138,MATH,1250,2016,Fall,b,D+
+139,BIOL,1030,2019,Spring,b,D+
+139,CS,3200,2019,Spring,a,D+
+139,PHYS,3220,2017,Summer,a,D+
+142,BIOL,2355,2020,Fall,a,D+
+143,BIOL,1030,2019,Spring,c,D+
+143,BIOL,2210,2018,Summer,a,D+
+144,BIOL,1210,2016,Spring,a,D+
+146,CS,4970,2020,Summer,d,D+
+146,PHYS,3210,2019,Summer,a,D+
+149,CS,1030,2016,Spring,a,D+
+156,PHYS,2060,2018,Fall,a,D+
+162,MATH,1260,2015,Summer,a,D+
+163,PHYS,2220,2017,Spring,d,D+
+164,BIOL,2210,2017,Summer,b,D+
+164,CS,2100,2018,Fall,d,D+
+164,CS,4970,2019,Summer,b,D+
+164,MATH,1220,2020,Summer,a,D+
+165,CS,4970,2019,Spring,a,D+
+167,CS,2420,2020,Summer,a,D+
+172,CS,1410,2015,Summer,d,D+
+173,BIOL,2210,2018,Summer,c,D+
+173,CS,4970,2019,Summer,c,D+
+175,MATH,2270,2020,Spring,a,D+
+177,PHYS,2040,2015,Spring,a,D+
+177,PHYS,2060,2019,Fall,c,D+
+178,BIOL,2021,2019,Spring,b,D+
+178,CS,3505,2019,Fall,a,D+
+179,BIOL,1010,2015,Fall,a,D+
+179,CS,4150,2020,Spring,a,D+
+179,PHYS,3210,2017,Summer,a,D+
+182,BIOL,2010,2015,Summer,a,D+
+182,BIOL,2355,2017,Fall,b,D+
+183,MATH,2270,2019,Summer,c,D+
+185,CS,4970,2018,Summer,c,D+
+185,PHYS,3220,2020,Spring,d,D+
+187,BIOL,2355,2018,Summer,b,D+
+192,BIOL,2420,2015,Spring,d,D+
+192,MATH,3220,2016,Spring,d,D+
+192,PHYS,2140,2015,Spring,b,D+
+194,PHYS,2060,2019,Summer,b,D+
+199,CS,3505,2017,Fall,a,D+
+204,CS,4150,2015,Summer,a,D+
+208,CS,3505,2017,Fall,b,D+
+209,PHYS,3210,2018,Spring,c,D+
+210,BIOL,2210,2018,Summer,c,D+
+210,BIOL,2330,2020,Spring,a,D+
+210,CS,3810,2018,Summer,d,D+
+211,CS,3505,2015,Fall,a,D+
+213,CS,3810,2016,Fall,a,D+
+214,BIOL,2030,2018,Summer,b,D+
+214,BIOL,2330,2017,Summer,a,D+
+214,PHYS,2220,2018,Spring,a,D+
+215,PHYS,3220,2017,Summer,a,D+
+217,CS,2100,2018,Fall,c,D+
+220,BIOL,1210,2018,Fall,a,D+
+220,BIOL,2210,2020,Fall,a,D+
+227,BIOL,2355,2018,Summer,a,D+
+227,MATH,3210,2020,Fall,a,D+
+228,CS,3505,2019,Spring,a,D+
+228,MATH,1220,2020,Spring,a,D+
+230,BIOL,2210,2018,Summer,b,D+
+230,MATH,1210,2017,Summer,b,D+
+231,CS,4400,2017,Spring,b,D+
+231,PHYS,2060,2018,Fall,d,D+
+233,CS,3810,2020,Fall,a,D+
+238,CS,2100,2019,Summer,b,D+
+239,BIOL,1010,2018,Fall,a,D+
+240,CS,4500,2020,Summer,a,D+
+241,BIOL,1006,2020,Fall,a,D+
+241,PHYS,2210,2019,Fall,a,D+
+247,MATH,1220,2019,Fall,c,D+
+249,BIOL,2021,2015,Summer,b,D+
+251,CS,4940,2020,Summer,b,D+
+254,BIOL,1030,2020,Spring,a,D+
+254,MATH,1220,2019,Fall,a,D+
+255,BIOL,1006,2020,Fall,b,D+
+255,BIOL,1210,2018,Fall,a,D+
+255,CS,4970,2018,Fall,d,D+
+255,MATH,1210,2019,Summer,a,D+
+255,MATH,3210,2020,Fall,a,D+
+255,PHYS,2060,2020,Spring,a,D+
+255,PHYS,2210,2019,Spring,a,D+
+255,PHYS,3220,2020,Spring,b,D+
+256,CS,2100,2017,Spring,a,D+
+256,CS,3505,2018,Fall,a,D+
+256,PHYS,2210,2019,Summer,a,D+
+257,CS,4970,2020,Summer,a,D+
+259,CS,2100,2018,Fall,d,D+
+259,MATH,1220,2017,Spring,b,D+
+260,CS,1030,2019,Fall,a,D+
+260,CS,3500,2020,Summer,a,D+
+260,MATH,1260,2017,Fall,a,D+
+262,BIOL,1006,2017,Fall,a,D+
+262,BIOL,2355,2018,Summer,a,D+
+262,CS,4000,2017,Fall,a,D+
+264,CS,3810,2016,Fall,b,D+
+264,CS,4150,2016,Summer,b,D+
+264,MATH,3220,2016,Fall,b,D+
+267,CS,4970,2018,Fall,d,D+
+270,BIOL,2325,2019,Spring,b,D+
+270,CS,3505,2019,Summer,d,D+
+270,MATH,1210,2016,Spring,a,D+
+270,MATH,3210,2019,Spring,a,D+
+273,CS,1410,2016,Spring,b,D+
+275,CS,3500,2017,Fall,c,D+
+276,BIOL,2355,2018,Summer,d,D+
+276,CS,4400,2017,Spring,c,D+
+276,MATH,2280,2015,Fall,a,D+
+276,PHYS,3220,2017,Fall,d,D+
+277,BIOL,1006,2020,Fall,b,D+
+277,CS,3505,2020,Spring,a,D+
+277,MATH,2270,2017,Summer,a,D+
+285,BIOL,2325,2019,Summer,a,D+
+285,CS,2100,2018,Summer,b,D+
+285,CS,3810,2016,Fall,a,D+
+285,MATH,2210,2019,Spring,a,D+
+288,CS,4970,2017,Summer,a,D+
+288,PHYS,2100,2018,Fall,a,D+
+288,PHYS,2220,2017,Spring,d,D+
+289,BIOL,2020,2019,Summer,a,D+
+289,CS,2100,2020,Fall,a,D+
+289,CS,3505,2019,Fall,b,D+
+290,CS,1030,2016,Spring,a,D+
+291,BIOL,2330,2016,Fall,a,D+
+291,MATH,1260,2017,Fall,a,D+
+292,BIOL,2325,2019,Spring,a,D+
+292,BIOL,2420,2020,Summer,a,D+
+292,CS,4000,2020,Fall,a,D+
+292,MATH,2280,2019,Fall,c,D+
+292,PHYS,2040,2017,Summer,a,D+
+294,BIOL,1006,2018,Spring,b,D+
+294,CS,4400,2019,Summer,a,D+
+295,PHYS,2040,2015,Fall,b,D+
+296,CS,4500,2019,Fall,d,D+
+298,BIOL,2210,2018,Summer,a,D+
+298,PHYS,2140,2018,Summer,a,D+
+299,BIOL,2355,2017,Spring,c,D+
+300,BIOL,2030,2019,Summer,d,D+
+302,CS,3100,2015,Summer,a,D+
+303,BIOL,1006,2019,Summer,a,D+
+305,CS,4500,2018,Spring,b,D+
+305,MATH,2210,2019,Spring,b,D+
+305,PHYS,2040,2019,Spring,b,D+
+305,PHYS,2140,2018,Summer,b,D+
+305,PHYS,2210,2019,Spring,a,D+
+307,PHYS,2100,2017,Summer,b,D+
+309,BIOL,2010,2019,Fall,a,D+
+309,CS,4000,2020,Spring,b,D+
+309,CS,4970,2020,Summer,b,D+
+310,MATH,1210,2020,Spring,a,D+
+311,CS,3500,2017,Summer,a,D+
+312,BIOL,2210,2016,Summer,a,D+
+312,CS,2420,2016,Spring,a,D+
+312,CS,3200,2015,Fall,c,D+
+312,MATH,3220,2018,Spring,b,D+
+312,PHYS,3220,2016,Summer,a,D+
+313,BIOL,1030,2017,Spring,a,D+
+313,CS,4970,2016,Fall,b,D+
+313,PHYS,2060,2019,Summer,b,D+
+314,CS,3100,2016,Fall,a,D+
+318,BIOL,1006,2017,Fall,b,D+
+318,BIOL,2021,2018,Summer,a,D+
+318,CS,4000,2017,Summer,a,D+
+321,BIOL,1006,2017,Fall,a,D+
+321,BIOL,1010,2017,Summer,a,D+
+321,CS,3505,2017,Fall,b,D+
+321,CS,4940,2020,Summer,b,D+
+323,MATH,1220,2019,Fall,a,D+
+325,CS,4970,2019,Summer,b,D+
+326,CS,4940,2017,Fall,a,D+
+326,PHYS,2210,2017,Summer,b,D+
+329,BIOL,1010,2018,Summer,c,D+
+329,BIOL,2355,2017,Spring,d,D+
+329,BIOL,2420,2020,Fall,a,D+
+329,CS,4970,2019,Summer,d,D+
+329,PHYS,2060,2018,Fall,c,D+
+331,CS,3500,2020,Summer,a,D+
+331,CS,4940,2020,Summer,a,D+
+332,BIOL,2020,2018,Spring,a,D+
+332,MATH,1250,2018,Summer,a,D+
+333,BIOL,1030,2019,Spring,a,D+
+333,CS,4500,2019,Summer,a,D+
+335,CS,3500,2017,Fall,a,D+
+339,CS,4150,2020,Fall,a,D+
+342,CS,3200,2020,Spring,a,D+
+345,CS,4000,2017,Fall,b,D+
+345,PHYS,3210,2019,Summer,c,D+
+347,BIOL,2355,2018,Summer,d,D+
+347,CS,3810,2020,Fall,a,D+
+355,PHYS,2220,2017,Spring,b,D+
+356,BIOL,2210,2016,Summer,a,D+
+356,CS,3810,2018,Summer,d,D+
+356,CS,4970,2018,Fall,d,D+
+356,PHYS,3210,2019,Spring,a,D+
+361,CS,4000,2017,Fall,a,D+
+361,MATH,1260,2017,Summer,a,D+
+362,BIOL,1010,2018,Fall,a,D+
+363,BIOL,2420,2020,Summer,a,D+
+365,CS,2420,2020,Summer,a,D+
+366,BIOL,1006,2018,Spring,a,D+
+369,CS,4500,2018,Spring,c,D+
+371,BIOL,1006,2020,Fall,b,D+
+371,CS,3505,2018,Fall,a,D+
+372,CS,2420,2017,Summer,c,D+
+373,MATH,1250,2016,Summer,a,D+
+373,MATH,3220,2016,Spring,a,D+
+373,PHYS,3220,2020,Spring,d,D+
+374,BIOL,2355,2017,Spring,d,D+
+375,MATH,2210,2019,Spring,b,D+
+377,CS,3500,2019,Fall,a,D+
+377,PHYS,2140,2019,Fall,b,D+
+378,BIOL,2021,2018,Summer,a,D+
+378,BIOL,2355,2017,Spring,d,D+
+378,MATH,2210,2020,Fall,a,D+
+379,BIOL,2325,2015,Fall,b,D+
+379,CS,2100,2019,Spring,a,D+
+379,CS,4400,2019,Spring,a,D+
+379,MATH,1210,2016,Fall,c,D+
+380,CS,3505,2019,Summer,b,D+
+386,MATH,1210,2020,Spring,b,D+
+386,MATH,3210,2020,Fall,a,D+
+387,BIOL,1030,2018,Fall,a,D+
+387,BIOL,2355,2018,Summer,d,D+
+387,CS,2420,2017,Fall,a,D+
+388,CS,1410,2018,Spring,c,D+
+389,PHYS,2040,2016,Spring,a,D+
+390,BIOL,2355,2020,Fall,a,D+
+391,CS,4500,2018,Spring,d,D+
+391,PHYS,2210,2019,Spring,c,D+
+392,BIOL,2020,2015,Fall,b,D+
+392,CS,2420,2016,Fall,a,D+
+394,BIOL,1006,2015,Spring,b,D+
+397,BIOL,2021,2018,Fall,b,D+
+397,CS,2420,2016,Fall,c,D+
+397,CS,3100,2017,Fall,a,D+
+397,CS,4500,2020,Summer,a,D+
+397,CS,4940,2020,Summer,b,D+
+398,CS,4500,2019,Fall,b,D+
+399,PHYS,2040,2019,Spring,a,D+
+100,BIOL,2030,2019,Summer,b,F
+100,CS,4940,2020,Summer,b,F
+101,CS,4500,2018,Spring,a,F
+101,MATH,3220,2018,Spring,b,F
+101,PHYS,3210,2018,Fall,a,F
+102,CS,3810,2019,Fall,b,F
+102,MATH,3210,2016,Fall,a,F
+104,MATH,1210,2018,Fall,b,F
+106,BIOL,2355,2020,Summer,b,F
+106,PHYS,2060,2019,Summer,b,F
+107,MATH,1210,2016,Fall,c,F
+108,CS,3500,2019,Fall,b,F
+112,PHYS,2060,2020,Fall,a,F
+113,BIOL,1010,2020,Summer,a,F
+113,MATH,3210,2020,Summer,a,F
+115,BIOL,2210,2017,Spring,a,F
+116,CS,3505,2016,Fall,b,F
+117,BIOL,1210,2017,Spring,a,F
+119,BIOL,2210,2019,Summer,b,F
+119,BIOL,2325,2018,Spring,a,F
+119,CS,1030,2020,Fall,a,F
+119,MATH,2270,2020,Fall,b,F
+120,BIOL,1030,2016,Fall,a,F
+120,BIOL,2330,2016,Spring,a,F
+120,CS,1410,2018,Spring,a,F
+120,CS,3200,2016,Fall,a,F
+120,MATH,1260,2019,Summer,b,F
+121,CS,1410,2020,Spring,a,F
+121,CS,4970,2018,Fall,c,F
+122,CS,1410,2020,Spring,a,F
+123,CS,4400,2020,Fall,a,F
+127,CS,4400,2020,Fall,b,F
+127,CS,4500,2020,Summer,a,F
+128,BIOL,1030,2019,Summer,a,F
+128,CS,4500,2018,Spring,d,F
+129,BIOL,2355,2018,Summer,c,F
+129,PHYS,3210,2020,Fall,b,F
+131,BIOL,1030,2020,Summer,a,F
+131,BIOL,2210,2018,Summer,a,F
+131,BIOL,2325,2018,Fall,b,F
+131,MATH,1260,2019,Summer,a,F
+131,PHYS,3210,2020,Spring,a,F
+132,BIOL,2030,2018,Summer,b,F
+133,PHYS,3210,2019,Summer,b,F
+137,BIOL,2355,2020,Summer,b,F
+139,BIOL,1010,2019,Spring,a,F
+139,BIOL,2020,2018,Spring,a,F
+139,CS,2100,2018,Summer,c,F
+142,CS,3505,2020,Fall,a,F
+142,CS,4500,2020,Spring,a,F
+143,BIOL,2030,2019,Summer,d,F
+143,PHYS,2210,2019,Fall,a,F
+146,CS,3505,2020,Spring,a,F
+146,MATH,2280,2019,Fall,c,F
+149,CS,4500,2016,Spring,b,F
+149,MATH,1250,2015,Fall,a,F
+151,BIOL,2210,2017,Summer,a,F
+152,MATH,2270,2020,Fall,a,F
+158,BIOL,2020,2018,Fall,a,F
+158,PHYS,2100,2018,Fall,a,F
+162,CS,1030,2016,Spring,a,F
+162,CS,3505,2015,Fall,c,F
+163,BIOL,2030,2016,Summer,b,F
+163,PHYS,2140,2018,Fall,a,F
+164,PHYS,2220,2020,Summer,b,F
+167,BIOL,2010,2020,Summer,a,F
+167,CS,4940,2019,Fall,a,F
+167,PHYS,2060,2020,Spring,a,F
+167,PHYS,2140,2019,Fall,a,F
+169,BIOL,1006,2019,Fall,b,F
+169,BIOL,2355,2018,Spring,a,F
+175,PHYS,2220,2020,Spring,a,F
+177,BIOL,2210,2017,Spring,c,F
+177,CS,2100,2020,Fall,a,F
+177,CS,4940,2020,Summer,b,F
+177,MATH,1210,2018,Fall,a,F
+178,BIOL,1006,2019,Summer,a,F
+178,CS,4970,2018,Fall,d,F
+178,PHYS,2220,2018,Fall,a,F
+179,BIOL,1006,2016,Summer,b,F
+179,BIOL,2030,2017,Spring,a,F
+179,CS,3505,2018,Fall,a,F
+181,CS,1030,2020,Fall,a,F
+182,BIOL,1006,2015,Summer,a,F
+182,BIOL,1210,2016,Spring,a,F
+182,CS,1410,2015,Summer,c,F
+182,CS,2100,2018,Summer,b,F
+185,BIOL,2420,2018,Spring,a,F
+185,CS,4000,2020,Fall,a,F
+187,CS,3200,2020,Spring,b,F
+192,MATH,3210,2015,Fall,c,F
+194,CS,2100,2019,Summer,b,F
+195,BIOL,1010,2016,Summer,a,F
+195,CS,2420,2016,Summer,a,F
+195,PHYS,3220,2016,Summer,a,F
+197,BIOL,1030,2018,Summer,a,F
+199,BIOL,2030,2020,Spring,b,F
+199,CS,3505,2017,Fall,b,F
+199,CS,4400,2020,Spring,a,F
+200,CS,1410,2020,Spring,b,F
+200,MATH,2210,2020,Spring,b,F
+207,BIOL,2420,2017,Summer,b,F
+210,BIOL,2010,2018,Spring,a,F
+210,CS,3100,2017,Spring,a,F
+210,CS,4150,2019,Spring,a,F
+211,BIOL,1010,2015,Fall,c,F
+211,BIOL,1030,2015,Spring,c,F
+211,MATH,1250,2015,Spring,c,F
+213,PHYS,3220,2017,Fall,c,F
+220,BIOL,2355,2020,Fall,a,F
+220,CS,3505,2019,Summer,a,F
+221,BIOL,2355,2020,Summer,a,F
+221,CS,4970,2020,Summer,b,F
+223,MATH,3210,2019,Fall,a,F
+229,PHYS,3210,2018,Spring,a,F
+230,BIOL,1210,2019,Spring,a,F
+230,MATH,2280,2018,Spring,a,F
+231,BIOL,1030,2019,Spring,d,F
+231,MATH,1210,2018,Fall,a,F
+231,PHYS,2140,2018,Summer,a,F
+237,MATH,3220,2018,Spring,a,F
+238,BIOL,1010,2018,Summer,b,F
+240,CS,2100,2019,Spring,a,F
+243,BIOL,2021,2016,Fall,a,F
+246,BIOL,1030,2015,Summer,a,F
+247,CS,1030,2019,Fall,b,F
+247,CS,3500,2020,Summer,a,F
+247,CS,3505,2018,Summer,b,F
+248,BIOL,2420,2020,Spring,a,F
+250,PHYS,2060,2020,Fall,a,F
+252,BIOL,1010,2018,Fall,a,F
+252,CS,4000,2017,Fall,a,F
+255,BIOL,2355,2019,Spring,c,F
+255,BIOL,2420,2020,Fall,a,F
+255,CS,3505,2018,Summer,a,F
+255,MATH,2280,2020,Spring,a,F
+256,BIOL,2021,2018,Fall,a,F
+256,MATH,3210,2020,Fall,a,F
+257,MATH,1210,2018,Summer,a,F
+257,PHYS,2210,2019,Spring,d,F
+258,CS,2100,2018,Summer,c,F
+259,BIOL,1010,2018,Summer,c,F
+259,PHYS,2140,2017,Fall,a,F
+260,BIOL,2020,2018,Fall,b,F
+260,CS,4940,2017,Fall,a,F
+260,PHYS,3220,2018,Summer,a,F
+261,MATH,1250,2018,Summer,c,F
+261,PHYS,2210,2017,Summer,d,F
+262,CS,1030,2016,Fall,a,F
+267,CS,1410,2020,Spring,a,F
+267,CS,4970,2018,Fall,b,F
+268,BIOL,2021,2016,Fall,a,F
+270,BIOL,1010,2020,Summer,b,F
+270,BIOL,2030,2019,Summer,b,F
+270,BIOL,2030,2019,Summer,c,F
+270,CS,2420,2016,Fall,c,F
+272,CS,4400,2020,Fall,a,F
+274,CS,4970,2018,Fall,a,F
+276,BIOL,1010,2015,Summer,a,F
+276,BIOL,2355,2018,Summer,b,F
+276,CS,3500,2019,Summer,a,F
+276,CS,4970,2016,Fall,a,F
+276,MATH,1250,2015,Spring,c,F
+278,BIOL,1010,2017,Spring,a,F
+278,MATH,3220,2016,Summer,a,F
+280,MATH,1220,2015,Summer,b,F
+281,CS,3500,2020,Summer,a,F
+282,CS,3200,2015,Fall,d,F
+282,CS,3500,2016,Summer,a,F
+282,PHYS,2220,2015,Fall,b,F
+285,CS,4500,2016,Spring,b,F
+289,CS,4970,2019,Summer,a,F
+290,BIOL,2325,2015,Fall,b,F
+290,CS,4500,2015,Summer,b,F
+290,MATH,3220,2016,Spring,d,F
+292,BIOL,1010,2018,Summer,b,F
+292,BIOL,2355,2018,Fall,a,F
+292,CS,2100,2018,Fall,a,F
+293,PHYS,2220,2020,Summer,a,F
+299,MATH,1220,2017,Spring,a,F
+301,CS,1410,2015,Summer,d,F
+303,CS,3200,2020,Spring,a,F
+303,MATH,2270,2019,Summer,b,F
+303,PHYS,3220,2020,Spring,d,F
+304,BIOL,2010,2017,Fall,a,F
+304,MATH,1260,2017,Summer,a,F
+307,CS,4000,2015,Fall,a,F
+309,CS,3505,2019,Fall,b,F
+309,CS,4400,2017,Spring,b,F
+311,BIOL,2010,2020,Summer,a,F
+311,MATH,1210,2018,Fall,a,F
+311,PHYS,2060,2018,Fall,b,F
+312,BIOL,2030,2019,Summer,a,F
+312,MATH,1210,2018,Fall,a,F
+312,PHYS,2040,2015,Fall,b,F
+313,CS,3200,2016,Fall,b,F
+313,MATH,2280,2020,Spring,a,F
+313,PHYS,2040,2020,Spring,a,F
+314,PHYS,3210,2016,Summer,b,F
+320,CS,3505,2019,Spring,a,F
+329,BIOL,1030,2016,Summer,a,F
+329,BIOL,2210,2019,Summer,a,F
+329,BIOL,2325,2019,Spring,a,F
+329,CS,4150,2020,Fall,a,F
+329,PHYS,2140,2019,Fall,a,F
+332,CS,3505,2020,Spring,a,F
+333,BIOL,2420,2020,Summer,a,F
+335,BIOL,2330,2016,Fall,a,F
+339,CS,4940,2020,Summer,b,F
+339,PHYS,2220,2020,Summer,a,F
+340,BIOL,1006,2020,Spring,a,F
+340,CS,4500,2019,Summer,a,F
+341,MATH,1220,2019,Fall,a,F
+342,MATH,1210,2017,Summer,c,F
+344,CS,2100,2018,Fall,b,F
+345,CS,3200,2020,Fall,a,F
+345,MATH,2280,2018,Fall,c,F
+345,MATH,3220,2018,Spring,a,F
+347,PHYS,2210,2019,Fall,c,F
+348,PHYS,2060,2016,Summer,b,F
+353,CS,2420,2017,Summer,a,F
+355,BIOL,2010,2017,Fall,a,F
+355,CS,3100,2017,Spring,b,F
+355,PHYS,2100,2017,Summer,c,F
+356,BIOL,1210,2019,Spring,a,F
+358,CS,3505,2019,Spring,b,F
+359,PHYS,2210,2019,Summer,a,F
+361,CS,2420,2017,Summer,a,F
+362,BIOL,2355,2020,Spring,a,F
+363,CS,3500,2019,Fall,c,F
+365,PHYS,3220,2020,Spring,c,F
+366,PHYS,2060,2019,Summer,b,F
+366,PHYS,2100,2019,Summer,a,F
+368,BIOL,2210,2019,Summer,a,F
+368,CS,4970,2019,Summer,a,F
+368,MATH,1210,2018,Summer,a,F
+371,MATH,2270,2020,Fall,b,F
+372,MATH,1250,2018,Summer,a,F
+373,BIOL,1010,2017,Spring,a,F
+373,BIOL,2020,2018,Fall,b,F
+377,PHYS,3210,2017,Fall,a,F
+378,CS,4940,2020,Summer,a,F
+379,BIOL,2355,2018,Summer,d,F
+384,BIOL,1030,2019,Spring,d,F
+385,MATH,1210,2016,Fall,d,F
+386,CS,3505,2018,Summer,a,F
+386,PHYS,2100,2019,Summer,a,F
+386,PHYS,2220,2020,Fall,a,F
+387,BIOL,2325,2017,Fall,b,F
+387,MATH,1210,2017,Summer,c,F
+389,CS,1410,2016,Summer,a,F
+390,MATH,1210,2020,Spring,a,F
+391,BIOL,2330,2017,Fall,a,F
+391,CS,1030,2020,Spring,b,F
+391,CS,4970,2019,Summer,b,F
+391,MATH,1250,2020,Summer,a,F
+391,MATH,2270,2020,Fall,a,F
+391,PHYS,2220,2016,Summer,a,F
+392,MATH,3220,2016,Spring,b,F
+392,PHYS,2100,2016,Fall,a,F
+393,CS,1030,2016,Summer,a,F
+396,MATH,1220,2019,Fall,c,F
+397,BIOL,2210,2017,Spring,c,F
+399,BIOL,1010,2018,Summer,a,F
diff --git a/tests/integration/data/Section.csv b/tests/integration/data/Section.csv
new file mode 100644
index 000000000..8dc95361b
--- /dev/null
+++ b/tests/integration/data/Section.csv
@@ -0,0 +1,757 @@
+dept,course,term_year,term,section,auditorium
+BIOL,1006,2015,Spring,a,C68
+BIOL,1006,2015,Spring,b,C22
+BIOL,1006,2015,Summer,a,D38
+BIOL,1006,2015,Summer,b,C15
+BIOL,1006,2016,Spring,a,B87
+BIOL,1006,2016,Spring,b,D72
+BIOL,1006,2016,Summer,a,A34
+BIOL,1006,2016,Summer,b,D48
+BIOL,1006,2016,Summer,c,F34
+BIOL,1006,2016,Summer,d,F48
+BIOL,1006,2017,Fall,a,E42
+BIOL,1006,2017,Fall,b,B83
+BIOL,1006,2018,Spring,a,F39
+BIOL,1006,2018,Spring,b,A18
+BIOL,1006,2018,Fall,a,A13
+BIOL,1006,2019,Spring,a,D59
+BIOL,1006,2019,Summer,a,F70
+BIOL,1006,2019,Fall,a,B54
+BIOL,1006,2019,Fall,b,D79
+BIOL,1006,2020,Spring,a,A89
+BIOL,1006,2020,Fall,a,C13
+BIOL,1006,2020,Fall,b,C70
+BIOL,1006,2020,Fall,c,F46
+BIOL,1010,2015,Summer,a,D12
+BIOL,1010,2015,Summer,b,F82
+BIOL,1010,2015,Summer,c,A7
+BIOL,1010,2015,Summer,d,B17
+BIOL,1010,2015,Fall,a,B9
+BIOL,1010,2015,Fall,b,E27
+BIOL,1010,2015,Fall,c,B43
+BIOL,1010,2015,Fall,d,E1
+BIOL,1010,2016,Summer,a,B70
+BIOL,1010,2017,Spring,a,A17
+BIOL,1010,2017,Summer,a,B76
+BIOL,1010,2018,Summer,a,E15
+BIOL,1010,2018,Summer,b,D58
+BIOL,1010,2018,Summer,c,E76
+BIOL,1010,2018,Fall,a,E6
+BIOL,1010,2018,Fall,b,F67
+BIOL,1010,2019,Spring,a,A8
+BIOL,1010,2019,Spring,b,D55
+BIOL,1010,2019,Spring,c,D92
+BIOL,1010,2019,Spring,d,A11
+BIOL,1010,2020,Summer,a,E71
+BIOL,1010,2020,Summer,b,D77
+BIOL,1010,2020,Summer,c,D65
+BIOL,1010,2020,Summer,d,A90
+BIOL,1030,2015,Spring,a,E93
+BIOL,1030,2015,Spring,b,D58
+BIOL,1030,2015,Spring,c,D44
+BIOL,1030,2015,Spring,d,D54
+BIOL,1030,2015,Summer,a,C55
+BIOL,1030,2016,Spring,a,F61
+BIOL,1030,2016,Summer,a,A56
+BIOL,1030,2016,Fall,a,B72
+BIOL,1030,2017,Spring,a,E43
+BIOL,1030,2017,Spring,b,D46
+BIOL,1030,2017,Spring,c,D93
+BIOL,1030,2018,Summer,a,B85
+BIOL,1030,2018,Fall,a,C72
+BIOL,1030,2019,Spring,a,E29
+BIOL,1030,2019,Spring,b,E99
+BIOL,1030,2019,Spring,c,E87
+BIOL,1030,2019,Spring,d,A78
+BIOL,1030,2019,Summer,a,F35
+BIOL,1030,2020,Spring,a,C45
+BIOL,1030,2020,Summer,a,E85
+BIOL,1210,2015,Spring,a,A12
+BIOL,1210,2015,Spring,b,B49
+BIOL,1210,2016,Spring,a,E77
+BIOL,1210,2017,Spring,a,F11
+BIOL,1210,2017,Summer,a,D78
+BIOL,1210,2018,Spring,a,A45
+BIOL,1210,2018,Fall,a,D68
+BIOL,1210,2018,Fall,b,A29
+BIOL,1210,2019,Spring,a,A27
+BIOL,2010,2015,Spring,a,B17
+BIOL,2010,2015,Summer,a,E72
+BIOL,2010,2015,Summer,b,C10
+BIOL,2010,2015,Fall,a,D3
+BIOL,2010,2017,Summer,a,C15
+BIOL,2010,2017,Fall,a,B80
+BIOL,2010,2018,Spring,a,C12
+BIOL,2010,2019,Fall,a,F44
+BIOL,2010,2020,Spring,a,A66
+BIOL,2010,2020,Spring,b,E66
+BIOL,2010,2020,Summer,a,C94
+BIOL,2010,2020,Summer,b,F19
+BIOL,2020,2015,Summer,a,F10
+BIOL,2020,2015,Fall,a,D60
+BIOL,2020,2015,Fall,b,E58
+BIOL,2020,2015,Fall,c,E83
+BIOL,2020,2015,Fall,d,E42
+BIOL,2020,2016,Spring,a,F41
+BIOL,2020,2018,Spring,a,C60
+BIOL,2020,2018,Fall,a,A83
+BIOL,2020,2018,Fall,b,A79
+BIOL,2020,2018,Fall,c,D60
+BIOL,2020,2018,Fall,d,F6
+BIOL,2020,2019,Summer,a,F25
+BIOL,2021,2015,Spring,a,C92
+BIOL,2021,2015,Summer,a,A32
+BIOL,2021,2015,Summer,b,D68
+BIOL,2021,2015,Summer,c,B47
+BIOL,2021,2016,Fall,a,F83
+BIOL,2021,2017,Summer,a,D37
+BIOL,2021,2017,Fall,a,E20
+BIOL,2021,2018,Spring,a,B45
+BIOL,2021,2018,Summer,a,F51
+BIOL,2021,2018,Fall,a,A40
+BIOL,2021,2018,Fall,b,F43
+BIOL,2021,2018,Fall,c,F90
+BIOL,2021,2018,Fall,d,F88
+BIOL,2021,2019,Spring,a,A83
+BIOL,2021,2019,Spring,b,E47
+BIOL,2021,2019,Fall,a,C99
+BIOL,2030,2015,Spring,a,A65
+BIOL,2030,2015,Spring,b,F68
+BIOL,2030,2015,Fall,a,B77
+BIOL,2030,2016,Summer,a,E22
+BIOL,2030,2016,Summer,b,A53
+BIOL,2030,2016,Fall,a,D79
+BIOL,2030,2017,Spring,a,D30
+BIOL,2030,2017,Spring,b,C61
+BIOL,2030,2017,Spring,c,B48
+BIOL,2030,2017,Spring,d,E57
+BIOL,2030,2018,Summer,a,B26
+BIOL,2030,2018,Summer,b,B33
+BIOL,2030,2019,Summer,a,F67
+BIOL,2030,2019,Summer,b,C11
+BIOL,2030,2019,Summer,c,C58
+BIOL,2030,2019,Summer,d,B56
+BIOL,2030,2020,Spring,a,D45
+BIOL,2030,2020,Spring,b,D7
+BIOL,2210,2016,Summer,a,C19
+BIOL,2210,2017,Spring,a,F18
+BIOL,2210,2017,Spring,b,D58
+BIOL,2210,2017,Spring,c,A3
+BIOL,2210,2017,Summer,a,E94
+BIOL,2210,2017,Summer,b,D15
+BIOL,2210,2017,Summer,c,B39
+BIOL,2210,2018,Spring,a,E59
+BIOL,2210,2018,Summer,a,D77
+BIOL,2210,2018,Summer,b,F66
+BIOL,2210,2018,Summer,c,F19
+BIOL,2210,2019,Summer,a,B86
+BIOL,2210,2019,Summer,b,E47
+BIOL,2210,2019,Fall,a,E65
+BIOL,2210,2019,Fall,b,D61
+BIOL,2210,2020,Fall,a,C9
+BIOL,2325,2015,Spring,a,F14
+BIOL,2325,2015,Spring,b,F97
+BIOL,2325,2015,Fall,a,F23
+BIOL,2325,2015,Fall,b,F60
+BIOL,2325,2015,Fall,c,D81
+BIOL,2325,2016,Summer,a,D5
+BIOL,2325,2017,Fall,a,E51
+BIOL,2325,2017,Fall,b,E61
+BIOL,2325,2018,Spring,a,B37
+BIOL,2325,2018,Summer,a,F43
+BIOL,2325,2018,Fall,a,D52
+BIOL,2325,2018,Fall,b,D44
+BIOL,2325,2018,Fall,c,D89
+BIOL,2325,2019,Spring,a,E35
+BIOL,2325,2019,Spring,b,F55
+BIOL,2325,2019,Summer,a,B70
+BIOL,2330,2015,Spring,a,B89
+BIOL,2330,2015,Fall,a,C79
+BIOL,2330,2015,Fall,b,C82
+BIOL,2330,2015,Fall,c,A10
+BIOL,2330,2015,Fall,d,D47
+BIOL,2330,2016,Spring,a,F87
+BIOL,2330,2016,Fall,a,F57
+BIOL,2330,2017,Summer,a,C47
+BIOL,2330,2017,Fall,a,E20
+BIOL,2330,2017,Fall,b,C48
+BIOL,2330,2019,Fall,a,A95
+BIOL,2330,2020,Spring,a,E16
+BIOL,2355,2015,Spring,a,C89
+BIOL,2355,2015,Spring,b,D26
+BIOL,2355,2015,Summer,a,D23
+BIOL,2355,2015,Summer,b,D12
+BIOL,2355,2015,Summer,c,C86
+BIOL,2355,2016,Spring,a,C21
+BIOL,2355,2016,Spring,b,F82
+BIOL,2355,2017,Spring,a,B31
+BIOL,2355,2017,Spring,b,A47
+BIOL,2355,2017,Spring,c,C60
+BIOL,2355,2017,Spring,d,E17
+BIOL,2355,2017,Summer,a,A9
+BIOL,2355,2017,Fall,a,F62
+BIOL,2355,2017,Fall,b,D74
+BIOL,2355,2018,Spring,a,F10
+BIOL,2355,2018,Summer,a,C17
+BIOL,2355,2018,Summer,b,E82
+BIOL,2355,2018,Summer,c,B56
+BIOL,2355,2018,Summer,d,A16
+BIOL,2355,2018,Fall,a,C22
+BIOL,2355,2019,Spring,a,B45
+BIOL,2355,2019,Spring,b,E37
+BIOL,2355,2019,Spring,c,C26
+BIOL,2355,2019,Spring,d,E36
+BIOL,2355,2020,Spring,a,E83
+BIOL,2355,2020,Summer,a,B22
+BIOL,2355,2020,Summer,b,F78
+BIOL,2355,2020,Fall,a,A4
+BIOL,2420,2015,Spring,a,E34
+BIOL,2420,2015,Spring,b,E54
+BIOL,2420,2015,Spring,c,A64
+BIOL,2420,2015,Spring,d,E38
+BIOL,2420,2015,Summer,a,C62
+BIOL,2420,2015,Fall,a,D39
+BIOL,2420,2016,Spring,a,B57
+BIOL,2420,2017,Summer,a,C94
+BIOL,2420,2017,Summer,b,C52
+BIOL,2420,2018,Spring,a,C31
+BIOL,2420,2020,Spring,a,B21
+BIOL,2420,2020,Spring,b,E93
+BIOL,2420,2020,Summer,a,D66
+BIOL,2420,2020,Fall,a,D3
+CS,1030,2016,Spring,a,A7
+CS,1030,2016,Summer,a,F87
+CS,1030,2016,Fall,a,A56
+CS,1030,2018,Fall,a,C71
+CS,1030,2019,Fall,a,E88
+CS,1030,2019,Fall,b,B13
+CS,1030,2020,Spring,a,C72
+CS,1030,2020,Spring,b,B26
+CS,1030,2020,Spring,c,D65
+CS,1030,2020,Fall,a,D67
+CS,1410,2015,Spring,a,E18
+CS,1410,2015,Summer,a,B51
+CS,1410,2015,Summer,b,F39
+CS,1410,2015,Summer,c,E66
+CS,1410,2015,Summer,d,F73
+CS,1410,2016,Spring,a,C43
+CS,1410,2016,Spring,b,D75
+CS,1410,2016,Summer,a,F81
+CS,1410,2017,Spring,a,E74
+CS,1410,2018,Spring,a,F80
+CS,1410,2018,Spring,b,D19
+CS,1410,2018,Spring,c,B5
+CS,1410,2018,Spring,d,F15
+CS,1410,2020,Spring,a,E61
+CS,1410,2020,Spring,b,F94
+CS,2100,2015,Summer,a,E49
+CS,2100,2016,Spring,a,C70
+CS,2100,2016,Summer,a,F88
+CS,2100,2016,Summer,b,F34
+CS,2100,2016,Summer,c,B32
+CS,2100,2017,Spring,a,C99
+CS,2100,2017,Fall,a,C62
+CS,2100,2018,Spring,a,F36
+CS,2100,2018,Summer,a,E49
+CS,2100,2018,Summer,b,D45
+CS,2100,2018,Summer,c,B38
+CS,2100,2018,Fall,a,A45
+CS,2100,2018,Fall,b,F33
+CS,2100,2018,Fall,c,B26
+CS,2100,2018,Fall,d,C72
+CS,2100,2019,Spring,a,B14
+CS,2100,2019,Spring,b,E31
+CS,2100,2019,Summer,a,E29
+CS,2100,2019,Summer,b,A13
+CS,2100,2019,Fall,a,A88
+CS,2100,2019,Fall,b,A71
+CS,2100,2019,Fall,c,B53
+CS,2100,2019,Fall,d,D62
+CS,2100,2020,Spring,a,C42
+CS,2100,2020,Fall,a,F74
+CS,2420,2015,Spring,a,A23
+CS,2420,2015,Summer,a,A51
+CS,2420,2015,Summer,b,B96
+CS,2420,2015,Summer,c,C5
+CS,2420,2015,Fall,a,A43
+CS,2420,2016,Spring,a,E68
+CS,2420,2016,Summer,a,E60
+CS,2420,2016,Fall,a,C21
+CS,2420,2016,Fall,b,F33
+CS,2420,2016,Fall,c,A95
+CS,2420,2017,Summer,a,B23
+CS,2420,2017,Summer,b,F52
+CS,2420,2017,Summer,c,E42
+CS,2420,2017,Fall,a,B18
+CS,2420,2018,Spring,a,A34
+CS,2420,2019,Summer,a,E2
+CS,2420,2020,Summer,a,D40
+CS,2420,2020,Fall,a,F99
+CS,3100,2015,Summer,a,C48
+CS,3100,2015,Summer,b,B18
+CS,3100,2016,Spring,a,C54
+CS,3100,2016,Spring,b,D97
+CS,3100,2016,Spring,c,F28
+CS,3100,2016,Spring,d,F97
+CS,3100,2016,Summer,a,A68
+CS,3100,2016,Fall,a,A73
+CS,3100,2017,Spring,a,E26
+CS,3100,2017,Spring,b,B22
+CS,3100,2017,Summer,a,A88
+CS,3100,2017,Fall,a,A66
+CS,3100,2019,Spring,a,E60
+CS,3100,2019,Spring,b,C93
+CS,3200,2015,Spring,a,E8
+CS,3200,2015,Spring,b,A61
+CS,3200,2015,Fall,a,F94
+CS,3200,2015,Fall,b,D48
+CS,3200,2015,Fall,c,D58
+CS,3200,2015,Fall,d,D49
+CS,3200,2016,Summer,a,E18
+CS,3200,2016,Summer,b,C16
+CS,3200,2016,Fall,a,E17
+CS,3200,2016,Fall,b,B1
+CS,3200,2016,Fall,c,C60
+CS,3200,2016,Fall,d,E55
+CS,3200,2017,Spring,a,B32
+CS,3200,2018,Spring,a,A5
+CS,3200,2018,Spring,b,D79
+CS,3200,2018,Spring,c,A31
+CS,3200,2019,Spring,a,F7
+CS,3200,2020,Spring,a,A18
+CS,3200,2020,Spring,b,C30
+CS,3200,2020,Spring,c,F74
+CS,3200,2020,Summer,a,F42
+CS,3200,2020,Fall,a,F67
+CS,3500,2015,Fall,a,F23
+CS,3500,2015,Fall,b,D72
+CS,3500,2016,Spring,a,F86
+CS,3500,2016,Summer,a,F54
+CS,3500,2017,Summer,a,B29
+CS,3500,2017,Fall,a,D8
+CS,3500,2017,Fall,b,D72
+CS,3500,2017,Fall,c,D32
+CS,3500,2019,Summer,a,B7
+CS,3500,2019,Fall,a,E6
+CS,3500,2019,Fall,b,B98
+CS,3500,2019,Fall,c,F72
+CS,3500,2020,Summer,a,C2
+CS,3505,2015,Spring,a,F97
+CS,3505,2015,Fall,a,B51
+CS,3505,2015,Fall,b,E42
+CS,3505,2015,Fall,c,D60
+CS,3505,2015,Fall,d,C40
+CS,3505,2016,Summer,a,D60
+CS,3505,2016,Fall,a,D98
+CS,3505,2016,Fall,b,B48
+CS,3505,2017,Summer,a,F19
+CS,3505,2017,Fall,a,E75
+CS,3505,2017,Fall,b,C20
+CS,3505,2018,Summer,a,B64
+CS,3505,2018,Summer,b,F44
+CS,3505,2018,Fall,a,F83
+CS,3505,2018,Fall,b,D22
+CS,3505,2018,Fall,c,C22
+CS,3505,2019,Spring,a,B70
+CS,3505,2019,Spring,b,A68
+CS,3505,2019,Summer,a,F7
+CS,3505,2019,Summer,b,D18
+CS,3505,2019,Summer,c,B9
+CS,3505,2019,Summer,d,A28
+CS,3505,2019,Fall,a,C8
+CS,3505,2019,Fall,b,F79
+CS,3505,2019,Fall,c,F63
+CS,3505,2020,Spring,a,D2
+CS,3505,2020,Summer,a,E37
+CS,3505,2020,Fall,a,F56
+CS,3505,2020,Fall,b,B14
+CS,3505,2020,Fall,c,E20
+CS,3810,2015,Spring,a,C46
+CS,3810,2016,Summer,a,F29
+CS,3810,2016,Fall,a,A84
+CS,3810,2016,Fall,b,F98
+CS,3810,2018,Spring,a,F22
+CS,3810,2018,Summer,a,F43
+CS,3810,2018,Summer,b,A68
+CS,3810,2018,Summer,c,B28
+CS,3810,2018,Summer,d,F73
+CS,3810,2019,Fall,a,E73
+CS,3810,2019,Fall,b,B41
+CS,3810,2020,Fall,a,D10
+CS,4000,2015,Spring,a,E50
+CS,4000,2015,Spring,b,E43
+CS,4000,2015,Summer,a,F93
+CS,4000,2015,Fall,a,C7
+CS,4000,2016,Fall,a,E77
+CS,4000,2017,Spring,a,A82
+CS,4000,2017,Summer,a,D30
+CS,4000,2017,Fall,a,D24
+CS,4000,2017,Fall,b,F49
+CS,4000,2018,Spring,a,B92
+CS,4000,2019,Spring,a,B95
+CS,4000,2020,Spring,a,D47
+CS,4000,2020,Spring,b,A17
+CS,4000,2020,Fall,a,E53
+CS,4150,2015,Summer,a,E77
+CS,4150,2015,Summer,b,D2
+CS,4150,2016,Summer,a,B74
+CS,4150,2016,Summer,b,F49
+CS,4150,2018,Fall,a,C33
+CS,4150,2018,Fall,b,F81
+CS,4150,2019,Spring,a,D14
+CS,4150,2020,Spring,a,D43
+CS,4150,2020,Fall,a,F77
+CS,4400,2015,Summer,a,B62
+CS,4400,2015,Fall,a,C38
+CS,4400,2015,Fall,b,F63
+CS,4400,2015,Fall,c,B42
+CS,4400,2016,Spring,a,D47
+CS,4400,2016,Summer,a,E70
+CS,4400,2016,Fall,a,A94
+CS,4400,2017,Spring,a,D38
+CS,4400,2017,Spring,b,A53
+CS,4400,2017,Spring,c,B82
+CS,4400,2019,Spring,a,E52
+CS,4400,2019,Spring,b,F54
+CS,4400,2019,Spring,c,C90
+CS,4400,2019,Spring,d,E77
+CS,4400,2019,Summer,a,A14
+CS,4400,2019,Summer,b,F86
+CS,4400,2019,Fall,a,A73
+CS,4400,2019,Fall,b,F83
+CS,4400,2020,Spring,a,D14
+CS,4400,2020,Fall,a,E72
+CS,4400,2020,Fall,b,E29
+CS,4500,2015,Summer,a,E89
+CS,4500,2015,Summer,b,C4
+CS,4500,2016,Spring,a,A15
+CS,4500,2016,Spring,b,F19
+CS,4500,2016,Fall,a,E62
+CS,4500,2017,Summer,a,D41
+CS,4500,2018,Spring,a,A44
+CS,4500,2018,Spring,b,F22
+CS,4500,2018,Spring,c,F32
+CS,4500,2018,Spring,d,E21
+CS,4500,2019,Summer,a,F24
+CS,4500,2019,Fall,a,D4
+CS,4500,2019,Fall,b,B58
+CS,4500,2019,Fall,c,D1
+CS,4500,2019,Fall,d,B36
+CS,4500,2020,Spring,a,A74
+CS,4500,2020,Summer,a,B47
+CS,4940,2015,Summer,a,E82
+CS,4940,2017,Fall,a,C79
+CS,4940,2017,Fall,b,F18
+CS,4940,2019,Fall,a,E50
+CS,4940,2020,Summer,a,F23
+CS,4940,2020,Summer,b,D37
+CS,4970,2016,Fall,a,E65
+CS,4970,2016,Fall,b,D88
+CS,4970,2017,Spring,a,D63
+CS,4970,2017,Summer,a,B38
+CS,4970,2018,Summer,a,E96
+CS,4970,2018,Summer,b,D71
+CS,4970,2018,Summer,c,E15
+CS,4970,2018,Fall,a,C70
+CS,4970,2018,Fall,b,A98
+CS,4970,2018,Fall,c,E28
+CS,4970,2018,Fall,d,A95
+CS,4970,2019,Spring,a,B39
+CS,4970,2019,Spring,b,A58
+CS,4970,2019,Summer,a,A57
+CS,4970,2019,Summer,b,A100
+CS,4970,2019,Summer,c,B95
+CS,4970,2019,Summer,d,C91
+CS,4970,2019,Fall,a,D22
+CS,4970,2019,Fall,b,B27
+CS,4970,2019,Fall,c,E45
+CS,4970,2019,Fall,d,E69
+CS,4970,2020,Summer,a,C38
+CS,4970,2020,Summer,b,E87
+CS,4970,2020,Summer,c,B97
+CS,4970,2020,Summer,d,A36
+CS,4970,2020,Fall,a,B90
+CS,4970,2020,Fall,b,B19
+CS,4970,2020,Fall,c,B98
+CS,4970,2020,Fall,d,D63
+MATH,1210,2015,Summer,a,F54
+MATH,1210,2016,Spring,a,A52
+MATH,1210,2016,Spring,b,C89
+MATH,1210,2016,Spring,c,C59
+MATH,1210,2016,Spring,d,C75
+MATH,1210,2016,Fall,a,F12
+MATH,1210,2016,Fall,b,D82
+MATH,1210,2016,Fall,c,C9
+MATH,1210,2016,Fall,d,D28
+MATH,1210,2017,Spring,a,B64
+MATH,1210,2017,Summer,a,C71
+MATH,1210,2017,Summer,b,E63
+MATH,1210,2017,Summer,c,F98
+MATH,1210,2018,Spring,a,D3
+MATH,1210,2018,Summer,a,D59
+MATH,1210,2018,Fall,a,B89
+MATH,1210,2018,Fall,b,F39
+MATH,1210,2019,Spring,a,C12
+MATH,1210,2019,Spring,b,C11
+MATH,1210,2019,Summer,a,B7
+MATH,1210,2020,Spring,a,B55
+MATH,1210,2020,Spring,b,F13
+MATH,1220,2015,Summer,a,A2
+MATH,1220,2015,Summer,b,A55
+MATH,1220,2015,Summer,c,D10
+MATH,1220,2016,Spring,a,A41
+MATH,1220,2017,Spring,a,B83
+MATH,1220,2017,Spring,b,B9
+MATH,1220,2017,Spring,c,A79
+MATH,1220,2017,Spring,d,D45
+MATH,1220,2017,Summer,a,F96
+MATH,1220,2018,Spring,a,B12
+MATH,1220,2018,Spring,b,B97
+MATH,1220,2018,Summer,a,C55
+MATH,1220,2019,Fall,a,E93
+MATH,1220,2019,Fall,b,F4
+MATH,1220,2019,Fall,c,F39
+MATH,1220,2020,Spring,a,B96
+MATH,1220,2020,Summer,a,B64
+MATH,1250,2015,Spring,a,A68
+MATH,1250,2015,Spring,b,A47
+MATH,1250,2015,Spring,c,B50
+MATH,1250,2015,Spring,d,E54
+MATH,1250,2015,Fall,a,D99
+MATH,1250,2016,Spring,a,A34
+MATH,1250,2016,Summer,a,D65
+MATH,1250,2016,Fall,a,D55
+MATH,1250,2016,Fall,b,A82
+MATH,1250,2016,Fall,c,E20
+MATH,1250,2017,Summer,a,B20
+MATH,1250,2017,Summer,b,D76
+MATH,1250,2017,Summer,c,F88
+MATH,1250,2017,Summer,d,C90
+MATH,1250,2018,Spring,a,B8
+MATH,1250,2018,Summer,a,A59
+MATH,1250,2018,Summer,b,A40
+MATH,1250,2018,Summer,c,F95
+MATH,1250,2020,Summer,a,F34
+MATH,1260,2015,Spring,a,C94
+MATH,1260,2015,Spring,b,A43
+MATH,1260,2015,Spring,c,C68
+MATH,1260,2015,Summer,a,E81
+MATH,1260,2016,Fall,a,C21
+MATH,1260,2017,Summer,a,F15
+MATH,1260,2017,Fall,a,A2
+MATH,1260,2019,Spring,a,A71
+MATH,1260,2019,Spring,b,F95
+MATH,1260,2019,Spring,c,B42
+MATH,1260,2019,Summer,a,C35
+MATH,1260,2019,Summer,b,E48
+MATH,1260,2019,Fall,a,A23
+MATH,1260,2020,Spring,a,A52
+MATH,2210,2015,Spring,a,C12
+MATH,2210,2015,Spring,b,A48
+MATH,2210,2015,Summer,a,C95
+MATH,2210,2015,Summer,b,D48
+MATH,2210,2015,Summer,c,D99
+MATH,2210,2015,Summer,d,F70
+MATH,2210,2015,Fall,a,B20
+MATH,2210,2017,Spring,a,A43
+MATH,2210,2017,Summer,a,F94
+MATH,2210,2018,Spring,a,D63
+MATH,2210,2018,Spring,b,B92
+MATH,2210,2019,Spring,a,D90
+MATH,2210,2019,Spring,b,D96
+MATH,2210,2020,Spring,a,A76
+MATH,2210,2020,Spring,b,D85
+MATH,2210,2020,Spring,c,B38
+MATH,2210,2020,Fall,a,F95
+MATH,2270,2015,Fall,a,B100
+MATH,2270,2015,Fall,b,A20
+MATH,2270,2017,Summer,a,D40
+MATH,2270,2017,Fall,a,A21
+MATH,2270,2017,Fall,b,C91
+MATH,2270,2017,Fall,c,A28
+MATH,2270,2017,Fall,d,C19
+MATH,2270,2019,Spring,a,F39
+MATH,2270,2019,Summer,a,A52
+MATH,2270,2019,Summer,b,E96
+MATH,2270,2019,Summer,c,A60
+MATH,2270,2019,Fall,a,A2
+MATH,2270,2020,Spring,a,B17
+MATH,2270,2020,Fall,a,F11
+MATH,2270,2020,Fall,b,C10
+MATH,2280,2015,Summer,a,D17
+MATH,2280,2015,Fall,a,C16
+MATH,2280,2016,Fall,a,F51
+MATH,2280,2018,Spring,a,C36
+MATH,2280,2018,Fall,a,E32
+MATH,2280,2018,Fall,b,D53
+MATH,2280,2018,Fall,c,D8
+MATH,2280,2019,Fall,a,E32
+MATH,2280,2019,Fall,b,E3
+MATH,2280,2019,Fall,c,F46
+MATH,2280,2020,Spring,a,C73
+MATH,2280,2020,Spring,b,D35
+MATH,3210,2015,Spring,a,C8
+MATH,3210,2015,Spring,b,D68
+MATH,3210,2015,Summer,a,B21
+MATH,3210,2015,Fall,a,C69
+MATH,3210,2015,Fall,b,F8
+MATH,3210,2015,Fall,c,B74
+MATH,3210,2015,Fall,d,D46
+MATH,3210,2016,Spring,a,B23
+MATH,3210,2016,Fall,a,C76
+MATH,3210,2017,Spring,a,E73
+MATH,3210,2017,Summer,a,D70
+MATH,3210,2019,Spring,a,A43
+MATH,3210,2019,Spring,b,B17
+MATH,3210,2019,Fall,a,C8
+MATH,3210,2020,Spring,a,B100
+MATH,3210,2020,Summer,a,C10
+MATH,3210,2020,Fall,a,D76
+MATH,3220,2016,Spring,a,F63
+MATH,3220,2016,Spring,b,B91
+MATH,3220,2016,Spring,c,F79
+MATH,3220,2016,Spring,d,B86
+MATH,3220,2016,Summer,a,B49
+MATH,3220,2016,Fall,a,B23
+MATH,3220,2016,Fall,b,F74
+MATH,3220,2017,Spring,a,E5
+MATH,3220,2017,Fall,a,E29
+MATH,3220,2017,Fall,b,A64
+MATH,3220,2018,Spring,a,B45
+MATH,3220,2018,Spring,b,B82
+MATH,3220,2018,Spring,c,A91
+MATH,3220,2018,Spring,d,F43
+PHYS,2040,2015,Spring,a,B53
+PHYS,2040,2015,Fall,a,A62
+PHYS,2040,2015,Fall,b,E84
+PHYS,2040,2015,Fall,c,B21
+PHYS,2040,2016,Spring,a,A38
+PHYS,2040,2017,Summer,a,B94
+PHYS,2040,2017,Fall,a,A44
+PHYS,2040,2017,Fall,b,E62
+PHYS,2040,2017,Fall,c,D84
+PHYS,2040,2018,Spring,a,B7
+PHYS,2040,2019,Spring,a,F94
+PHYS,2040,2019,Spring,b,F37
+PHYS,2040,2020,Spring,a,D20
+PHYS,2060,2015,Spring,a,F77
+PHYS,2060,2016,Spring,a,A61
+PHYS,2060,2016,Spring,b,C51
+PHYS,2060,2016,Summer,a,C12
+PHYS,2060,2016,Summer,b,D24
+PHYS,2060,2018,Summer,a,E8
+PHYS,2060,2018,Fall,a,A11
+PHYS,2060,2018,Fall,b,E53
+PHYS,2060,2018,Fall,c,E30
+PHYS,2060,2018,Fall,d,D67
+PHYS,2060,2019,Summer,a,D74
+PHYS,2060,2019,Summer,b,D39
+PHYS,2060,2019,Fall,a,F5
+PHYS,2060,2019,Fall,b,E74
+PHYS,2060,2019,Fall,c,E19
+PHYS,2060,2020,Spring,a,B22
+PHYS,2060,2020,Spring,b,B17
+PHYS,2060,2020,Fall,a,B81
+PHYS,2100,2015,Spring,a,C94
+PHYS,2100,2015,Spring,b,A12
+PHYS,2100,2016,Fall,a,F80
+PHYS,2100,2016,Fall,b,D15
+PHYS,2100,2017,Summer,a,A14
+PHYS,2100,2017,Summer,b,A37
+PHYS,2100,2017,Summer,c,C53
+PHYS,2100,2017,Fall,a,E78
+PHYS,2100,2018,Fall,a,F89
+PHYS,2100,2019,Summer,a,F31
+PHYS,2140,2015,Spring,a,C36
+PHYS,2140,2015,Spring,b,F88
+PHYS,2140,2015,Summer,a,B39
+PHYS,2140,2015,Summer,b,D100
+PHYS,2140,2015,Summer,c,C94
+PHYS,2140,2015,Fall,a,B57
+PHYS,2140,2016,Spring,a,F63
+PHYS,2140,2016,Spring,b,C8
+PHYS,2140,2016,Spring,c,B9
+PHYS,2140,2016,Summer,a,B100
+PHYS,2140,2016,Summer,b,E4
+PHYS,2140,2016,Fall,a,B8
+PHYS,2140,2017,Summer,a,F26
+PHYS,2140,2017,Fall,a,E51
+PHYS,2140,2017,Fall,b,A88
+PHYS,2140,2018,Summer,a,B61
+PHYS,2140,2018,Summer,b,C45
+PHYS,2140,2018,Fall,a,F89
+PHYS,2140,2019,Fall,a,B29
+PHYS,2140,2019,Fall,b,F27
+PHYS,2140,2020,Fall,a,F2
+PHYS,2210,2015,Fall,a,B33
+PHYS,2210,2015,Fall,b,C92
+PHYS,2210,2015,Fall,c,F36
+PHYS,2210,2017,Summer,a,E51
+PHYS,2210,2017,Summer,b,A66
+PHYS,2210,2017,Summer,c,C72
+PHYS,2210,2017,Summer,d,E37
+PHYS,2210,2018,Fall,a,F42
+PHYS,2210,2018,Fall,b,C84
+PHYS,2210,2018,Fall,c,F39
+PHYS,2210,2019,Spring,a,B8
+PHYS,2210,2019,Spring,b,E52
+PHYS,2210,2019,Spring,c,F18
+PHYS,2210,2019,Spring,d,F64
+PHYS,2210,2019,Summer,a,C54
+PHYS,2210,2019,Fall,a,E91
+PHYS,2210,2019,Fall,b,B44
+PHYS,2210,2019,Fall,c,B88
+PHYS,2210,2019,Fall,d,D86
+PHYS,2220,2015,Spring,a,E24
+PHYS,2220,2015,Fall,a,F72
+PHYS,2220,2015,Fall,b,B88
+PHYS,2220,2015,Fall,c,F12
+PHYS,2220,2016,Summer,a,D43
+PHYS,2220,2016,Fall,a,D16
+PHYS,2220,2017,Spring,a,E75
+PHYS,2220,2017,Spring,b,A61
+PHYS,2220,2017,Spring,c,E16
+PHYS,2220,2017,Spring,d,D68
+PHYS,2220,2018,Spring,a,B26
+PHYS,2220,2018,Summer,a,D19
+PHYS,2220,2018,Fall,a,A63
+PHYS,2220,2019,Spring,a,C82
+PHYS,2220,2020,Spring,a,E98
+PHYS,2220,2020,Summer,a,A17
+PHYS,2220,2020,Summer,b,F55
+PHYS,2220,2020,Fall,a,D1
+PHYS,3210,2016,Summer,a,B3
+PHYS,3210,2016,Summer,b,F94
+PHYS,3210,2016,Fall,a,C40
+PHYS,3210,2017,Summer,a,B9
+PHYS,3210,2017,Summer,b,C38
+PHYS,3210,2017,Fall,a,E44
+PHYS,3210,2018,Spring,a,B44
+PHYS,3210,2018,Spring,b,D46
+PHYS,3210,2018,Spring,c,B52
+PHYS,3210,2018,Fall,a,B94
+PHYS,3210,2019,Spring,a,A47
+PHYS,3210,2019,Spring,b,A49
+PHYS,3210,2019,Spring,c,C99
+PHYS,3210,2019,Spring,d,A77
+PHYS,3210,2019,Summer,a,F14
+PHYS,3210,2019,Summer,b,A7
+PHYS,3210,2019,Summer,c,D57
+PHYS,3210,2019,Fall,a,D90
+PHYS,3210,2020,Spring,a,F2
+PHYS,3210,2020,Summer,a,F67
+PHYS,3210,2020,Fall,a,B54
+PHYS,3210,2020,Fall,b,A66
+PHYS,3210,2020,Fall,c,A37
+PHYS,3220,2016,Summer,a,B46
+PHYS,3220,2016,Summer,b,C21
+PHYS,3220,2017,Summer,a,C31
+PHYS,3220,2017,Fall,a,A74
+PHYS,3220,2017,Fall,b,B12
+PHYS,3220,2017,Fall,c,A93
+PHYS,3220,2017,Fall,d,C83
+PHYS,3220,2018,Summer,a,C34
+PHYS,3220,2020,Spring,a,C55
+PHYS,3220,2020,Spring,b,A98
+PHYS,3220,2020,Spring,c,A18
+PHYS,3220,2020,Spring,d,B43
diff --git a/tests/integration/data/Student.csv b/tests/integration/data/Student.csv
new file mode 100644
index 000000000..bdcf87846
--- /dev/null
+++ b/tests/integration/data/Student.csv
@@ -0,0 +1,301 @@
+student_id,first_name,last_name,sex,date_of_birth,home_address,home_city,home_state,home_zip,home_phone
+100,Allison,Hill,F,1991-05-09,819 Anthony Fields Suite 083,Jacquelinebury,IN,01352,+1-542-351-1615
+101,Lindsey,Roman,F,1995-05-18,618 Courtney Tunnel Apt. 310,Kendrashire,UT,50324,(525)534-1928x327
+102,William,Bowman,M,2005-01-07,030 Morales Centers Suite 953,Randallside,IL,32826,(969)653-2871x01226
+103,Janice,Carlson,F,1989-07-16,0184 Peterson Green,North Jenniferchester,PA,67043,+1-489-325-2880x9570
+104,Sherry,Decker,F,2004-04-08,117 Spence Mountain,New Staceyville,NJ,28261,001-346-578-7133
+105,Alisha,Spencer,F,1994-03-10,031 Heath Circle,New Jasonland,NH,62454,+1-631-165-6670x106
+106,Rebecca,Rodriguez,F,1987-11-30,24731 Michelle Orchard Apt. 801,Allisonville,GA,53066,(064)746-8723
+107,Tracy,Riley,F,2005-02-24,97882 William Summit Apt. 136,Port Johnstad,MA,77004,(435)346-2475x10799
+108,Mr.,Daniel,M,1995-07-04,2784 Archer Ports Apt. 841,Taylorland,NV,36198,534.874.0164x0052
+109,Deborah,Figueroa,F,1994-05-30,12805 Hernandez Creek,Port Laura,VT,28036,586.923.2260x25634
+110,Meredith,Reyes,F,1997-03-09,75433 James Heights,Rasmussenburgh,MD,70783,001-142-940-1965x569
+111,Stephanie,Lee,F,1997-01-06,8356 Elizabeth Highway,Lake Jennifer,IA,54029,482-366-2994x68044
+112,Rachel,Lawson,F,1990-12-07,872 Campbell Prairie,Clarenceshire,IA,26601,3791769367
+113,Brittany,Watts,F,2003-02-04,632 Dominguez Lodge Suite 172,Contrerasshire,WV,58509,872-774-3487x34714
+114,Gabriella,Orozco,F,1998-11-11,2316 Amy Lakes,West Rebeccastad,TX,75957,(546)688-9373x467
+115,Gabriella,Shelton,F,1997-01-15,2980 Vargas Prairie,South Michelleville,KS,60099,646-417-0805x310
+116,Travis,Gonzalez,M,1996-07-14,19374 Jackson Place,Dannyfort,CO,03866,663.193.1491x905
+117,Mary,Jones,F,2002-05-15,7165 Poole Road,Lake Tammy,SD,71040,(945)314-7379x965
+118,Samuel,White,M,1994-03-13,9480 Lee Forest Apt. 837,Travisfort,HI,91174,957.885.6855
+119,Devin,King,M,1986-05-27,82337 Brittany Skyway,Tinafort,LA,40119,+1-240-084-2710
+120,Julie,Alexander,F,1993-08-06,711 Charles Plaza,East Annaburgh,CT,55049,+1-677-496-4990x913
+121,Deborah,Miller,F,1993-07-27,67974 Keith Gateway Suite 134,Weberfurt,MA,71877,421.024.9947x17464
+122,Johnny,Miller,M,1995-05-20,40139 Smith Spring,Johnstonmouth,MT,58464,(967)175-6551
+123,Gary,Steele,M,1987-09-04,807 Johnny Cove Suite 808,North April,MO,58440,(824)771-0932
+124,Adam,Russell,M,2000-01-14,12748 Perry Manors Apt. 782,Port William,UT,36709,840-449-9727x875
+125,Patricia,Williams,F,1988-06-19,627 Martinez Vista Apt. 171,Stephenchester,NC,20733,(459)615-8657x809
+126,Jade,Thomas,F,2004-07-08,221 Reyes Rapid Apt. 923,East Jonathan,SD,38201,759-464-7436
+127,Ashley,James,F,1997-11-27,064 Michelle Spur,Lozanomouth,VA,30663,(394)210-4709
+128,Carlos,Browning,M,1990-09-16,85884 Scott Stream,Lake Julie,CO,10370,001-368-516-0481
+129,Megan,Chambers,F,2002-09-06,137 Nicole Park Suite 317,Turnerbury,WV,40394,382-675-8692
+130,Matthew,Bass,M,1986-08-24,53773 Garcia Rapids Suite 506,Port Stacy,CA,28302,5329318393
+131,David,Schroeder,M,1998-03-28,22842 Michelle Crescent Apt. 395,East Davidbury,AR,59257,(178)390-8470x0766
+132,John,Browning,M,1989-10-24,1249 Kelley Heights,Schmidtview,CO,92484,+1-836-736-5766x1565
+133,Brittany,Leblanc,F,2002-04-29,15280 Hoffman Highway Apt. 560,Burkeborough,GA,86580,(158)514-9368
+134,Dr.,Louis,M,1993-03-28,402 Kathryn Valleys Apt. 229,Chadmouth,CA,70032,752-545-9910x2290
+135,Denise,Stanley,F,1993-02-08,81561 Erika Meadow,Brandonbury,AL,40008,+1-445-107-6226x838
+136,Michael,Gomez,M,1994-03-14,7159 Richard Port Apt. 605,Port Stevechester,MI,14376,681-645-3521x81883
+137,Hannah,Luna,F,1996-11-30,24329 Katherine Circles Suite 779,Coleside,NY,82358,+1-527-177-4490x5814
+138,Anthony,Decker,M,1997-08-09,998 Betty Villages Suite 079,Marcport,AR,14067,001-182-037-7889x255
+139,George,Harper,M,1988-10-20,18644 Douglas Underpass Suite 519,Sabrinaburgh,NC,17402,652.816.8505
+140,Tiffany,Peterson,F,1998-09-26,214 Garcia Springs,Stephensontown,RI,17677,292-706-5379
+141,Nicole,Cole,F,1990-08-18,735 Hudson Loaf,Stricklandport,DC,26675,+1-075-818-1412x4782
+142,Susan,Velasquez,F,1986-02-05,6853 Christopher Flat Apt. 152,West Mariachester,OH,59300,001-043-289-8614x341
+143,Jennifer,Bauer,F,1988-10-31,980 Andrews Roads,North Michael,FL,88085,(518)888-8067x06540
+144,Austin,Allen,M,2001-06-29,5205 Li Drives,Marshallchester,SD,08771,3030548687
+145,Nicole,Lee,F,2000-05-12,541 Kim Knoll Apt. 652,South Sandra,SC,95801,9284511544
+146,Michelle,Jackson,F,2000-10-29,596 Tina Village,New Michaelfort,WV,19215,1355690927
+147,Jacqueline,Hines,F,2001-04-19,4310 Porter Junctions Suite 447,New Heathershire,CT,10207,(715)518-8442
+148,Timothy,Little,M,1988-06-05,32370 Ashley Loop Suite 291,West Jenniferport,MD,75854,517-785-2892
+149,Carl,Shaw,M,1991-08-28,4225 Perez Village Suite 414,Port Joshuastad,CA,84516,922.995.9001x094
+150,Randall,Butler,M,1996-10-13,4473 Cohen Green,North Scottport,NJ,41471,001-562-588-1537
+151,Jerry,Thomas,M,1994-02-09,632 Peck Roads Apt. 278,Port Tyler,MD,60431,(500)479-7480
+152,Jessica,Khan,F,2004-11-24,6098 Angela Circles Suite 849,Davidshire,SC,44945,001-239-868-0002x578
+153,Jordan,Hicks,M,2005-10-09,0551 Silva Squares Suite 097,New Teresa,HI,07232,(896)230-9130x7562
+154,Christina,Shaw,F,1994-11-30,028 Mark Prairie,Leeville,KY,46938,334.843.4437x5758
+155,Robert,Hill,M,1994-01-22,6524 Stephanie Cliff Suite 473,South Sarahchester,NM,77418,833.016.5712
+156,Krista,Hickman,F,1987-02-26,734 Debbie Union Apt. 938,Melissatown,MA,23541,001-672-400-4991x547
+157,Teresa,Rosales,F,1997-01-28,27420 Gibbs Parks,Thompsonhaven,TN,68039,122-753-0463
+158,Debra,Rivera,F,1998-08-19,53017 Richard Mills Suite 414,East Susan,MN,79896,878-339-1878x51910
+159,Stephanie,Harris,F,2001-08-26,713 Burns Turnpike,North David,NV,73743,406.403.9106x51801
+160,John,Mitchell,M,1986-09-10,656 Sally Isle Apt. 825,Port Phillipland,TN,99614,001-786-863-3752x431
+161,Timothy,Small,M,2005-07-09,7903 Morales Ford,Port Brianport,SD,96382,953.428.3644
+162,Jamie,Webster,F,1998-10-02,27086 Grant Crest Apt. 351,Booneton,FL,35688,901.398.3735x40331
+163,Paul,Rocha,M,1987-06-23,3854 Amanda Island Apt. 877,Port Terrancefort,LA,54755,320.489.9642x353
+164,Sandra,Porter,F,1993-10-17,77725 Jennifer Meadow Suite 808,Lake Sierrafurt,MA,83168,2038750997
+165,Alexis,Patel,F,2003-10-31,840 Wolfe Lane,Whiteside,ID,81736,546.156.7933
+166,Jonathan,Hamilton,M,1986-06-14,180 Rachel Rest Suite 401,Juanmouth,FL,41721,001-926-142-9396x856
+167,William,Brown,M,1988-06-02,9965 Joshua Well Apt. 586,New Donna,NM,32803,262-655-1104
+168,Philip,Garcia,M,2004-12-15,8610 Angela Pine,Shieldstown,RI,95507,001-398-262-2444x721
+169,Desiree,Evans,F,2000-07-27,799 Daniel Grove,Cookstad,KS,44375,+1-924-593-7526x5479
+170,Erika,Ramirez,F,1999-11-03,398 Katrina Burg,Sherryville,TN,09565,243.426.6179x79688
+171,Sergio,Barnes,M,1989-07-10,891 John Prairie Apt. 909,Byrdbury,WI,56921,4388899375
+172,Patricia,Chapman,F,2001-04-24,14611 Cross Inlet,Lake Adriana,CA,95134,401.051.2382
+173,Gary,Simmons,M,1992-04-12,2660 Ware Locks Apt. 033,New Laura,SC,70872,371-478-5969x6915
+174,Jimmy,Thompson,M,1991-10-25,912 John Cove Apt. 286,North Patrick,NY,91390,(742)257-9050x72368
+175,Jon,Cohen,M,2004-05-12,1903 Joshua Mountains Apt. 797,Danielland,SD,48586,+1-078-361-3407x4517
+176,Autumn,Cain,F,2003-06-04,962 Glover Stravenue Suite 958,South Mario,IN,35542,001-126-042-2325x367
+177,Mark,Brooks,M,1999-06-14,684 Wiley Locks Apt. 901,Stephenfurt,AR,70549,(637)454-5892
+178,Karina,Cooper,F,1989-02-04,70127 Victoria Lane,Blankenshiphaven,UT,36417,415.206.4361x10371
+179,Courtney,Frazier,F,2005-01-31,627 Patrick Row Apt. 554,Lake Karenland,DE,70035,2753269731
+180,Charles,Martinez,M,2003-07-15,2341 Carolyn Roads,Port Anthony,UT,27429,364.037.6137x9180
+181,Timothy,Anderson,M,2000-05-01,710 Smith Field,Frybury,OK,54952,+1-188-924-1418
+182,William,Moore,M,1990-08-03,146 Mathis Center Apt. 617,Brianfurt,DC,02161,+1-275-884-2524
+183,Bruce,Yoder,M,1989-11-04,4917 Michael Mill,Michaelberg,NH,95237,(800)030-7562
+184,Toni,Johnson,F,1996-06-28,3536 Flores Stream Suite 180,Lake Tinashire,MN,37503,870-534-9493x759
+185,Dr.,Patty,F,1989-01-31,60385 Steele Branch Apt. 641,Port Robertshire,DE,37178,3865719182
+186,James,Vargas,M,1996-05-29,44565 Joseph Circles Apt. 912,South Leeland,RI,59734,(112)490-3521x356
+187,Amy,Norman,F,1987-05-16,1994 Jones Wells,New Lisaton,SD,16560,001-029-667-0662x532
+188,Sophia,Johnson,F,1998-02-20,68701 Derrick Extensions,Foxstad,SC,50635,(759)856-4205x930
+189,Whitney,Robinson,F,2002-08-10,2239 Joanna Island Suite 599,Port Maryfort,NE,23511,0393087059
+190,Teresa,Foster,F,1995-12-10,26752 Hoffman Tunnel,Michaelfurt,ME,96707,096-902-9593
+191,Brian,Crawford,M,2000-01-03,5215 Joseph Forges,East Danieltown,OR,22303,(658)617-9327x1040
+192,Trevor,Jones,M,1992-05-20,815 Austin Manors,Port Frederickhaven,CO,27442,884-443-1069x87205
+193,Brandon,Colon,M,1998-06-27,32417 Parker Keys,New Christopher,FL,50497,(047)743-4902
+194,Michael,Miller,M,2005-05-13,938 Paul Mount Suite 793,North Raven,MO,68241,921.722.3320x61632
+195,Lisa,Mills,F,1987-03-12,99119 Floyd Track,Humphreyburgh,NH,62504,(629)960-6530
+196,Thomas,Prince,M,2003-06-14,47132 Julia Springs Apt. 691,East Madisonmouth,UT,07868,+1-148-628-9023x303
+197,Anthony,Ward,M,1988-12-29,6103 Brooke Drives,Matthewsborough,VT,98668,602.933.3346
+198,Sharon,Coffey,F,2001-10-19,29034 Hahn Road,Joshuaside,MN,29102,896.910.8589
+199,Edwin,Rodriguez,M,1999-09-08,4443 Kathy Turnpike Suite 965,Jenniferfurt,IL,55363,099-353-8758x4282
+200,John,Figueroa,M,1988-05-05,513 Julie Groves Suite 554,Stevenland,NY,76563,(381)684-6022x356
+201,Stephanie,Hatfield,F,2000-07-12,52500 Jason Springs,Ericmouth,CT,57348,760-083-5058x30033
+202,Gregory,Anderson,M,1990-05-20,04478 Morgan Tunnel Suite 575,Martinside,AL,29903,(098)215-0648
+203,Linda,Williams,F,2003-04-29,16761 Wells Dale Suite 046,Elaineburgh,CT,14252,+1-141-173-9348
+204,Mr.,Jason,M,1995-12-29,753 Emily Union Suite 721,Joneschester,NY,60368,012.045.5611
+205,Stefanie,Smith,F,1991-05-06,79415 White Knoll Suite 467,Banksfort,OH,08187,979-729-6590
+206,Sheryl,Acosta,F,1997-06-06,6701 Leon River,Katrinamouth,WI,88298,(916)375-6289x0028
+207,Samuel,Booth,M,2002-11-04,40838 Powell Ford,Lake Shane,MI,16060,001-016-608-8019
+208,Miss,Stefanie,F,1998-01-01,0375 Harvey Mall,Jenniferland,HI,45243,+1-488-510-2726x1493
+209,Tara,Long,F,2005-10-29,160 Monroe Path Suite 779,Taylorport,AZ,57230,(829)221-6995x8669
+210,Stacey,Hunt,F,2000-02-15,83339 Parks Valleys Apt. 288,Marcusland,MS,75295,846.081.0620x03424
+211,Brianna,Brown,F,1987-07-09,5719 Stevenson Trace,Annaberg,SC,38202,001-665-800-4397x359
+212,Craig,Hardy,M,1991-03-10,122 Wilson Camp,East Eugene,AL,61623,5909479851
+213,Evan,Robinson,M,1986-03-21,6886 Jeffrey Field,West Jeffery,NE,74076,573-993-0561
+214,Carol,Huber,F,1997-03-16,36138 Johns Run,Lake Charles,AK,94462,1024819346
+215,Mark,Hamilton,M,2004-01-26,9190 Jones Via Apt. 491,Port Patrick,AK,20990,(684)245-0882
+216,Aaron,Carlson,M,1988-03-18,53682 Jeffrey Street Apt. 290,Randolphshire,NV,38597,397.552.3149
+217,Cheryl,Tucker,F,1998-02-15,299 Leslie Lane Apt. 336,West Erin,MS,58874,+1-781-291-4283x411
+218,Sarah,Welch,F,1998-04-20,308 Patricia Mountains Suite 256,Lake Jessicaburgh,MT,52508,(392)827-2299x2750
+219,Katherine,Brown,F,1991-11-01,56770 Deborah Course,Schultzburgh,NH,75233,659-184-6386x5577
+220,Adriana,Macias,F,1993-02-01,4322 Carolyn Stravenue,Robertborough,ND,63287,603.029.9228x092
+221,Roberto,Valentine,M,1990-06-02,7236 Norton Stravenue Apt. 842,Matthewview,HI,51024,388-629-1279
+222,Sherry,Schmidt,F,2005-07-09,9806 Wood Camp,Jeromefort,ME,77708,247-314-9864
+223,Michelle,Clarke,F,1992-11-06,35651 Denise Fork,Hendersonborough,ND,99456,872-588-7449x56213
+224,Melissa,Martin,F,1988-08-22,8902 Cynthia Squares,Ruizstad,IL,49107,669.849.0277x0384
+225,Richard,Dixon,M,2005-10-02,530 Miller Gardens Apt. 669,North Janeside,OR,73785,439-376-9042x681
+226,Kathy,Morgan,F,1993-09-28,89476 Carrillo Shores Suite 779,Olsonberg,SC,29386,+1-658-804-3416x5182
+227,Hayden,Shannon,M,1987-05-11,373 John Fort Apt. 395,North Samanthafurt,NM,71473,+1-595-794-7284x6392
+228,Jay,Ayers,M,1994-11-11,271 Stevens Rest,East Biancaborough,IL,72402,(795)527-6365
+229,Jennifer,Hayes,F,1996-02-16,143 Chase Extensions Suite 270,South Wendyhaven,OK,64283,906.120.3471
+230,Felicia,Ward,F,2001-09-12,06159 Barbara Ports Apt. 455,Tonychester,ME,38056,225.699.6112x5355
+231,Michael,Jacobs,M,2003-10-01,598 Gutierrez Estates Apt. 341,West Codyside,AZ,52538,+1-114-921-6433x472
+232,Ryan,Johnson,M,1988-12-19,77848 Tara Ridge Apt. 979,New Amanda,MS,30271,(564)240-0825x478
+233,Thomas,Arroyo,M,1994-11-13,4930 Lopez Trail,East Jennifer,TN,29414,3894484631
+234,Dylan,Walsh,M,1993-04-23,3502 Amanda Estates,East Jenniferchester,DE,65195,475-705-1204x618
+235,Corey,Skinner,M,2003-08-24,36730 Jill Corner Suite 376,Larryborough,AZ,72535,743-503-1365
+236,Rebecca,Richards,F,1987-12-15,979 Kelli Forge,New Matthew,PA,08372,281-273-5857x306
+237,Brandy,Roach,F,1994-11-17,73928 Jessica Garden,Rochamouth,DE,39255,(708)620-9593x51863
+238,Kathleen,Arnold,F,2003-10-23,1181 Sharon Estate,North Jamestown,ME,64714,940.539.1037x1705
+239,Teresa,Perry,F,1992-01-03,480 Davenport Cliff Apt. 811,Amandaville,ID,82463,(861)957-6122x86852
+240,Krista,Garner,F,1995-04-23,004 Holmes Well,West Jeffrey,AK,90903,001-889-921-0752x245
+241,Danielle,Scott,F,2000-02-03,3157 Margaret Rest Suite 194,Lake Patrickmouth,KY,57426,001-139-060-4805x892
+242,Connie,Williams,F,2000-09-13,9981 Keith Key,North Ashleytown,CA,66275,+1-227-837-6938x983
+243,Deborah,Jordan,F,1988-11-02,66553 Brittney Brooks Apt. 597,Scottside,ND,20947,039-240-5147
+244,Evelyn,Singh,F,1986-03-15,879 Thomas Ridges Apt. 980,North James,IL,61444,4510463681
+245,Kari,Harper,F,2002-12-22,800 Alyssa Hill,East Michael,NM,31460,046.084.3256
+246,Jessica,Edwards,F,1988-03-23,29832 Janet Mount,Port Theresaland,VA,42115,(125)205-6647x42312
+247,Pamela,Salazar,F,1995-02-06,33051 Woods Mills Suite 526,North James,PA,02468,001-333-127-9757x366
+248,Roger,Cortez,M,1992-05-18,8808 Stephen Trail Suite 388,Lake Angela,NY,06962,644.726.4908
+249,Julie,Lucas,F,1989-01-08,98266 Angel Locks Suite 371,New Rebecca,OK,16694,751-868-9268
+250,Patricia,Barr,F,2002-09-16,22064 Kayla Lock Suite 123,Lake Alexanderport,SD,80190,(977)671-9903
+251,Donald,Fuller,M,2005-05-23,05020 Massey Greens,Williamsbury,ND,80597,+1-279-501-4556x168
+252,John,Martinez,M,2000-06-13,3390 Jessica Plaza,Webbchester,WY,38143,548.995.2997x8772
+253,Crystal,Roberts,F,1996-02-19,1396 Matthew Park,Alexville,SC,40841,(501)556-9902x3557
+254,Rebecca,Brewer,F,1988-03-04,857 Gutierrez Shoal Suite 495,Andrewmouth,VA,46847,001-405-682-9962x914
+255,Brandon,Wiley,M,2003-06-25,84215 Strickland Unions Apt. 078,West Timothyhaven,KS,13379,230.768.1040x91570
+256,Pamela,Reese,F,2004-08-11,3533 Amanda Springs Suite 422,North Cindy,GA,46417,249.321.4958
+257,Carlos,Ruiz,M,2001-10-06,66299 Vaughn Lock,West James,SD,10796,171.747.7332x945
+258,Michael,Ortega,M,1996-03-13,0171 Steven Drive Suite 992,Richardchester,NV,09797,(696)393-8276x15396
+259,Jessica,Cobb,F,1998-10-24,1971 Ford Oval,Thompsonshire,CO,78673,013-290-2278x469
+260,Christina,Maldonado,F,1989-08-26,465 Aguilar Plain Suite 240,South Brian,SD,47587,+1-036-965-6666x8327
+261,Janice,Middleton,F,2001-06-08,220 Alfred Roads,South Veronica,NY,55008,001-969-278-6876x532
+262,Adam,Jimenez,M,1988-12-05,89500 Bush Courts Apt. 128,Terrellmouth,AR,80464,189.490.5807
+263,Taylor,Berry,M,1995-11-05,442 Sandra Shoals,Anneton,DC,07266,+1-904-712-8144x2944
+264,Adrian,Rodriguez,M,2000-11-23,75243 Lauren Throughway Apt. 129,Mooreport,RI,31689,001-239-504-1027
+265,Eric,Reese,M,1995-03-12,6742 Graham Glen Suite 658,Blakeside,WV,57096,414-967-3938x525
+266,Michael,Decker,M,1990-01-01,75344 Andrew Common,Douglasfort,NY,93309,926-921-2447
+267,Robin,Thompson,F,1985-12-12,62712 Reynolds Plains Apt. 741,North Jessicamouth,MO,86073,001-642-569-0877x661
+268,Janice,Norris,F,1992-10-30,5546 Wendy Port,Lake Matthew,PA,38506,(063)461-5717
+269,Charles,Lee,M,2001-07-07,1847 Flowers Locks Suite 050,Lake Richard,NC,69067,001-829-310-2707x903
+270,Mark,Conway,M,1990-01-11,9111 Lauren Fields,Simmonsfort,ND,42999,001-982-530-9251x142
+271,Ann,Pearson,F,1996-03-02,723 Joseph Locks,East Heatherstad,NM,12038,083-318-1958x837
+272,Mary,Hill,F,1991-11-27,772 Sandra Causeway Apt. 364,Lake Katherine,OR,70933,078-113-7995
+273,Nicole,Villanueva,F,1992-07-11,36363 Brenda Causeway,East Chelsea,ME,60497,435.209.0421x7762
+274,Daniel,Phillips,M,2000-09-10,298 Miller Terrace Apt. 397,Ramirezchester,ID,43400,929.060.0780x686
+275,Rebecca,Nicholson,F,2001-09-12,0632 John Wells,New Evanview,NH,60117,+1-625-701-6580x464
+276,Logan,Johnston,M,1994-01-14,5085 Rodriguez Islands Suite 552,Janetmouth,DE,44400,(793)355-4864x01557
+277,Kelsey,Martinez,F,1990-12-14,4795 Dougherty Station Suite 137,West Haroldshire,DC,15184,(380)468-2756x7043
+278,John,Wade,M,1991-11-20,9242 Perez Islands Apt. 025,Port Christine,NE,24392,+1-223-105-9274x5238
+279,Mary,Spence,F,1995-12-23,841 Sullivan Mill,South Luketown,WI,43922,(492)975-1702x814
+280,Lisa,Robinson,F,1996-09-24,3983 Wang Extensions,Lake Ericashire,MD,64787,805.626.5650x4554
+281,Shannon,Miller,M,1998-09-15,426 Perry Street Suite 234,Port Valerie,WV,99606,646-287-9232
+282,Donna,Henry,F,1992-01-09,7873 Aaron Fort,Flowersview,VT,55178,(301)471-9597x9647
+283,Dr.,Jacqueline,F,2003-05-28,2572 Brian Island,Stephanietown,NY,10570,(219)285-5445
+284,Lauren,Morrow,F,1989-11-19,7652 Eric Fields Apt. 898,Marquezchester,MA,10514,+1-075-452-7985x2401
+285,Shannon,Thomas,F,1996-03-07,16110 Todd Camp,Lake Williamton,ID,09184,119.393.2501x24955
+286,Kathryn,Chandler,F,1992-01-27,90833 Jackson Shore Apt. 138,Wellschester,ND,14568,+1-663-836-1517x1827
+287,Michele,Hawkins,F,1992-01-08,47947 Richard Way,Lake Patricia,WA,48662,7167811266
+288,William,Figueroa,M,1999-07-16,3539 Powell Ford,South Kathy,NJ,99631,967-842-7114x773
+289,Chad,Garcia,M,2002-11-10,269 Hernandez Plains,North Karenmouth,GA,87282,(485)880-0616x7567
+290,Andrew,Hawkins,M,1991-03-28,762 Paul Skyway,Tracymouth,MN,74196,(647)969-5450x0902
+291,Hannah,Harmon,F,1987-03-11,1655 Brian Forest Apt. 491,Jonesburgh,AK,43245,(698)640-7905x696
+292,Brent,Freeman,M,1996-01-14,5294 Ryan Mews,Cobbfort,IN,06731,001-639-191-9541x987
+293,Angela,Colon,F,1993-03-01,5366 Zachary Ramp,Nicolestad,FL,65932,748.969.0835x72324
+294,Alexis,Robles,M,1986-08-06,603 Derek Forks,Hopkinsville,WI,64181,1594165162
+295,Laura,Mason,F,1994-07-28,8471 David Station Apt. 963,Robinsonland,IN,54027,+1-078-515-8673x4257
+296,Alex,Rasmussen,M,1996-02-27,0348 Danielle Ridges Suite 183,Priceside,WI,33994,343-275-6041
+297,Todd,Ruiz,M,1999-07-21,124 Bell Pines Suite 570,Davidsonville,NY,00904,(459)112-3829
+298,Ricky,Flores,M,1992-08-31,95431 Hunter Trail Suite 930,Leblancfurt,VA,61111,206.969.4215
+299,Keith,Smith,M,1992-01-21,713 Lee Throughway Suite 476,Lake Carolshire,ND,55332,204-439-7359x71072
+300,William,Sanders,M,1987-06-20,9411 Williams Viaduct,West Catherine,SC,93505,8964652809
+301,Christopher,Vasquez,M,1994-11-23,86241 Tiffany Mill,Campbellborough,VA,35001,(625)728-7032x0320
+302,Carla,Mcdonald,F,2005-11-05,7587 Daniel Roads Apt. 513,Whiteville,IL,87419,(089)261-3715
+303,Melanie,Becker,F,2005-04-14,520 Mariah Prairie Apt. 490,North Cindy,WV,96749,045-018-9616
+304,David,Wise,M,2003-05-13,66421 Laurie Rue,Mckeestad,CA,48664,(767)499-6165
+305,Jessica,Simmons,F,1994-05-19,3278 Warren Glens,Port Tim,CT,39876,(490)810-8186x61794
+306,Lauren,Mack,F,1994-09-28,2601 Janet Harbor Suite 794,Port Lisa,AR,79675,+1-168-006-1027x7697
+307,Valerie,Ward,F,1988-11-06,4122 Daniel Bridge Suite 037,Debraview,SC,25524,727.601.2277
+308,Scott,Richards,M,2002-07-09,050 Melanie Light Apt. 799,Yolandatown,MT,95477,(080)695-8146
+309,Audrey,Dean,F,1995-11-26,2437 Jesse Fields,Morganstad,NC,17692,001-665-729-3417
+310,Christina,Obrien,F,1997-05-30,433 Kidd Island,New Gregg,MO,08845,931-837-4550x84289
+311,Michael,House,M,1991-04-06,119 Garrison Corners,Williamville,GA,47901,001-787-125-5213
+312,Jennifer,Mack,F,1998-03-25,8214 Kari Island Suite 286,Taylorview,VT,68154,001-720-811-5562x606
+313,Margaret,Orr,F,1992-11-24,846 Erin Oval Apt. 550,Mcculloughstad,MD,84895,001-997-563-4108x562
+314,Kimberly,Lewis,F,2003-03-10,2008 Allen Springs,Valerieland,ME,82681,017-490-7539x989
+315,Elizabeth,Estrada,F,1999-08-16,68315 Lee Spur Apt. 266,North Pamelaport,LA,69478,864.976.7762x282
+316,Judith,Faulkner,F,1995-12-03,770 Raymond Islands Suite 961,New Billyland,WY,40249,(229)604-4327x0185
+317,Amanda,Olson,F,1999-11-09,6792 Wagner Lodge,South Michelle,SC,87598,658-074-1209x4818
+318,Tina,Weaver,F,1997-06-27,7801 Schmidt Vista Apt. 339,Lake Catherine,AZ,03550,608-564-1118x24224
+319,Christian,Farley,M,2005-11-10,200 Corey Crossroad,Scottside,AZ,31908,(886)140-5786
+320,Sarah,Mason,F,2002-04-29,2386 Peters Camp,Woodwardstad,DC,08388,465.398.4028
+321,Elizabeth,Foster,F,1996-11-11,4639 Pham Trail,Reidshire,IL,87306,795-020-9700x268
+322,Michele,Farmer,F,2001-01-17,1807 Gomez Station Suite 562,Cainshire,LA,25796,0453194337
+323,Mr.,Johnathan,M,1988-02-18,614 Snyder Oval,Arielfurt,AR,17310,938-430-8948
+324,Aaron,Simmons,M,2005-05-17,566 Erin Lodge Apt. 030,West Shane,FL,11223,+1-361-332-5411x0760
+325,Mark,Cook,M,1998-10-05,50583 Parsons Plains,Garrettmouth,AR,04871,120.704.9611
+326,Kristin,Phillips,F,2003-07-08,399 Patrick Square,Harveyborough,RI,60017,311-091-9392x845
+327,Nathaniel,Wallace,M,2003-03-05,49685 Nicole Springs Apt. 495,Port Zachary,DE,31615,+1-806-533-3153x7795
+328,Kylie,Rogers,F,1992-03-09,07303 Owens Ferry,Lake Lisa,ME,52970,+1-050-150-8124x7395
+329,Allen,Gonzalez,M,1998-08-03,583 Andrew Streets Suite 026,Nicoleborough,MN,48950,896.112.2338x65596
+330,David,Williams,M,2003-03-30,530 Ramirez Creek Suite 973,Kristenfort,DC,51372,872-558-7774x9690
+331,Stephanie,Hayes,F,2000-06-01,6925 Christopher Shore,South Jerry,MT,44590,(665)754-6027x341
+332,Bradley,Kirby,M,2004-05-25,311 Benjamin Fall Apt. 544,Kaylahaven,NJ,18571,001-044-566-9078x263
+333,Paul,Wells,M,1986-04-01,751 Jacob Springs Suite 377,Johnsonland,IA,97206,(553)666-8459x0902
+334,Troy,Rivera,M,1988-04-13,6636 Paul Mall Apt. 741,New Gregoryfort,AK,26584,001-643-348-1705x802
+335,Michelle,Wells,F,2001-06-11,8743 Douglas Centers Apt. 385,Suarezview,OR,38238,469-263-2967x629
+336,Michael,Williams,M,2003-01-30,841 Bowen Field,Port Angela,AR,14292,+1-567-243-8070x176
+337,Jennifer,Lee,F,1989-05-04,257 Carlos Orchard,Port Donaldfort,DC,02868,(186)210-4275
+338,Michelle,Stafford,F,1986-11-14,81647 Adam Springs,Mcfarlandbury,CA,55771,001-531-312-2068x155
+339,Taylor,Foster,F,1996-03-06,52065 Jason Fields,Joshuastad,VT,54384,+1-718-924-1956x252
+340,Stephen,Stewart,M,2000-07-01,9976 Harmon Mills,Alexandertown,CT,31485,001-910-257-4326
+341,Amanda,Mclean,F,1993-06-27,524 Kristin Bypass Suite 640,Lake Matthewville,VA,33051,685.270.1713x0232
+342,Christina,Coleman,F,1986-08-05,3471 Ward Isle,West Chelsea,DE,63677,+1-614-982-8246x747
+343,Kristina,Castillo,F,1999-01-05,30085 Sara Views Suite 567,Port Charles,WY,16816,001-236-458-7506x633
+344,Robert,Mccoy,M,1992-05-05,4972 Carrie Villages Suite 011,Sabrinabury,VT,68466,+1-264-488-6946x1195
+345,Daniel,Goodman,M,2005-03-19,70116 Pena Row,West Janeville,WV,59570,+1-230-234-6791x2141
+346,Destiny,Peterson,F,1994-12-18,100 Stephanie Prairie,Williamsberg,ME,68668,001-759-655-5535x669
+347,Shane,Drake,M,1999-12-23,209 Alyssa Village,Wrightview,UT,67991,050.505.7397x69156
+348,Todd,Alvarez,M,2001-02-07,64932 Walter Spurs Suite 027,Turnerfurt,UT,22528,001-783-332-1160x256
+349,Greg,Kent,M,1988-01-10,8633 Kelly Courts Apt. 931,Davidburgh,OR,41238,366.552.8993x160
+350,Nicole,Sweeney,F,1993-07-30,81497 Lewis Glens,Brownfort,OK,96531,+1-027-642-0865
+351,John,Bailey,M,2005-07-22,438 David Shore,Lindahaven,MN,21956,742-333-0591
+352,Kara,Landry,F,1986-04-25,6263 John Meadow Suite 261,Hancockfurt,NC,48646,117-830-9997
+353,Nichole,Bauer,F,2003-12-15,6492 Bryan Union,Lopezfort,NV,70810,(898)131-2920x8751
+354,Kenneth,Delgado,M,2004-02-03,118 Tammy Drive,Barrettberg,WV,38957,(975)859-8831x030
+355,Jennifer,Pierce,F,1998-10-24,71462 Jones Row Suite 359,Loristad,DE,57337,9314181861
+356,Brandon,Blankenship,M,1989-03-03,401 Tanya Isle,Port Gregorychester,SD,64676,(948)491-0256x25889
+357,Jennifer,Vargas,F,1995-04-21,226 Adams Valley Suite 539,South Scott,MN,38095,001-834-146-5111x312
+358,Patrick,Spencer,M,1997-08-29,682 Zachary Wells Suite 160,Rhondamouth,OH,98761,890.972.8321
+359,Casey,Gomez,M,1987-02-15,15381 Timothy Fort,New Phillipside,WV,68072,001-970-509-7545x105
+360,Adam,Jordan,M,1991-06-05,617 Kayla Forges Apt. 545,East Lisa,MI,58088,605-313-4026
+361,Erin,Johnson,F,1993-12-19,416 Tyler Rapid Apt. 686,Port Lauraland,AL,90211,5690674471
+362,Danielle,Hernandez,F,1990-12-24,436 Jasmine Station,Wayneville,NJ,83663,(260)432-6093
+363,Anthony,Russell,M,1995-08-17,56708 Brett Court Apt. 563,North Blake,OR,28285,(916)247-5541x108
+364,Carlos,Ward,M,1988-06-19,9534 Patrick Tunnel Apt. 910,Rhondafurt,OH,13429,001-954-738-2023x684
+365,James,Lawson,M,1994-01-09,9087 Le Forks,Phillipsburgh,HI,70436,242.403.3810
+366,Mackenzie,Compton,F,1989-07-16,426 Phillips Way Suite 053,Joshuaberg,NC,76950,001-649-837-3543
+367,Robert,Mullins,M,1996-06-21,527 Hunter Estates,Lopezport,NC,03259,(269)312-1637
+368,Tracy,Garcia,F,1989-07-15,916 Daniel Bridge Suite 023,Adamsside,SC,01732,(513)279-7245x72308
+369,Mark,Martinez,M,2002-08-27,86203 Ronald Curve,Jeremiahhaven,VT,15234,(131)451-9515
+370,Thomas,Huang,M,1988-07-08,9262 Mcdaniel Plaza,Port Joseph,LA,35287,+1-225-267-7119x642
+371,Wendy,White,F,1988-10-06,6952 Valdez Forge,South Amanda,SD,50914,689.313.5030x587
+372,Tammie,Brown,F,1998-07-26,247 Melissa Walk Suite 333,North Suzannechester,AK,56168,1917920252
+373,Angela,Carroll,F,1986-04-16,28476 Wallace Port,North Brianfurt,DC,21518,678-498-4362x4186
+374,Beth,Lewis,F,1995-02-07,891 Mcdonald Harbor,Margaretville,NY,26024,159-503-4281
+375,Linda,Avila,F,1999-03-18,0341 Cunningham Park Suite 005,West Tinamouth,MO,41719,001-215-681-8209
+376,John,Melton,M,2003-09-22,113 Aguirre Ports,Martinshire,OR,85880,001-572-545-9606x339
+377,Brittany,Burton,F,1990-09-12,48171 Geoffrey Green Apt. 955,East Kelseyberg,IL,58440,001-970-546-6927x589
+378,Michael,Hunter,M,2001-11-10,903 Castro Dale Apt. 629,North Paul,CA,61564,711.216.6365x15597
+379,Natalie,Wilson,F,1988-10-06,235 Huerta Springs Apt. 567,East Andrewmouth,ID,23583,461-476-8342
+380,Anna,Valenzuela,F,1996-12-07,56778 Martin Ridge Apt. 960,Patriciaville,NH,19456,502.727.5164x80727
+381,Kenneth,Johnson,M,2003-01-01,296 Jason Extension,Stephaniebury,IA,40735,+1-177-665-5868x5127
+382,Christopher,Larson,M,2004-06-14,649 Bullock Corners,Lake Christophertown,CO,98797,789-046-3378
+383,Christina,Harrison,F,2003-07-30,660 Casey Mission Apt. 446,Adamside,AK,49575,+1-955-296-3863x9609
+384,Todd,Myers,M,1989-02-03,26312 Welch Spurs,Burtonberg,WV,27208,609-209-8196
+385,Morgan,Lucero,F,1990-02-03,34383 Roman Isle Apt. 041,Burtonfurt,CO,60679,442-117-5361
+386,Joanne,Martin,F,1993-04-12,9015 Webb Plains Suite 284,Leetown,MT,20469,+1-130-523-1244x7315
+387,John,Lamb,M,1996-10-06,423 Clay Gateway Apt. 994,East Jenniferview,NJ,36109,966.395.5172x0849
+388,Charlene,Sanchez,F,1989-06-03,51050 Lewis Parks,East Carl,GA,29004,919.665.5330x770
+389,Jennifer,Martinez,F,2001-11-27,4090 Mitchell Streets,Port Samantha,NY,09604,644-556-1857
+390,Jennifer,Horton,F,1987-09-15,159 Jeffrey Stream Apt. 563,East Rachelbury,WY,90710,010.414.5964
+391,Tammy,Silva,F,1988-09-26,96718 Lane Prairie,Morrischester,IL,39329,331-170-3037x637
+392,Daniel,Garza,M,2005-07-23,472 Garcia Crescent Suite 679,Kimberlyville,DC,40759,271.130.7240x78754
+393,Krista,Gomez,F,2002-09-18,5074 Brandon Junction,Leeville,IN,80120,(103)131-0094x3181
+394,Sonya,Lyons,F,1994-01-14,47323 Keith Pine,Clintonport,MS,40520,(122)572-0765
+395,William,Ibarra,M,2001-04-27,57907 Kennedy Canyon Apt. 438,Karimouth,SC,44498,(584)745-7054x5897
+396,Michael,Chandler,M,2001-03-16,257 Becky Ridge Apt. 313,Grayland,NM,71924,001-824-556-9644x309
+397,Barbara,Pope,F,1990-02-13,1072 Edward Vista Suite 247,Lake Alexis,IN,78236,4065004254
+398,Jonathan,Mullen,M,1991-10-25,236 Miller Fields Apt. 536,Port Corey,IA,41229,592.342.6834x414
+399,Lori,Gardner,F,1996-03-17,2875 Jennings Island Apt. 766,Port Anthony,CA,18927,+1-985-298-9406x260
diff --git a/tests/integration/data/StudentMajor.csv b/tests/integration/data/StudentMajor.csv
new file mode 100644
index 000000000..644a46492
--- /dev/null
+++ b/tests/integration/data/StudentMajor.csv
@@ -0,0 +1,227 @@
+student_id,dept,declare_date
+100,BIOL,2010-01-10
+102,CS,2019-01-13
+103,PHYS,2018-10-04
+104,CS,2010-11-04
+105,CS,2018-11-20
+107,MATH,2020-01-04
+108,PHYS,2012-09-26
+111,MATH,2001-04-19
+112,MATH,2000-07-12
+113,PHYS,2000-01-02
+114,MATH,2004-06-01
+115,BIOL,2006-11-19
+116,CS,2002-04-14
+117,PHYS,2002-08-13
+118,CS,2015-12-29
+120,MATH,2015-03-18
+121,BIOL,2010-01-05
+122,MATH,2006-11-17
+123,PHYS,2007-01-19
+124,MATH,2002-08-03
+125,CS,2004-12-02
+126,PHYS,2012-01-26
+127,CS,2013-04-17
+128,MATH,2001-03-10
+129,BIOL,2001-02-08
+130,CS,2019-10-27
+131,MATH,2007-07-10
+132,PHYS,2002-11-23
+134,CS,2000-04-10
+135,MATH,2001-06-24
+136,MATH,2014-01-09
+137,CS,2011-09-26
+139,CS,2019-08-21
+141,BIOL,2020-06-24
+142,CS,2000-01-02
+143,PHYS,2004-12-03
+144,CS,2009-12-05
+147,CS,2002-08-30
+148,PHYS,2014-04-18
+150,BIOL,2011-11-07
+151,PHYS,2003-07-14
+153,PHYS,2020-09-08
+156,PHYS,2018-07-10
+159,PHYS,2017-12-07
+160,MATH,2005-10-18
+161,MATH,2005-08-29
+162,MATH,2007-08-04
+163,BIOL,2015-09-17
+164,CS,2013-11-20
+165,CS,2008-09-25
+166,BIOL,2006-09-03
+167,MATH,2005-11-05
+168,PHYS,2004-07-07
+169,PHYS,2013-10-08
+171,PHYS,2016-12-25
+172,MATH,2005-07-17
+174,PHYS,2001-12-04
+175,CS,2018-10-22
+176,MATH,1999-10-29
+177,BIOL,2020-05-28
+178,PHYS,2002-04-10
+181,BIOL,2005-12-04
+182,PHYS,2000-02-18
+183,PHYS,2003-10-13
+184,MATH,1999-03-07
+185,CS,2011-03-27
+187,PHYS,2012-11-18
+188,PHYS,2018-05-03
+189,BIOL,2017-08-06
+191,MATH,2001-06-13
+194,CS,2010-08-05
+195,BIOL,2005-04-21
+196,CS,2020-11-07
+197,BIOL,2016-12-20
+198,CS,2015-11-19
+200,CS,2005-06-20
+203,BIOL,2006-01-22
+204,MATH,2018-05-29
+205,PHYS,2015-02-13
+206,CS,2016-01-16
+207,CS,2010-12-24
+210,BIOL,2011-02-17
+211,PHYS,2020-01-17
+212,BIOL,2018-01-04
+213,MATH,2003-09-10
+215,BIOL,2001-04-14
+216,MATH,2013-12-07
+217,PHYS,2013-07-18
+218,PHYS,2020-04-13
+219,MATH,2011-10-19
+220,PHYS,2001-05-30
+221,MATH,2018-05-14
+223,BIOL,2001-08-29
+224,PHYS,2003-04-30
+225,PHYS,2016-08-07
+226,PHYS,2009-02-23
+228,CS,2002-06-08
+230,MATH,2003-01-05
+231,MATH,2015-12-20
+232,CS,2006-11-05
+233,PHYS,2000-10-01
+234,CS,2019-06-20
+235,PHYS,2017-05-23
+236,BIOL,2010-04-05
+237,CS,1999-10-08
+238,CS,2006-08-16
+239,MATH,2008-11-11
+240,MATH,2007-07-22
+241,MATH,2012-04-14
+242,PHYS,2011-03-06
+243,MATH,2001-04-24
+244,CS,2004-05-15
+245,CS,2008-10-19
+246,PHYS,2001-07-18
+248,CS,2017-03-08
+249,MATH,2018-07-30
+250,BIOL,2007-03-19
+251,CS,2016-08-13
+252,BIOL,2019-10-19
+253,CS,2016-01-06
+254,PHYS,2009-08-16
+255,BIOL,2012-08-01
+256,PHYS,2020-01-19
+257,MATH,2000-12-04
+258,BIOL,2017-07-29
+259,PHYS,2002-10-09
+260,BIOL,2018-10-30
+261,BIOL,2015-01-10
+262,BIOL,2007-12-14
+263,MATH,2000-01-08
+264,CS,2000-02-06
+265,PHYS,2010-07-03
+267,PHYS,2013-05-04
+268,PHYS,2007-11-17
+269,PHYS,2005-10-27
+270,BIOL,2010-05-20
+272,CS,2001-01-08
+273,MATH,2003-09-28
+274,CS,2005-12-13
+275,BIOL,2017-08-12
+276,PHYS,2010-03-20
+277,PHYS,2001-02-13
+278,CS,2007-01-07
+279,MATH,2015-10-17
+280,PHYS,2001-06-25
+282,CS,2018-03-09
+283,CS,2019-10-03
+285,BIOL,2000-03-15
+286,MATH,2010-10-08
+287,MATH,2001-05-29
+288,PHYS,2013-02-28
+290,PHYS,2019-05-09
+292,MATH,2019-11-03
+293,BIOL,2001-09-28
+295,MATH,2017-10-05
+296,CS,2015-04-16
+299,PHYS,2003-05-28
+301,PHYS,2008-03-15
+302,MATH,2000-06-02
+304,MATH,2002-07-17
+305,PHYS,2000-03-18
+307,BIOL,2015-11-24
+308,MATH,2016-04-09
+311,BIOL,2006-08-31
+312,PHYS,2010-12-01
+313,CS,2013-09-06
+314,PHYS,2015-04-02
+315,BIOL,2009-04-28
+318,PHYS,2006-10-01
+319,CS,1999-09-24
+320,MATH,2000-11-18
+321,PHYS,1999-11-24
+322,BIOL,2005-09-03
+323,BIOL,2017-03-05
+324,CS,2019-09-10
+325,MATH,2011-11-28
+326,MATH,1999-08-13
+328,CS,2017-10-19
+329,CS,2015-05-29
+332,PHYS,2000-10-09
+334,MATH,2012-03-04
+336,PHYS,2011-11-02
+337,MATH,2003-04-06
+338,PHYS,2013-08-15
+340,CS,2013-07-10
+342,PHYS,2017-09-12
+343,PHYS,2003-09-09
+344,PHYS,2002-12-07
+345,CS,2013-11-25
+346,BIOL,2003-01-06
+348,PHYS,2019-12-13
+349,PHYS,2011-07-06
+350,CS,2010-12-20
+351,CS,2005-08-03
+352,MATH,2010-09-04
+353,PHYS,2013-11-07
+357,BIOL,2000-12-20
+358,CS,2007-02-07
+360,BIOL,2006-11-23
+362,BIOL,2002-02-17
+364,BIOL,2019-01-11
+365,BIOL,1999-05-05
+366,MATH,2006-09-23
+367,CS,2013-01-20
+368,CS,2017-03-30
+369,BIOL,2018-04-30
+370,PHYS,2000-07-22
+371,CS,1999-07-05
+372,CS,2007-07-03
+373,MATH,2000-12-07
+376,CS,2001-08-10
+378,MATH,2000-12-05
+379,PHYS,2003-04-24
+382,PHYS,2013-12-03
+383,PHYS,2005-02-22
+385,MATH,2008-08-12
+386,PHYS,2000-06-27
+390,CS,2009-09-08
+391,MATH,2010-11-24
+392,CS,2019-07-01
+393,CS,2007-04-24
+394,BIOL,2008-12-12
+395,PHYS,2003-06-01
+396,MATH,2019-08-16
+398,MATH,2012-07-14
+399,CS,2015-04-16
diff --git a/tests/integration/data/Term.csv b/tests/integration/data/Term.csv
new file mode 100644
index 000000000..91c3400ae
--- /dev/null
+++ b/tests/integration/data/Term.csv
@@ -0,0 +1,19 @@
+term_year,term
+2015,Spring
+2015,Summer
+2015,Fall
+2016,Spring
+2016,Summer
+2016,Fall
+2017,Spring
+2017,Summer
+2017,Fall
+2018,Spring
+2018,Summer
+2018,Fall
+2019,Spring
+2019,Summer
+2019,Fall
+2020,Spring
+2020,Summer
+2020,Fall
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("