## Installation
+
+```bash
+pip install datajoint
```
-pip3 install datajoint
-```
-If you already have an older version of DataJoint installed using `pip`, upgrade with
+or with Conda:
+
```bash
-pip3 install --upgrade datajoint
+conda install -c conda-forge datajoint
```
-## Documentation and Tutorials
-A number of labs are currently adopting DataJoint and we are quickly getting the documentation in shape in February 2017.
+## Example Pipeline
+
+
+
+**Cite DataJoint:** [Yatsenko et al., 2026](https://arxiv.org/abs/2602.16585) — RRID: [SCR_014543](https://scicrunch.org/resolver/SCR_014543)
+
+## Resources
+
+- **[Documentation](https://docs.datajoint.com)** — Complete guides and reference
+ - [Tutorials](https://docs.datajoint.com/tutorials/) — Learn by example
+ - [How-To Guides](https://docs.datajoint.com/how-to/) — Task-oriented guides
+ - [API Reference](https://docs.datajoint.com/api/) — Complete API documentation
+ - [Migration Guide](https://docs.datajoint.com/how-to/migrate-to-v20/) — Upgrade from legacy versions
+- **[DataJoint Elements](https://docs.datajoint.com/elements/)** — Example pipelines for neuroscience
+- **[GitHub Discussions](https://github.com/datajoint/datajoint-python/discussions)** — Community support
+
+## Contributing
-* https://datajoint.github.com -- start page
-* https://docs.datajoint.io -- up-to-date documentation
-* https://tutorials.datajoint.io -- step by step tutorials
+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 0f5276205..000000000
--- a/datajoint/__init__.py
+++ /dev/null
@@ -1,89 +0,0 @@
-"""
-DataJoint for Python is a high-level programming interface for MySQL databases
-to support data processing chains in science labs. DataJoint is built on the
-foundation of the relational data model and prescribes a consistent method for
-organizing, populating, and querying data.
-
-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
-"""
-
-import logging
-import os
-from types import ModuleType
-from .version import __version__
-
-__author__ = "Dimitri Yatsenko, Edgar Y. Walker, and Fabian Sinz at Baylor College of Medicine"
-__date__ = "July 26, 2017"
-__all__ = ['__author__', '__version__',
- 'config', 'conn', 'kill', 'BaseRelation',
- 'Connection', 'Heading', 'FreeRelation', 'Not', 'schema',
- 'Manual', 'Lookup', 'Imported', 'Computed', 'Part',
- 'AndList', 'OrList', 'ERD', 'U',
- 'set_password']
-
-
-class key:
- """
- object that allows requesting the primary key in Fetch.__getitem__
- """
- pass
-
-
-class DataJointError(Exception):
- """
- Base class for errors specific to DataJoint internal operation.
- """
- pass
-
-# ----------- loads local configuration from file ----------------
-from .settings import Config, LOCALCONFIG, GLOBALCONFIG, logger, log_levels
-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:
- config.add_history('No config file found, using default settings.')
-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'),
- map(os.getenv, ('DJ_HOST', 'DJ_USER', 'DJ_PASS')))
- if v is not None}
-for k in mapping:
- config.add_history('Updated login credentials from %s' % k)
-config.update(mapping)
-
-logger.setLevel(log_levels[config['loglevel']])
-
-# ------------- flatten import hierarchy -------------------------
-from .connection import conn, Connection
-from .base_relation import FreeRelation, BaseRelation
-from .user_relations import Manual, Lookup, Imported, Computed, Part
-from .relational_operand import Not, AndList, OrList, U
-from .heading import Heading
-from .schema import Schema as schema
-from .erd import ERD
-from .admin import set_password, kill
-
-
-def create_virtual_module(modulename, dbname):
- """
- Creates a python module with the given name from a database name in mysql with datajoint tables.
- Automatically creates the classes of the appropriate tier in the module.
-
- :param modulename: desired name of the module
- :param dbname: name of the database in mysql
- :return: the python module
- """
- mod = ModuleType(modulename)
- s = schema(dbname, mod.__dict__)
- s.spawn_missing_classes()
- mod.__dict__['schema'] = s
- return mod
diff --git a/datajoint/admin.py b/datajoint/admin.py
deleted file mode 100644
index 172f89237..000000000
--- a/datajoint/admin.py
+++ /dev/null
@@ -1,58 +0,0 @@
-import pymysql
-from . import conn
-from getpass import getpass
-
-
-def set_password(new_password=None, connection=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.')
-
-
-def kill(restriction=None, connection=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()
-
- 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 older than 10 minutes.
- """
-
- if connection is None:
- connection = conn()
-
- query = 'SELECT * FROM information_schema.processlist WHERE id <> CONNECTION_ID()' + (
- "" if restriction is None else ' AND (%s)' % restriction)
-
- while True:
- print(' ID USER STATE TIME INFO')
- print('+--+ +----------+ +-----------+ +--+')
- for process in connection.query(query, as_dict=True).fetchall():
- try:
- print('{ID:>4d} {USER:<12s} {STATE:<12s} {TIME:>5d} {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')
diff --git a/datajoint/autopopulate.py b/datajoint/autopopulate.py
deleted file mode 100644
index 9e3a7a824..000000000
--- a/datajoint/autopopulate.py
+++ /dev/null
@@ -1,158 +0,0 @@
-"""autopopulate containing the dj.AutoPopulate class. See `dj.AutoPopulate` for more info."""
-import logging
-import datetime
-import random
-from pymysql import OperationalError
-from .relational_operand import RelationalOperand, AndList
-from . import DataJointError
-from . import key as KEY
-from .base_relation import FreeRelation
-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_tuples.
- """
- _key_source = None
-
- @property
- def key_source(self):
- """
- :return: the relation whose primary key values are passed, sequentially, to the
- `_make_tuples` 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.
- """
- if self._key_source is None:
- self.connection.dependencies.load(self.full_table_name)
- parents = list(self.target.parents(primary=True))
- if not parents:
- raise DataJointError('A relation must have parent relations to be able to be populated')
- self._key_source = FreeRelation(self.connection, parents.pop(0)).proj()
- while parents:
- self._key_source *= FreeRelation(self.connection, parents.pop(0)).proj()
- return self._key_source
-
- def _make_tuples(self, key):
- """
- Derived classes must implement method _make_tuples 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_tuples"')
-
- @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 populate(self, *restrictions, suppress_errors=False, reserve_jobs=False, order="original", limit=None):
- """
- rel.populate() calls rel._make_tuples(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: suppresses error if true
- :param reserve_jobs: if true, reserves job to populate in asynchronous fashion
- :param order: "original"|"reverse"|"random" - the order of execution
- :param limit: 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))
-
- todo = self.key_source
- if not isinstance(todo, RelationalOperand):
- raise DataJointError('Invalid key_source value')
- todo = todo.proj() & AndList(restrictions)
-
- error_list = [] if suppress_errors else None
-
- jobs = self.connection.jobs[self.target.database] 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)
-
- todo -= self.target
- keys = todo.fetch(KEY, limit=limit)
- if order == "reverse":
- keys.reverse()
- elif order == "random":
- random.shuffle(keys)
-
- logger.info('Found %d keys to populate' % len(keys))
- for key in keys:
- 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))
- try:
- self._make_tuples(dict(key))
- except (KeyboardInterrupt, SystemExit, Exception) as error:
- try:
- self.connection.cancel_transaction()
- except OperationalError:
- pass
- if reserve_jobs:
- # show error name and error message (if any)
- error_message = ': '.join([error.__class__.__name__, str(error)]).strip(': ')
- jobs.error(self.target.table_name, self._job_key(key), error_message=error_message)
-
- if not suppress_errors or isinstance(error, SystemExit):
- raise
- else:
- logger.error(error)
- error_list.append((key, error))
- else:
- self.connection.commit_transaction()
- if reserve_jobs:
- jobs.complete(self.target.table_name, self._job_key(key))
-
- # 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 this table
- :return: remaining, total -- tuples to be populated
- """
- todo = self.key_source & AndList(restrictions)
- total = len(todo)
- remaining = len(todo.proj() - 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/base_relation.py b/datajoint/base_relation.py
deleted file mode 100644
index c033d0f88..000000000
--- a/datajoint/base_relation.py
+++ /dev/null
@@ -1,579 +0,0 @@
-import collections
-import itertools
-import inspect
-import platform
-import numpy as np
-import pymysql
-import logging
-from . import config, DataJointError
-from .declare import declare
-from .relational_operand import RelationalOperand
-from .blob import pack
-from .utils import user_choice
-from .heading import Heading
-from .settings import server_error_codes
-from . import __version__ as version
-
-logger = logging.getLogger(__name__)
-
-
-class BaseRelation(RelationalOperand):
- """
- BaseRelation is an abstract class that represents a base relation, i.e. a table in the database.
- To make it a concrete class, override the abstract properties specifying the connection,
- table name, database, context, and definition.
- A Relation implements insert and delete methods in addition to inherited relational operators.
- """
- _heading = None
- _context = None
- database = None
- _log_ = None
-
- # -------------- required by RelationalOperand ----------------- #
- @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: # lazy loading of heading
- self._heading.init_from_database(self.connection, self.database, self.table_name)
- return self._heading
-
- @property
- def context(self):
- return self._context
-
- def declare(self):
- """
- Loads the table heading. If the table is not declared, use self.definition to declare
- """
- try:
- self.connection.query(
- declare(self.full_table_name, self.definition, self._context))
- except pymysql.OperationalError as error:
- if error.args[0] == server_error_codes['command denied']:
- logger.warning(error.args[1])
- else:
- self._log('Declared ' + 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 dict(p[::2] for p in self.connection.dependencies.in_edges(self.full_table_name, data=True)
- if primary is None or p[2]['primary'] == primary)
-
- def children(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 with foreign keys referencing self
- """
- return dict(p[1:3] for p in self.connection.dependencies.out_edges(self.full_table_name, data=True)
- if primary is None or p[2]['primary'] == primary)
-
- @property
- def is_declared(self):
- """
- :return: True is the table is declared in the database
- """
- 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 database
- """
- 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)
- return self._log_
-
- 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, ignore_errors=False, skip_duplicates=False, ignore_extra_fields=False):
- """
- Insert a collection of rows.
-
- :param rows: An iterable where an element is a numpy record, a dict-like object, or an ordered sequence.
- rows may also be another relation with the same heading.
- :param replace: If True, replaces the existing tuple.
- :param ignore_errors: If True, ignore errors: e.g. constraint violations.
- :param skip_duplicates: If True, silently skip duplicate inserts.
- :param ignore_extra_fields: If False, fields that are not in the heading raise error.
-
- 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, RelationalOperand):
- # INSERT FROM SELECT
- query = 'INSERT{ignore} INTO {table} ({fields}) {select}'.format(
- ignore=" IGNORE" if ignore_errors or skip_duplicates else "",
- table=self.full_table_name,
- fields='`'+'`,`'.join(rows.heading.names)+'`',
- select=rows.make_sql())
- self.connection.query(query)
- return
-
- heading = self.heading
- 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:
- :param value:
- """
- if ignore_extra_fields and name not in heading:
- return None
- if heading[name].is_blob:
- value = pack(value)
- placeholder = '%s'
- elif heading[name].numeric:
- if value is None or value == '' 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'
- 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.keys())
- 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:
- self.connection.query(
- "{command} INTO {destination}(`{fields}`) VALUES {placeholders}".format(
- command='REPLACE' if replace else 'INSERT IGNORE' if ignore_errors or skip_duplicates else 'INSERT',
- destination=self.from_clause,
- fields='`,`'.join(field_list),
- placeholders=','.join('(' + ','.join(row['placeholders']) + ')' for row in rows)),
- args=list(itertools.chain.from_iterable((v for v in r['values'] if v is not None) for r in rows)))
- except pymysql.err.OperationalError as err:
- if err.args[0] == server_error_codes['command denied']:
- raise DataJointError('Command denied: %s' % err.args[1])
- else:
- raise
-
- def delete_quick(self):
- """
- Deletes the table without cascading and without user prompt. If this table has any dependent
- table(s), this will fail.
- """
- query = 'DELETE FROM ' + self.full_table_name + self.where_clause
- self.connection.query(query)
- self._log(query[0:255])
-
- def delete(self):
- """
- Deletes the contents of the table and its dependent tables, recursively.
- User is prompted for confirmation if config['safemode'] is set to True.
- """
- self.connection.dependencies.load()
-
- relations_to_delete = collections.OrderedDict(
- (r, FreeRelation(self.connection, r))
- for r in self.connection.dependencies.descendants(self.full_table_name))
-
- # construct restrictions for each relation
- restrict_by_me = set()
- restrictions = collections.defaultdict(list)
- if self.restrictions:
- restrict_by_me.add(self.full_table_name)
- restrictions[self.full_table_name].append(self.restrictions) # copy own restrictions
- for r in relations_to_delete.values():
- restrict_by_me.update(r.children(primary=False))
- for name, r in relations_to_delete.items():
- for dep in r.children():
- if name in restrict_by_me:
- restrictions[dep].append(r)
- else:
- restrictions[dep].extend(restrictions[name])
-
- # apply restrictions
- for name, r in relations_to_delete.items():
- if restrictions[name]: # do not restrict by an empty list
- r.restrict([r.proj() if isinstance(r, RelationalOperand) else r
- for r in restrictions[name]]) # project
- # execute
- do_delete = False # indicate if there is anything to delete
- if config['safemode']: # pragma: no cover
- print('The contents of the following tables are about to be deleted:')
- for relation in list(relations_to_delete.values()):
- count = len(relation)
- if count:
- do_delete = True
- if config['safemode']:
- print(relation.full_table_name, '(%d tuples)' % count)
- else:
- relations_to_delete.pop(relation.full_table_name)
- if not do_delete:
- if config['safemode']:
- print('Nothing to delete')
- else:
- if not config['safemode'] or user_choice("Proceed?", default='no') == 'yes':
- already_in_transaction = self.connection._in_transaction
- if not already_in_transaction:
- self.connection.start_transaction()
- for r in reversed(list(relations_to_delete.values())):
- r.delete_quick()
- if not already_in_transaction:
- self.connection.commit_transaction()
- print('Done')
-
- 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[0: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.
- """
- self.connection.dependencies.load()
- do_drop = True
- tables = self.connection.dependencies.descendants(self.full_table_name)
- if config['safemode']:
- for table in tables:
- print(table, '(%d tuples)' % len(FreeRelation(self.connection, table)))
- do_drop = user_choice("Proceed?", default='no') == 'yes'
- if do_drop:
- for table in reversed(tables):
- FreeRelation(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):
- logger.warn('show_definition is deprecated. Use describe instead.')
- return self.describe()
-
- def describe(self):
- """
- :return: the definition string for the relation using DataJoint DDL.
- This does not yet work for aliased foreign keys.
- """
- self.connection.dependencies.load()
- parents = self.parents()
- in_key = True
- definition = '# ' + self.heading.table_info['comment'] + '\n'
- attributes_thus_far = set()
- attributes_declared = set()
- 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['referencing_attributes']:
- do_include = False
- if attributes_thus_far.issuperset(fk_props['referencing_attributes']):
- # simple foreign keys
- parents.pop(parent_name)
- if not parent_name.isdigit():
- definition += '-> {class_name}\n'.format(
- class_name=lookup_class_name(parent_name, self.context) or parent_name)
- else:
- # aliased foreign key
- parent_name = self.connection.dependencies.in_edges(parent_name)[0][0]
- lst = [(attr, ref) for attr, ref in zip(
- fk_props['referencing_attributes'], fk_props['referenced_attributes'])
- if ref != attr]
- definition += '({attr_list}) -> {class_name}{ref_list}\n'.format(
- attr_list=','.join(r[0] for r in lst),
- class_name=lookup_class_name(parent_name, self.context) or parent_name,
- ref_list=('' if len(attributes_thus_far) - len(attributes_declared) == 1
- else '(%s)' % ','.join(r[1] for r in lst)))
- attributes_declared.update(fk_props['referencing_attributes'])
- 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)
- 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 = 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'
- 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 `database`.`table_name`, find its class in the context.
- :param name: `database`.`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 inspect.isclass(member) and issubclass(member, BaseRelation):
- 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 UserRelation -- cannot have part tables.
- else:
- for part in (getattr(member, p) for p in parts):
- if inspect.isclass(part) and issubclass(part, BaseRelation) 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 FreeRelation(BaseRelation):
- """
- 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.FreeRelation
- """
-
- def __init__(self, arg, full_table_name=None):
- super().__init__()
- if isinstance(arg, FreeRelation):
- # 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 "FreeRelation(`%s`.`%s`)" % (self.database, self._table_name)
-
- @property
- def table_name(self):
- """
- :return: the table name in the database
- """
- return self._table_name
-
-
-class Log(BaseRelation):
- """
- The log table for each database.
- Instances are callable. Calls log the time and identifying information along with the event.
- """
-
- def __init__(self, arg, database=None):
- super().__init__()
-
- if isinstance(arg, Log):
- # copy constructor
- self.database = arg.database
- self._connection = arg._connection
- self._definition = arg._definition
- self._user = arg._user
- return
-
- self.database = database
- self._connection = arg
- self._definition = """ # event logging table for `{database}`
- timestamp = CURRENT_TIMESTAMP : timestamp
- ---
- version :varchar(12) # datajoint version
- user :varchar(255) # user@host
- host="" :varchar(255) # system hostname
- event="" :varchar(255) # custom 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):
- try:
- self.insert1(dict(
- user=self._user,
- version=version + 'py',
- host=platform.uname().node,
- event=event), ignore_errors=True, ignore_extra_fields=True)
- except pymysql.err.OperationalError:
- 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/blob.py b/datajoint/blob.py
deleted file mode 100644
index 139a6ca30..000000000
--- a/datajoint/blob.py
+++ /dev/null
@@ -1,301 +0,0 @@
-"""
-Provides serialization methods for numpy.ndarrays that ensure compatibility with Matlab.
-"""
-
-import zlib
-from collections import OrderedDict, Mapping, Iterable
-import numpy as np
-from . import DataJointError
-
-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', None),
- ('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())
-
-decode_lookup = {
- b'ZL123\0': zlib.decompress
-}
-
-
-class BlobReader:
- def __init__(self, blob, squeeze=False, as_dict=False):
- self._squeeze = squeeze
- self._blob = blob
- self._pos = 0
- self._as_dict = as_dict
-
- @property
- def pos(self):
- return self._pos
-
- @pos.setter
- def pos(self, val):
- self._pos = val
-
- def reset(self):
- self.pos = 0
-
- def decompress(self):
- for pattern, decoder in decode_lookup.items():
- if self._blob[self.pos:].startswith(pattern):
- self.pos += len(pattern)
- blob_size = self.read_value('uint64')
- blob = decoder(self._blob[self.pos:])
- assert len(blob) == blob_size
- self._blob = blob
- self._pos = 0
- break
-
- def unpack(self):
- self.decompress()
- blob_format = self.read_string()
- if blob_format == 'mYm':
- return self.read_mym_data(n_bytes=-1)
-
- def read_mym_data(self, n_bytes=None):
- if n_bytes is not None:
- if n_bytes == -1:
- n_bytes = len(self._blob) - self.pos
- n_bytes -= 1
-
- type_id = self.read_value('c')
- if type_id == b'A':
- return self.read_array(n_bytes=n_bytes)
- elif type_id == b'S':
- return self.read_structure(n_bytes=n_bytes)
- elif type_id == b'C':
- return self.read_cell_array(n_bytes=n_bytes)
-
- def read_array(self, advance=True, n_bytes=None):
- start = self.pos
- n_dims = int(self.read_value('uint64'))
- shape = self.read_value('uint64', count=n_dims)
- n_elem = int(np.prod(shape))
- dtype_id = self.read_value('uint32')
- dtype = dtype_list[dtype_id]
- is_complex = self.read_value('uint32')
-
- if dtype_id == 4: # if dealing with character array
- 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:
- if is_complex:
- n_elem *= 2 # read real and imaginary parts
- data = self.read_value(dtype, count=n_elem)
- if is_complex:
- data = data[:n_elem//2] + 1j * data[n_elem//2:]
-
- if n_bytes is not None:
- assert self.pos - start == n_bytes
-
- if not advance:
- self.pos = start
-
- return self.squeeze(data.reshape(shape, order='F'))
-
- def read_structure(self, advance=True, n_bytes=None):
- start = self.pos
- n_dims = self.read_value('uint64').item()
- shape = self.read_value('uint64', count=n_dims)
- n_elem = int(np.prod(shape))
- n_field = int(self.read_value('uint32'))
- field_names = []
- for i in range(n_field):
- field_names.append(self.read_string())
- if not field_names:
- # return an empty array
- return np.array(None)
- dt = [(f, np.object) for f in field_names]
- raw_data = []
- for k in range(n_elem):
- values = []
- for i in range(n_field):
- nb = int(self.read_value('uint64')) # dealing with a weird bug of numpy
- values.append(self.read_mym_data(n_bytes=nb))
- raw_data.append(tuple(values))
- if n_bytes is not None:
- assert self.pos - start == n_bytes
- if not advance:
- self.pos = start
-
- if self._as_dict and n_elem == 1:
- data = dict(zip(field_names, values))
- return data
- else:
- data = np.rec.array(raw_data, dtype=dt)
- return self.squeeze(data.reshape(shape, order='F'))
-
- def squeeze(self, array):
- """
- Simplify the given array as much as possible - squeeze out all singleton
- dimensions and also convert a zero dimensional array into array scalar
- """
- if not self._squeeze:
- return array
- array = array.copy()
- array = array.squeeze()
- if array.ndim == 0:
- array = array[()]
- return array
-
- def read_cell_array(self, advance=True, n_bytes=None):
- start = self.pos
- n_dims = self.read_value('uint64').item()
- shape = self.read_value('uint64', count=n_dims)
- n_elem = int(np.prod(shape))
- data = np.empty(n_elem, dtype=np.object)
- for i in range(n_elem):
- nb = self.read_value('uint64').item()
- data[i] = self.read_mym_data(n_bytes=nb)
- if n_bytes is not None:
- assert self.pos - start == n_bytes
- if not advance:
- self.pos = start
- return data
-
- def read_string(self, advance=True):
- """
- Read a string terminated by null byte '\0'. The returned string
- object is ASCII decoded, and will not include the terminating null byte.
- """
- target = self._blob.find(b'\0', self.pos)
- assert target >= self._pos
- data = self._blob[self._pos:target]
- if advance:
- self._pos = target + 1
- return data.decode('ascii')
-
- def read_value(self, dtype='uint64', count=1, advance=True):
- """
- Read one or more scalars of the indicated dtype. Count specifies the number of
- scalars to be read in.
- """
- data = np.frombuffer(self._blob, dtype=dtype, count=count, offset=self.pos)
- if advance:
- # probably the same thing as data.nbytes * 8
- self._pos += data.dtype.itemsize * data.size
- if count == 1:
- data = data[0]
- return data
-
- def __repr__(self):
- return repr(self._blob[self.pos:])
-
- def __str__(self):
- return str(self._blob[self.pos:])
-
-
-def pack(obj, compress=True):
- blob = b"mYm\0"
- blob += pack_obj(obj)
-
- if compress:
- compressed = b'ZL123\0' + np.uint64(len(blob)).tostring() + zlib.compress(blob)
- if len(compressed) < len(blob):
- blob = compressed
-
- return blob
-
-
-def pack_obj(obj):
- blob = b''
- if isinstance(obj, np.ndarray):
- blob += pack_array(obj)
- elif isinstance(obj, Mapping): # TODO: check if this is a good inheritance check for dict etc.
- blob += pack_dict(obj)
- elif isinstance(obj, str):
- blob += pack_array(np.array(obj, dtype=np.dtype('c')))
- elif isinstance(obj, Iterable):
- blob += pack_array(np.array(list(obj)))
- elif isinstance(obj, int) or isinstance(obj, float):
- blob += pack_array(np.array(obj))
- else:
- raise DataJointError("Packing object of type %s currently not supported!" % type(obj))
-
- return blob
-
-
-def pack_array(array):
- if not isinstance(array, np.ndarray):
- raise ValueError("argument must be a numpy array!")
-
- blob = b"A"
- blob += np.array((len(array.shape), ) + array.shape, dtype=np.uint64).tostring()
-
- is_complex = np.iscomplexobj(array)
- if is_complex:
- array, imaginary = np.real(array), np.imag(array)
-
- type_number = rev_class_id[array.dtype]
-
- if dtype_list[type_number] is None:
- raise DataJointError("Type %s is ambiguous or unknown" % array.dtype)
-
- blob += np.array(type_number, dtype=np.uint32).tostring()
-
- blob += np.int32(is_complex).tostring()
- if type_number == 4: # if dealing with character array
- blob += ('\x00'.join(array.tostring(order='F').decode()) + '\x00').encode()
- else:
- blob += array.tostring(order='F')
-
- if is_complex:
- blob += imaginary.tostring(order='F')
-
- return blob
-
-
-def pack_string(value):
- return value.encode('ascii') + b'\0'
-
-
-def pack_dict(obj):
- """
- Write dictionary object as a singular structure array
- :param obj: dictionary object to serialize. The fields must be simple scalar or an array.
- """
- obj = OrderedDict(obj)
- blob = b'S'
- blob += np.array((1, 1), dtype=np.uint64).tostring()
- blob += np.array(len(obj), dtype=np.uint32).tostring()
-
- # write out field names
- for k in obj:
- blob += pack_string(k)
-
- for k, v in obj.items():
- blob_part = pack_obj(v)
- blob += np.array(len(blob_part), dtype=np.uint64).tostring()
- blob += blob_part
-
- return blob
-
-
-def unpack(blob, **kwargs):
- if blob is None:
- return None
-
- return BlobReader(blob, **kwargs).unpack()
-
diff --git a/datajoint/connection.py b/datajoint/connection.py
deleted file mode 100644
index c5ac20ecd..000000000
--- a/datajoint/connection.py
+++ /dev/null
@@ -1,211 +0,0 @@
-"""
-This module hosts the Connection class that manages the connection to the mysql 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 . import config
-from . import DataJointError
-from .dependencies import Dependencies
-from .jobs import JobManager
-from pymysql import err
-
-logger = logging.getLogger(__name__)
-
-
-def conn(host=None, user=None, password=None, init_fun=None, reset=False):
- """
- 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
- """
- 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']
- conn.connection = Connection(host, user, password, init_fun)
- 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
- :param user: user name
- :param password: password
- :param init_fun: connection initialization function (SQL)
- """
-
- def __init__(self, host, user, password, init_fun=None):
- if ':' in host:
- host, port = host.split(':')
- port = int(port)
- else:
- port = config['database.port']
- self.conn_info = dict(host=host, port=port, user=user, passwd=password)
- 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 DataJointError('Connection failed.')
- self._conn.autocommit(True)
- self._in_transaction = False
- self.jobs = JobManager(self)
- 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.
- """
- self._conn = client.connect(init_command=self.init_fun, **self.conn_info)
-
- def register(self, schema):
- self.schemas[schema.database] = schema
-
- @property
- def is_connected(self):
- """
- Returns true if the object is connected to the database server.
- """
- return self._conn.ping()
-
- def query(self, query, args=(), as_dict=False):
- """
- Execute the specified query and return the tuple generator (cursor).
-
- :param query: mysql 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.
- """
-
- cursor = client.cursors.DictCursor if as_dict else client.cursors.Cursor
- cur = self._conn.cursor(cursor=cursor)
-
- try:
- # Log the query
- logger.debug("Executing SQL:" + query[0:300])
- # suppress all warnings arising from underlying SQL library
- with warnings.catch_warnings():
- warnings.simplefilter("ignore")
- cur.execute(query, args)
- except err.OperationalError as e:
- if 'MySQL server has gone away' in str(e) and config['database.reconnect']:
- warnings.warn('''Mysql server has gone away.
- Reconnected to the server. Data from transactions might be lost and referential constraints may
- be violated. You can switch off this behavior by setting the 'database.reconnect' to False.
- ''')
- self.connect()
- logger.debug("Re-executing SQL: " + query[0:300])
- cur.execute(query, args)
- else:
- raise
- except err.ProgrammingError as e:
- print('Error in query:')
- print(query)
- raise
- return cur
-
- 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.
-
- :raise DataJointError: if there is an ongoing transaction.
- """
- if self.in_transaction:
- raise 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 5c7a10ab5..000000000
--- a/datajoint/declare.py
+++ /dev/null
@@ -1,181 +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 . import DataJointError
-
-logger = logging.getLogger(__name__)
-
-
-def build_foreign_key_parser():
- 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)
- 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_attribute_parser():
- quoted = pp.Or(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)).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
-
-
-foreign_key_parser = build_foreign_key_parser()
-attribute_parser = build_attribute_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[0:arrow_position] for c in '"#\'')
-
-
-def compile_foreign_key(line, context, attributes, primary_key, attr_sql, foreign_key_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: a list of sql statements defining attributes -- to be updated by this function.
- :param foreign_key_sql: a list of sql statements specifying foreign key constraints -- to be updated by this function.
- """
- from .base_relation import BaseRelation
- try:
- result = foreign_key_parser.parseString(line)
- except pp.ParseException as err:
- raise DataJointError('Parsing error in line "%s". %s.' % line, err)
- try:
- referenced_class = eval(result.ref_table, context)
- except NameError:
- raise DataJointError('Foreign key reference %s could not be resolved' % result.ref_table)
- if not issubclass(referenced_class, BaseRelation):
- raise DataJointError('Foreign key reference %s must be a subclass of UserRelation' % result.ref_table)
- ref = referenced_class()
- if not all(r in ref.primary_key for r in result.ref_attrs):
- raise DataJointError('Invalid foreign key attributes in "%s"' % line)
-
- # match new attributes and referenced attributes and create foreign keys
- missing_attrs = [attr for attr in ref.primary_key if attr not in attributes] or (
- len(result.new_attrs) == len(ref.primary_key) == 1 and ref.primary_key)
- new_attrs = result.new_attrs or missing_attrs
- ref_attrs = result.ref_attrs or missing_attrs
- if len(new_attrs) != len(ref_attrs):
- raise DataJointError('Mismatched attributes in foreign key "%s"' % line)
-
- # declare foreign key attributes and foreign keys
- lookup = dict(zip(ref_attrs, new_attrs))
- for attr in missing_attrs:
- new_attr = lookup.get(attr, attr)
- attributes.append(new_attr)
- if primary_key is not None:
- primary_key.append(new_attr)
- attr_sql.append(ref.heading[attr].sql.replace(attr, new_attr, 1))
- fk = [lookup.get(attr, attr) for attr in ref.primary_key]
- foreign_key_sql.append(
- 'FOREIGN KEY (`{fk}`) REFERENCES {ref} (`{pk}`) ON UPDATE CASCADE ON DELETE RESTRICT'.format(
- fk='`,`'.join(fk), pk='`,`'.join(ref.primary_key), ref=ref.full_table_name))
-
-
-def declare(full_table_name, definition, context):
- """
- Parse declaration and create new SQL table accordingly.
-
- :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. Usually this will be locals()
- """
- # 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 ''
- in_key = True # parse primary keys
- primary_key = []
- attributes = []
- attribute_sql = []
- foreign_key_sql = []
- index_sql = []
-
- for line in definition:
- if line.startswith('#'): # additional comments are ignored
- 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)
- elif re.match(r'^(unique\s+)?index[^:]*$', line, re.I): # index
- index_sql.append(line) # the SQL syntax is identical to DataJoint's
- else:
- name, sql = compile_attribute(line, in_key)
- 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)
- # compile SQL
- 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)
-
-
-def compile_attribute(line, in_key=False):
- """
- 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
- :returns: (name, sql) -- attribute name and sql code for its declaration
- """
-
- try:
- match = attribute_parser.parseString(line+'#', parseAll=True)
- except pp.ParseException as err:
- raise DataJointError('Declaration error in position {pos} in line:\n {line}\n{msg}'.format(
- line=err.args[0], pos=err.args[1], msg=err.args[2]))
- match['comment'] = match['comment'].rstrip('#')
- if 'default' not in match:
- match['default'] = ''
- match = {k: v.strip() for k, v in match.items()}
- match['nullable'] = match['default'].lower() == 'null'
-
- literals = ['CURRENT_TIMESTAMP'] # not to be enclosed in quotes
- 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'].upper() not in 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
- sql = ('`{name}` {type} {default}' + (' COMMENT "{comment}"' if match['comment'] else '')).format(**match)
- return match['name'], sql
diff --git a/datajoint/dependencies.py b/datajoint/dependencies.py
deleted file mode 100644
index 172c058a5..000000000
--- a/datajoint/dependencies.py
+++ /dev/null
@@ -1,101 +0,0 @@
-import pyparsing as pp
-import networkx as nx
-import itertools
-from . import DataJointError
-
-
-class Dependencies(nx.DiGraph):
- """
- The graph of dependencies (foreign keys) between loaded tables.
- """
- __primary_key_parser = (pp.CaselessLiteral('PRIMARY KEY') +
- pp.QuotedString('(', endQuoteChar=')').setResultsName('primary_key'))
-
- def __init__(self, connection):
- self._conn = connection
- self.loaded_tables = set()
- self._node_alias_count = itertools.count()
- super().__init__(self)
-
- @staticmethod
- def __foreign_key_parser(database):
- def paste_database(unused1, unused2, toc):
- return ['`{database}`.`{table}`'.format(database=database, table=toc[0])]
-
- return (pp.CaselessLiteral('CONSTRAINT').suppress() +
- pp.QuotedString('`').suppress() +
- pp.CaselessLiteral('FOREIGN KEY').suppress() +
- pp.QuotedString('(', endQuoteChar=')').setResultsName('attributes') +
- pp.CaselessLiteral('REFERENCES') +
- pp.Or([
- pp.QuotedString('`').setParseAction(paste_database),
- pp.Combine(pp.QuotedString('`', unquoteResults=False) + '.' +
- pp.QuotedString('`', unquoteResults=False))]).setResultsName('referenced_table') +
- pp.QuotedString('(', endQuoteChar=')').setResultsName('referenced_attributes'))
-
- def add_table(self, table_name):
- """
- Adds table to the dependency graph
- :param table_name: in format `schema`.`table`
- """
- if table_name in self.loaded_tables:
- return
- fk_parser = self.__foreign_key_parser(table_name.split('.')[0].strip('`'))
- self.loaded_tables.add(table_name)
- self.add_node(table_name)
- create_statement = self._conn.query('SHOW CREATE TABLE %s' % table_name).fetchone()[1].split('\n')
- primary_key = None
- for line in create_statement:
- if primary_key is None:
- try:
- result = self.__primary_key_parser.parseString(line)
- except pp.ParseException:
- pass
- else:
- primary_key = [s.strip(' `') for s in result.primary_key.split(',')]
- try:
- result = fk_parser.parseString(line)
- except pp.ParseException:
- pass
- else:
- referencing_attributes = [r.strip('` ') for r in result.attributes.split(',')]
- referenced_attributes = [r.strip('` ') for r in result.referenced_attributes.split(',')]
- props = dict(
- primary=all(a in primary_key for a in referencing_attributes),
- referencing_attributes=referencing_attributes,
- referenced_attributes=referenced_attributes,
- aliased=not all(a == b for a, b in zip(referencing_attributes, referenced_attributes)),
- multi=not all(a in referencing_attributes for a in primary_key))
- if not props['aliased']:
- self.add_edge(result.referenced_table, table_name, **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(result.referenced_table, alias_node, **props)
- self.add_edge(alias_node, table_name, **props)
-
- def load(self, target=None):
- """
- Load dependencies for all loaded schemas.
- This method gets called before any operation that requires dependencies: delete, drop, populate, progress.
- """
- if target is not None and '.' in target: # `database`.`table`
- self.add_table(target)
- else:
- databases = self._conn.schemas if target is None else [target]
- for database in databases:
- for row in self._conn.query('SHOW TABLES FROM `{database}`'.format(database=database)):
- table = row[0]
- if not table.startswith('~'): # exclude service tables
- self.add_table('`{db}`.`{tab}`'.format(db=database, tab=table))
- if not nx.is_directed_acyclic_graph(self): # pragma: no cover
- raise DataJointError('DataJoint can only work with acyclic dependencies')
-
- 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 = nx.algorithms.dag.descendants(self, full_table_name)
- return [full_table_name] + nx.algorithms.dag.topological_sort(self, nodes)
diff --git a/datajoint/erd.py b/datajoint/erd.py
deleted file mode 100644
index 275006336..000000000
--- a/datajoint/erd.py
+++ /dev/null
@@ -1,308 +0,0 @@
-import networkx as nx
-import re
-import functools
-import io
-import warnings
-
-try:
- from matplotlib import pyplot as plt
- from networkx.drawing.nx_agraph import graphviz_layout
- erd_active = True
-except:
- erd_active = False
-
-from . import Manual, Imported, Computed, Lookup, Part, DataJointError
-from .base_relation import lookup_class_name
-
-
-user_relation_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_relation_classes
- if re.fullmatch(tier.tier_regexp, table_name.split('`')[-2]))
- except StopIteration:
- return None
-
-if not erd_active:
- class ERD:
- """
- Entity relationship diagram, currently disabled due to the lack of required packages: matplotlib and pygraphviz.
-
- To enable ERD 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('ERD functionality depends on matplotlib and pygraphviz. Please install both of these '
- 'libraries to enable the ERD feature.')
-else:
- class ERD(nx.DiGraph):
- """
- Entity relationship diagram.
-
- Usage:
-
- >>> erd = Erd(source)
-
- source can be a base relation object, a base relation class, a schema, or a module that has a schema.
-
- >>> erd.draw()
-
- draws the diagram using pyplot
-
- erd1 + erd2 - combines the two ERDs.
- erd + n - adds n levels of successors
- erd - n - adds n levels of predecessors
- Thus dj.ERD(schema.Table)+1-1 defines the diagram of immediate ancestors and descendants of schema.Table
-
- Note that erd + 1 - 1 may differ from erd - 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, ERD):
- # copy constructor
- self.nodes_to_show = set(source.nodes_to_show)
- self.context = source.context
- super().__init__(source)
- return
-
- # get the caller's locals()
- if context is None:
- import inspect
- frame = inspect.currentframe()
- try:
- context = frame.f_back.f_locals
- finally:
- del frame
- 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 ERD 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 ERD for all objects in sequence
- :param sequence: a sequence (e.g. list, tuple)
- :return: ERD(arg1) + ... + ERD(argn)
- """
- return functools.reduce(lambda x, y: x+y, map(ERD, 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 = ERD(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 __add__(self, arg):
- """
- :param arg: either another ERD or a positive integer.
- :return: Union of the ERDs when arg is another ERD or an expansion downstream when arg is a positive integer.
- """
- self = ERD(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
- self.nodes_to_show.update(new)
- return self
-
- def __sub__(self, arg):
- """
- :param arg: either another ERD or a positive integer.
- :return: Difference of the ERDs when arg is another ERD or an expansion upstream when arg is a positive integer.
- """
- self = ERD(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):
- new = nx.algorithms.boundary.node_boundary(nx.DiGraph(self).reverse(), self.nodes_to_show)
- if not new:
- break
- self.nodes_to_show.update(new)
- return self
-
- def __mul__(self, arg):
- """
- Intersection of two ERDs
- :param arg: another ERD
- :return: a new ERD comprising nodes that are present in both operands.
- """
- self = ERD(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
- """
- # include aliased 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(self).subgraph(nodes)
- nx.set_node_attributes(graph, 'node_type', {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 ERD cannot be plotted.')
- nx.relabel_nodes(graph, mapping, copy=False)
- return graph
-
- def make_dot(self):
- import networkx as nx
-
- 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='white', fontsize=round(scale*6),
- size=0.15*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='circle', 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'])
- node.set_label(name)
- # node.set_margin(0.05)
- 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.node[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):
- return plt.imread(self.make_png())
-
- def _repr_svg_(self):
- return self.make_svg()._repr_svg_()
-
- def draw(self):
- plt.imshow(self.make_image())
- plt.gca().axis('off')
- plt.show()
-
- 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 graphviz_layout(graph, prog='dot', **kwargs)
-
diff --git a/datajoint/fetch.py b/datajoint/fetch.py
deleted file mode 100644
index 5b5a428d6..000000000
--- a/datajoint/fetch.py
+++ /dev/null
@@ -1,349 +0,0 @@
-from collections import OrderedDict
-from collections.abc import Callable, Iterable
-from functools import partial
-import numpy as np
-from .blob import unpack
-from . import DataJointError
-from . import key as PRIMARY_KEY
-import warnings
-
-
-def update_dict(d1, d2):
- return {k: (d2[k] if k in d2 else d1[k]) for k in d1}
-
-
-class FetchBase:
- def __init__(self, arg):
- # prepare copy constructor
- if isinstance(arg, self.__class__):
- self.sql_behavior = dict(arg.sql_behavior)
- self.ext_behavior = dict(arg.ext_behavior)
- self._relation = arg._relation
- else:
- self._initialize_behavior()
- self._relation = arg
-
- def copy(self):
- """
- DEPRECATED
-
- Creates and returns a copy of this object
- :return: copy FetchBase derivatives
- """
- warnings.warn('Use of `copy` on `fetch` object is deprecated', stacklevel=2)
-
- return self.__class__(self)
-
- def _initialize_behavior(self):
- self.sql_behavior = {}
- self.ext_behavior = dict(squeeze=False)
-
- @property
- def squeeze(self):
- """
- DEPRECATED
-
- Changes the state of the fetch object to squeeze the returned values as much as possible.
- :return: a copy of the fetch object
- """
-
- warnings.warn('Use of `squeeze` on `fetch` object is deprecated. Please use `squeeze=True` keyword arguments '
- 'in the call to `fetch`/`keys` instead', stacklevel=2)
-
- ret = self.copy()
- ret.ext_behavior['squeeze'] = True
- return ret
-
- @staticmethod
- def _prepare_attributes(item):
- """
- Used by fetch.__getitem__ to deal with slices
- :param item: the item passed to __getitem__. Can be a string, a tuple, a list, or a slice.
- :return: a tuple of items to fetch, a list of the corresponding attributes
- :raise DataJointError: if item does not match one of the datatypes above
- """
- if isinstance(item, str) or item is PRIMARY_KEY:
- item = (item,)
- try:
- attributes = tuple(i for i in item if i is not PRIMARY_KEY)
- except TypeError:
- raise DataJointError("Index must be a sequence or a string.")
- return item, attributes
-
- def __len__(self):
- return len(self._relation)
-
-
-class Fetch(FetchBase, Callable, Iterable):
- """
- A fetch object that handles retrieving elements from the database table.
-
- :param relation: relation the fetch object fetches data from
- """
-
- def _initialize_behavior(self):
- super()._initialize_behavior()
- self.sql_behavior = dict(self.sql_behavior, offset=None, limit=None, order_by=None, as_dict=False)
-
- def order_by(self, *args):
- """
- DEPRECATED
-
- Changes the state of the fetch object to order the results by a particular attribute.
- The commands are handed down to mysql.
- :param args: the attributes to sort by. If DESC is passed after the name, then the order is descending.
- :return: a copy of the fetch object
- Example:
-
- >>> my_relation.fetch.order_by('language', 'name DESC')
-
- """
- warnings.warn('Use of `order_by` on `fetch` object is deprecated. Please use `order_by` keyword arguments in '
- 'the call to `fetch`/`keys` instead', stacklevel=2)
- self = Fetch(self)
- if len(args) > 0:
- self.sql_behavior['order_by'] = args
- return self
-
- @property
- def as_dict(self):
- """
- DEPRECATED
-
- Changes the state of the fetch object to return dictionaries.
- :return: a copy of the fetch object
- Example:
-
- >>> my_relation.fetch.as_dict()
-
- """
- warnings.warn('Use of `as_dict` on `fetch` object is deprecated. Please use `as_dict` keyword arguments in the '
- 'call to `fetch`/`keys` instead', stacklevel=2)
- ret = Fetch(self)
- ret.sql_behavior['as_dict'] = True
- return ret
-
- def limit(self, limit):
- """
- DEPRECATED
-
- Limits the number of items fetched.
-
- :param limit: limit on the number of items
- :return: a copy of the fetch object
- """
- warnings.warn('Use of `limit` on `fetch` object is deprecated. Please use `limit` keyword arguments in '
- 'the call to `fetch`/`keys` instead', stacklevel=2)
- ret = Fetch(self)
- ret.sql_behavior['limit'] = limit
- return ret
-
- def offset(self, offset):
- """
- DEPRECATED
-
- Offsets the number of itms fetched. Needs to be applied with limit.
-
- :param offset: offset
- :return: a copy of the fetch object
- """
-
- warnings.warn('Use of `offset` on `fetch` object is deprecated. Please use `offset` keyword arguments in '
- 'the call to `fetch`/`keys` instead', stacklevel=2)
- ret = Fetch(self)
- if ret.sql_behavior['limit'] is None:
- warnings.warn('Fetch offset should be used with a limit.')
- ret.sql_behavior['offset'] = offset
- return ret
-
- def __call__(self, *attrs, **kwargs):
- """
- Fetches the relation from the database table into an np.array and unpacks blob attributes.
-
- :param attrs: OPTIONAL. one 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: the list of attributes to order the results. No ordering should be assumed if order_by=None.
- :param as_dict: returns a list of dictionaries instead of a record array
- :return: the contents of the relation in the form of a structured numpy.array
- """
- # if 'order_by' passed in a string, make into list
- if isinstance(kwargs.get('order_by'), str):
- kwargs['order_by'] = [kwargs['order_by']]
-
- sql_behavior = update_dict(self.sql_behavior, kwargs)
- ext_behavior = update_dict(self.ext_behavior, kwargs)
- total_behavior = dict(sql_behavior)
- total_behavior.update(ext_behavior)
-
- unpack_ = partial(unpack, squeeze=ext_behavior['squeeze'])
-
- if sql_behavior['limit'] is None and sql_behavior['offset'] is not None:
- warnings.warn('Offset set, but no limit. Setting limit to a large number. '
- 'Consider setting a limit explicitly.')
- sql_behavior['limit'] = 2 * len(self._relation)
-
- if len(attrs) == 0: # fetch all attributes
- cur = self._relation.cursor(**sql_behavior)
- heading = self._relation.heading
- if sql_behavior['as_dict']:
- ret = [OrderedDict((name, unpack_(d[name]) if heading[name].is_blob else d[name])
- for name in heading.names)
- for d in cur.fetchall()]
- else:
- ret = list(cur.fetchall())
- ret = np.array(ret, dtype=heading.as_dtype)
- for blob_name in heading.blobs:
- ret[blob_name] = list(map(unpack_, ret[blob_name]))
-
- else: # if list of attributes provided
- attributes = [a for a in attrs if a is not PRIMARY_KEY]
- result = self._relation.proj(*attributes).fetch(**total_behavior)
- return_values = [
- list(to_dicts(result[self._relation.primary_key]))
- if attribute is PRIMARY_KEY else result[attribute]
- for attribute in attrs]
- ret = return_values[0] if len(attrs) == 1 else return_values
-
- return ret
-
- def __iter__(self):
- """
- Iterator that returns the contents of the database.
- """
- sql_behavior = dict(self.sql_behavior)
- ext_behavior = dict(self.ext_behavior)
-
- unpack_ = partial(unpack, squeeze=ext_behavior['squeeze'])
-
- cur = self._relation.cursor(**sql_behavior)
-
- heading = self._relation.heading
- do_unpack = tuple(h in heading.blobs for h in heading.names)
- values = cur.fetchone()
- while values:
- if sql_behavior['as_dict']:
- yield OrderedDict(
- (field_name, unpack_(values[field_name])) if up
- else (field_name, values[field_name])
- for field_name, up in zip(heading.names, do_unpack))
- else:
- yield tuple(unpack_(value) if up else value for up, value in zip(do_unpack, values))
- values = cur.fetchone()
-
- def keys(self, **kwargs):
- """
- Iterator that returns primary keys as a sequence of dicts.
- """
- yield from self._relation.proj().fetch(**dict(self.sql_behavior, as_dict=True, **kwargs))
-
- def __getitem__(self, item):
- """
- DEPRECATED
-
- Fetch attributes as separate outputs.
- datajoint.key is a special value that requests the entire primary key
- :return: tuple with an entry for each element of item
-
- Examples:
- >>> a, b = relation['a', 'b']
- >>> a, b, key = relation['a', 'b', datajoint.key]
- """
-
- warnings.warn('Use of `rel.fetch[a, b]` notation is deprecated. Please use `rel.fetch(a, b) for equivalent '
- 'result', stacklevel=2)
-
- behavior = dict(self.sql_behavior)
- behavior.update(self.ext_behavior)
-
- single_output = isinstance(item, str) or item is PRIMARY_KEY or isinstance(item, int)
- item, attributes = self._prepare_attributes(item)
- result = self._relation.proj(*attributes).fetch(**behavior)
- return_values = [
- list(to_dicts(result[self._relation.primary_key]))
- if attribute is PRIMARY_KEY else result[attribute]
- for attribute in item]
- return return_values[0] if single_output else return_values
-
- def __repr__(self):
- repr_str = """Fetch object for {items} items on {name}\n""".format(name=self._relation.__class__.__name__,
- items=len(self._relation) )
- behavior = dict(self.sql_behavior)
- behavior.update(self.ext_behavior)
- repr_str += '\n'.join(
- ["\t{key}:\t{value}".format(key=k, value=str(v)) for k, v in behavior.items() if v is not None])
- return repr_str
-
-
-class Fetch1(FetchBase, Callable):
- """
- Fetch object for fetching exactly one row.
-
- :param relation: relation the fetch object fetches data from
- """
-
- def __call__(self, *attrs, **kwargs):
- """
- This version of fetch is called when self is expected to contain exactly one tuple.
- :return: the one tuple in the relation in the form of a dict
- """
- heading = self._relation.heading
- ext_behavior = update_dict(self.ext_behavior, kwargs)
- unpack_ = partial(unpack, squeeze=ext_behavior['squeeze'])
-
- if len(attrs) == 0: # fetch all attributes
- cur = self._relation.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, unpack_(ret[name]) if heading[name].is_blob else ret[name])
- for name in heading.names)
- else:
- attributes = [a for a in attrs if a is not PRIMARY_KEY]
- result = self._relation.proj(*attributes).fetch(**ext_behavior)
- 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._relation.primary_key]))
- if attribute is PRIMARY_KEY else result[attribute][0]
- for attribute in attrs)
- ret = return_values[0] if len(attrs) == 1 else return_values
-
- return ret
-
- def __getitem__(self, item):
- """
- DEPRECATED
-
- Fetch attributes as separate outputs.
- datajoint.key is a special value that requests the entire primary key
- :return: tuple with an entry for each element of item
-
- Examples:
-
- >>> a, b = relation['a', 'b']
- >>> a, b, key = relation['a', 'b', datajoint.key]
-
- """
- warnings.warn('Use of `rel.fetch[a, b]` notation is deprecated. Please use `rel.fetch(a, b) for equivalent '
- 'result', stacklevel=2)
-
- behavior = dict(self.sql_behavior)
- behavior.update(self.ext_behavior)
-
- single_output = isinstance(item, str) or item is PRIMARY_KEY
- item, attributes = self._prepare_attributes(item)
- result = self._relation.proj(*attributes).fetch(**behavior)
- 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._relation.primary_key]))
- if attribute is PRIMARY_KEY else result[attribute][0]
- for attribute in item)
- return return_values[0] if single_output else return_values
-
-
-def to_dicts(recarray):
- for rec in recarray:
- yield dict(zip(recarray.dtype.names, rec.tolist()))
diff --git a/datajoint/hash.py b/datajoint/hash.py
deleted file mode 100644
index c83f7a12e..000000000
--- a/datajoint/hash.py
+++ /dev/null
@@ -1,34 +0,0 @@
-import hashlib
-import base64
-
-
-def to_ascii(byte_string):
- """
- :param byte_string: a binary string
- :return: web-safe 64-bit ASCII encoding of binary strings
- """
- return base64.b64encode(byte_string, b'-_').decode()
-
-
-def long_hash(buffer):
- """
- :param buffer: a binary buffer (e.g. serialized blob)
- :return: 43-character base64 ASCII rendition SHA-256
- """
- return to_ascii(hashlib.sha256(buffer).digest())[0:43]
-
-
-def short_hash(buffer):
- """
- :param buffer: a binary buffer (e.g. serialized blob)
- :return: the first 8 characters of base64 ASCII rendition SHA-1
- """
- return to_ascii(hashlib.sha1(buffer).digest())[:8]
-
-
-# def filehash(filename):
-# s = hashlib.sha256()
-# with open(filename, 'rb') as f:
-# for block in iter(lambda: f.read(65536), b''):
-# s.update(block)
-# return to_ascii(s.digest())
diff --git a/datajoint/heading.py b/datajoint/heading.py
deleted file mode 100644
index 00f2101b9..000000000
--- a/datajoint/heading.py
+++ /dev/null
@@ -1,276 +0,0 @@
-import numpy as np
-from collections import namedtuple, OrderedDict
-import re
-import logging
-from . import DataJointError
-
-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, is_blob=False, sql_expression=None, dtype=object)
-
-
-class Attribute(namedtuple('_Attribute', default_attribute_properties.keys())):
- """
- 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(self):
- """
- Convert primary key attribute tuple into its SQL CREATE TABLE clause.
- Default values are not reflected.
- :return: SQL code
- """
- assert self.in_key and not self.nullable # primary key attributes are never nullable
- return '`{name}` {type} NOT NULL COMMENT "{comment}"'.format(
- name=self.name, type=self.type, comment=self.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.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 dependent_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]
-
- @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):
- 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):
- """
- 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['nullable'] = (attr['nullable'] == 'YES')
- attr['in_key'] = (attr['in_key'] == 'PRI')
- attr['autoincrement'] = bool(re.search(r'auto_increment', attr['Extra'], flags=re.IGNORECASE))
- attr['type'] = re.sub(r'int\(\d+\)', 'int', attr['type'], count=1) # strip size off integers
- attr['numeric'] = bool(re.match(r'(tiny|small|medium|big)?int|decimal|double|float', attr['type']))
- attr['string'] = bool(re.match(r'(var)?char|enum|date|year|time|timestamp', attr['type']))
- attr['is_blob'] = bool(re.match(r'(tiny|medium|long)?blob', attr['type']))
-
- 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'
-
- attr['sql_expression'] = None
- if not (attr['numeric'] or attr['string'] or attr['is_blob']):
- raise DataJointError('Unsupported field type {field} in `{database}`.`{table_name}`'.format(
- field=attr['type'], database=database, table_name=table_name))
- attr.pop('Extra')
-
- # fill out dtype. All floats and non-nullable integers are turned into specific dtypes
- attr['dtype'] = object
- if attr['numeric']:
- is_integer = bool(re.match(r'(tiny|small|medium|big)?int', attr['type']))
- is_float = bool(re.match(r'(double|float)', attr['type']))
- if is_integer and not attr['nullable'] or is_float:
- is_unsigned = bool(re.match('\sunsigned', attr['type'], flags=re.IGNORECASE))
- t = attr['type']
- t = re.sub(r'\(.*\)', '', t) # 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)]
- self.attributes = OrderedDict([(q['name'], Attribute(**q)) for q in attributes])
-
- 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()
- return Heading(
- [dict( # copied attributes
- self.attributes[k].todict(),
- in_key=self.attributes[k].in_key or k in force_primary_key)
- for k in attribute_list] +
- [dict( # renamed attributes
- self.attributes[sql_expression].todict(),
- name=new_name,
- sql_expression='`%s`' % sql_expression,
- in_key=self.attributes[sql_expression].in_key or sql_expression in force_primary_key)
- if sql_expression in self.names else
- dict( # computed attributes
- default_attribute_properties,
- name=new_name,
- sql_expression=sql_expression)
- for new_name, sql_expression in named_attributes.items()])
-
- 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.dependent_attributes if name not in other.primary_key] +
- [other.attributes[name].todict() for name in other.dependent_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 5563125bc..000000000
--- a/datajoint/jobs.py
+++ /dev/null
@@ -1,135 +0,0 @@
-import hashlib
-import os
-import pymysql
-from .base_relation import BaseRelation
-
-ERROR_MESSAGE_LENGTH = 2047
-TRUNCATION_APPENDIX = '...truncated'
-
-
-def key_hash(key):
- """
- 32-byte hash used for lookup of primary keys of jobs
- """
- hashed = hashlib.md5()
- for k, v in sorted(key.items()):
- hashed.update(str(v).encode())
- return hashed.hexdigest()
-
-
-class JobTable(BaseRelation):
- """
- 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=key_hash(key),
- status='reserved',
- host=os.uname().nodename,
- pid=os.getpid(),
- connection_id=self.connection.connection_id,
- key=key,
- user=self._user)
- try:
- self.insert1(job, ignore_extra_fields=True)
- except pymysql.err.IntegrityError:
- return False
- return True
-
- def complete(self, table_name, key):
- """
- Log a completed job. When a job is completed, its reservation entry is deleted.
- :param table_name: `database`.`table_name`
- :param key: the dict of the job's primary key
- """
- job_key = dict(table_name=table_name, key_hash=key_hash(key))
- (self & job_key).delete_quick()
-
- def error(self, table_name, key, error_message):
- """
- 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
- """
- if len(error_message) > ERROR_MESSAGE_LENGTH:
- error_message = error_message[:ERROR_MESSAGE_LENGTH-len(TRUNCATION_APPENDIX)] + TRUNCATION_APPENDIX
- job_key = dict(table_name=table_name, key_hash=key_hash(key))
- self.insert1(
- dict(job_key,
- status="error",
- host=os.uname().nodename,
- pid=os.getpid(),
- connection_id=self.connection.connection_id,
- user=self._user,
- key=key,
- error_message=error_message), replace=True, ignore_extra_fields=True)
-
-
-class JobManager:
- """
- A container for all job tables (one job table per schema).
- """
- def __init__(self, connection):
- self.connection = connection
- self._jobs = {}
-
- def __getitem__(self, database):
- if database not in self._jobs:
- self._jobs[database] = JobTable(self.connection, database)
- return self._jobs[database]
diff --git a/datajoint/relational_operand.py b/datajoint/relational_operand.py
deleted file mode 100644
index 754258655..000000000
--- a/datajoint/relational_operand.py
+++ /dev/null
@@ -1,806 +0,0 @@
-import collections
-from itertools import zip_longest
-import logging
-import numpy as np
-import re
-import datetime
-import decimal
-from . import DataJointError, config
-from .fetch import Fetch, Fetch1
-
-logger = logging.getLogger(__name__)
-
-
-def equal_ignore_case(str1, str2):
- try:
- return str1.upper() == str2.upper()
- except AttributeError:
- return False
-
-
-def restricts_to_same(arg):
- """
- returns True if restriction with arg produces the same result as not restricting at all
- """
- return (isinstance(arg, U) or arg is True or equal_ignore_case(arg, "TRUE") or
- isinstance(arg, Not) and restricts_to_empty(arg.restriction))
-
-
-def restricts_to_empty(arg):
- """
- returns True if restriction with arg must produce the empty relation.
- """
- or_lists = (list, set, tuple, np.ndarray)
- return (arg is None or (isinstance(arg, AndList) and any(restricts_to_empty(r) for r in arg)) or
- arg is None or arg is False or equal_ignore_case(arg, "FALSE") or
- isinstance(arg, or_lists) and len(arg) == 0 or # empty OR-list equals FALSE
- isinstance(arg, Not) and restricts_to_same(arg.restriction))
-
-
-class AndList(list):
- """
- A list of restrictions to by applied to a relation. The restrictions are AND-ed.
- Each restriction can be a list or set or a relation 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
- """
- pass
-
-
-class OrList(list):
- """
- A list of restrictions to by applied to a relation. The restrictions are OR-ed.
- If any restriction is .
- But the elements that are lists can contain other AndLists.
-
- Example:
- rel2 = rel & dj.ORList((cond1, cond2, cond3))
- is equivalent to
- rel2 = rel & [cond1, cond2, cond3]
-
- Since ORList is just an alias for list, it is not necessary and is only provided
- for consistency with AndList.
- """
- pass
-
-
-class RelationalOperand:
- """
- RelationalOperand implements the relational algebra.
- RelationalOperand objects link other relational operands with relational operators.
- The leaves of this tree of objects are base relations.
- When fetching data from the database, this tree of objects is compiled into an SQL expression.
- RelationalOperand operators are restrict, join, proj, and aggregate.
- """
-
- def __init__(self, arg=None):
- if arg is None: # initialize
- # initialize
- self._restrictions = AndList()
- self._distinct = False
- else: # copy
- assert isinstance(arg, RelationalOperand), 'Cannot make RelationalOperand from %s' % arg.__class__.__name__
- self._restrictions = AndList(arg._restrictions)
- self._distinct = arg.distinct
-
- @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 of the relation
- """
- return self._heading
-
- @property
- def distinct(self):
- """True if the DISTINCT modifier is required to turn the query into a relation"""
- return self._distinct
-
- @property
- def restrictions(self):
- """
- :return: The AndList of restrictions applied to the relation.
- """
- assert isinstance(self._restrictions, AndList)
- return self._restrictions
-
- @property
- def is_restricted(self):
- return len(self.restrictions) > 0
-
- @property
- def primary_key(self):
- return self.heading.primary_key
-
- @property
- def where_clause(self):
- """
- convert self.restrictions to the SQL WHERE clause
- """
-
- def make_condition(arg, _negate=False):
- if isinstance(arg, str):
- return arg, _negate
- elif isinstance(arg, AndList):
- return '(' + ' AND '.join([make_condition(element)[0] for element in arg]) + ')', _negate
- # semijoin or antijoin
- elif isinstance(arg, RelationalOperand):
- common_attributes = [q for q in self.heading.names if q in arg.heading.names]
- if not common_attributes:
- condition = 'FALSE' if _negate else 'TRUE'
- else:
- condition = '({fields}) {not_}in ({subquery})'.format(
- fields='`' + '`,`'.join(common_attributes) + '`',
- not_="not " if _negate else "",
- subquery=arg.make_sql(common_attributes))
- return condition, False # _negate is cleared
-
- # mappings are turned into ANDed equality conditions
- elif isinstance(arg, collections.abc.Mapping):
- condition = ['`%s`=%r' % (k, (v if not isinstance(v, (
- datetime.date, datetime.datetime, datetime.time, decimal.Decimal)) else str(v)))
- for k, v in arg.items() if k in self.heading]
- elif isinstance(arg, np.void):
- # element of a record array
- condition = [('`%s`='+('%s' if self.heading[k].numeric else '"%s"')) % (k, arg[k])
- for k in arg.dtype.fields if k in self.heading]
- else:
- raise DataJointError('Invalid restriction type')
- return ' AND '.join(condition) if condition else 'TRUE', _negate
-
- if not self.is_restricted:
- return ''
-
- # An empty or-list in the restrictions immediately causes an empty result
- if restricts_to_empty(self.restrictions):
- return ' WHERE FALSE'
-
- conditions = []
- for item in self.restrictions:
- negate = isinstance(item, Not)
- if negate:
- item = item.restriction # NOT is added below
- if isinstance(item, (list, tuple, set, np.ndarray)):
- item = '(' + ') OR ('.join(
- [make_condition(q)[0] for q in item if q is not restricts_to_empty(q)]) + ')'
- else:
- item, negate = make_condition(item, negate)
- conditions.append(('NOT (%s)' if negate else '(%s)') % item)
- return ' WHERE ' + ' AND '.join(conditions)
-
- 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
-
- # --------- relational operators -----------
-
- def __mul__(self, other):
- """
- natural join of relations self and other
- """
- return other * self if isinstance(other, U) else Join.create(self, other)
-
- def proj(self, *attributes, **named_attributes):
- """
- Relational 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 relation.
- Primary key attributes are always cannot be excluded but may be renamed.
- Thus self.proj() produces the relation with only the primary key 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 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.
- """
- ret = Projection.create(self, attributes, named_attributes)
- return ret
-
- def aggregate(self, group, *attributes, keep_all_rows=False, **named_attributes):
- """
- Relational aggregation/projection operator
- :param group: relation whose tuples can be used in aggregation operators
- :param attributes: attributes of self to include in the resulting relation
- :param keep_all_rows: True = preserve the number of tuples in the result (equivalent of LEFT JOIN in SQL)
- :param named_attributes: renamings and computations on attributes of self and group
- :return: a relation representing the result of the aggregation/projection operator
- """
- return GroupBy.create(self, group, keep_all_rows=keep_all_rows,
- attributes=attributes, named_attributes=named_attributes)
-
- aggr = aggregate # shorthand
-
- 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 relational_operand.restrict for more detail.
- """
- return (Subquery.create(self) if self.heading.expressions else self).restrict(restriction)
-
- def __and__(self, restriction):
- """
- relational restriction or semijoin
- :return: a restricted copy of the argument
- See relational_operand.restrict for more detail.
- """
- return (Subquery.create(self) # the HAVING clause in GroupBy can handle renamed attributes but WHERE cannot
- if 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 relational_operand.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 relational_operand.restrict for more detail.
- """
- return self & Not(restriction)
-
- def restrict(self, restriction):
- """
- In-place restriction. Restricts the relation to a subset of its original tuples.
- 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 using the logical AND.
- The AndList class is provided to play the role of successive restrictions.
- Any relation, collection, or sequence other than an AndList are treated as OrLists.
- However, the class OrList is still provided for cases when explicitness is required.
- 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 relation
- rel & 'TRUE' rel
- rel & 'FALSE' the empty relation
- 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_relation 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_relation rel
-
- When arg is another relation, the restrictions rel & arg and rel - arg become the relational semijoin and
- antijoin operators, respectively.
- Then, rel & arg restricts rel to tuples that match at least one tuple in arg (hence arg is treated as an OrList).
- Conversely, rel - arg restricts rel to tuples that do not match any tuples in arg.
- Two tuples 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.
-
- relational_operand.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 relation, an SQL condition string, or
- an AndList.
- """
- if not restricts_to_same(restriction):
- assert not self.heading.expressions or isinstance(self, GroupBy), \
- "Cannot restrict in place a projection with renamed attributes."
- if isinstance(restriction, AndList):
- self.restrictions.extend(restriction)
- else:
- self.restrictions.append(restriction)
- return self
-
- @property
- def fetch1(self):
- return Fetch1(self)
-
- @property
- def fetch(self):
- return Fetch(self)
-
- def attributes_in_restriction(self):
- """
- :return: list of attributes that are probably used in the restrictions.
- 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 relation.
- """
- rel = self.proj(*self.heading.non_blobs,
- **dict(zip_longest(self.heading.blobs, [], fillvalue="''"))) # replace blobs with
- if limit is None:
- limit = config['display.limit']
- if width is None:
- width = config['display.width']
- tuples = rel.fetch(limit=limit+1)
- has_more = len(tuples) > limit
- tuples = tuples[:limit]
- columns = rel.heading.names
- widths = {f: min(max([len(f)] + [len(str(e)) for e in tuples[f]]) + 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] for f in columns) for tup in tuples) +
- ('\n ...\n' if has_more else '\n') +
- (' (%d tuples)\n' % len(rel) if config['display.show_tuple_count'] else ''))
-
- def _repr_html_(self):
- rel = self.proj(*self.heading.non_blobs,
- **dict(zip_longest(self.heading.blobs, [], fillvalue="'=BLOB='"))) # replace blobs with =BLOB=
- info = self.heading.table_info
- tuples = rel.fetch(limit=config['display.limit']+1)
- 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=rel.heading.attributes[c].comment,
- primary='primary' if c in self.primary_key else 'nonprimary') for c in
- rel.heading.names),
- ellipsis='
...
' if has_more else '',
- body='
'.join(
- ['\n'.join(['
%s
' % column for column in tup])
- for tup in tuples]),
- count=('
%d tuples
' % 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 tuples in the relation.
- """
- return U().aggr(self, n='count(*)').fetch1('n')
-
- def __bool__(self):
- """
- :return: True if the relation 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 relation.
- :param item: any restriction
- (item in relation) is equivalent to bool(self & item) but may be executed more efficiently.
- """
- return bool(self & item) # May be optimized e.g. using an EXISTS query
-
- def cursor(self, offset=0, limit=None, order_by=None, as_dict=False):
- """
- See Relation.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 = True if isinstance(restriction, U) else restriction
-
-
-class Join(RelationalOperand):
- """
- Relational join.
- Join is a private DataJoint class not exposed to users.
- """
-
- 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 not isinstance(arg1, RelationalOperand) or not isinstance(arg2, RelationalOperand):
- raise DataJointError('a relation can only be joined with another relation')
- if arg1.connection != arg2.connection:
- raise DataJointError("Cannot join relations 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
- try:
- # ensure no common dependent attributes
- raise DataJointError("Cannot join relations on dependent attribute `%s`" % next(r for r in set(
- obj._arg1.heading.dependent_attributes).intersection(obj._arg2.heading.dependent_attributes)))
- except StopIteration:
- obj._heading = obj._arg1.heading.join(obj._arg2.heading)
- obj.restrict(obj._arg1.restrictions)
- obj.restrict(obj._arg2.restrictions)
- 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)) 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 Projection(RelationalOperand):
- """
- Projection is an private DataJoint class that implements relational projection.
- See RelationalOperand.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
-
- @classmethod
- def create(cls, arg, attributes=None, named_attributes=None, include_primary_key=True):
- """
- :param arg: A relation to be be projected
- :param attributes: attributes to be selected from
- :param named_attributes: new attributes to select or
- :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
- named_attributes = {k: v.strip() for k, v in named_attributes.items()} # clean up values
- obj._distinct = arg.distinct
- if include_primary_key: # include primary key of relation
- 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)
- else:
- obj._arg = arg
- obj._heading = obj._arg.heading.project(attributes, named_attributes)
- obj &= arg.restrictions # copy restrictions 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(RelationalOperand):
- """
- GroupBy(rel, comp1='expr1', ..., compn='exprn') produces a relation with the primary key specified by rel.heading.
- The computed arguments comp1, ..., compn use aggregation operators on the attributes of rel.
- GroupBy is used RelationalOperand.aggregate and U.aggregate.
- 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=None, named_attributes=None, keep_all_rows=False):
- if not isinstance(group, RelationalOperand):
- raise DataJointError('a relation can only be joined with another relation')
- obj = cls()
- obj._keep_all_rows = keep_all_rows
- if not (set(group.primary_key) - set(arg.primary_key) or set(group.primary_key) == set(arg.primary_key)):
- raise DataJointError(
- 'The aggregated relation should have additional fields in its primary key for aggregation to work')
- 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(RelationalOperand):
- """
- 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.
- """
- __counter = 0
-
- 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 counter(self):
- Subquery.__counter += 1
- return Subquery.__counter
-
- @property
- def from_clause(self):
- return '(' + self._arg.make_sql() + ') as `_s%x`' % self.counter
-
- 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 special relations representing all possible values their attributes.
- dj.U objects cannot be queried on their own but are useful for forming some relational queries.
- dj.U('attr1', ..., 'attrn') represents a relation with the primary key attributes attr1 ... attrn.
- The body of the relation is filled with all possible combinations of values of the attributes.
- Without any attributes, dj.U() represents the relation with one tuple and no attributes.
- The Third Manifesto refers to dj.U() as TABLE_DEE.
-
- Relational restriction:
-
- dj.U can be used to enumerate unique combinations of values of attributes from other relations.
-
- The following expression produces a relation containing all unique combinations of contrast and brightness
- found in relation stimulus:
-
- >>> dj.U('contrast', 'brightness') & stimulus
-
- The following expression produces a relation containing all unique combinations of contrast and brightness that is
- contained in relation1 but not contained in relation 2.
-
- >>> (dj.U('contrast', 'brightness') & relation1) - relation2
-
- Relational aggregation:
-
- In aggregation, dj.U is used to compute aggregate expressions on the entire relation.
-
- The following expression produces a relation with one tuple and one attribute s containing the total number
- of tuples in relation:
-
- >>> dj.U().aggregate(relation, n='count(*)')
-
- The following expression produces a relation with one tuple containing the number n of distinct values of attr
- in relation.
-
- >>> dj.U().aggregate(relation, n='count(distinct attr)')
-
- The following expression produces a relation with one tuple and one attribute s containing the total sum of attr
- from relation:
-
- >>> dj.U().aggregate(relation, s='sum(attr)') # sum of attr from the entire relation
-
- The following expression produces a relation with the count n of tuples in relation containing each unique
- combination of values in attr1 and attr2.
-
- >>> dj.U(attr1,attr2).aggregate(relation, n='count(*)')
-
- Joins:
-
- If relation rel has attributes 'attr1' and 'attr2', then rel*dj.U('attr1','attr2') or produces a relation that is
- identical to rel except attr1 and attr2 are included in the primary key. This is useful for producing a join on
- non-primary key attributes.
- For example, if attr is in both rel1 and rel2 but not in their primary keys, then rel1*rel2 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.
- Join is commutative.
- """
-
- def __init__(self, *primary_key):
- self._primary_key = primary_key
-
- @property
- def primary_key(self):
- return self._primary_key
-
- def __and__(self, relation):
- if not isinstance(relation, RelationalOperand):
- raise DataJointError('Relation U can only be restricted with another relation.')
- return Projection.create(relation, attributes=self.primary_key,
- named_attributes=dict(), include_primary_key=False)
-
- def __mul__(self, relation):
- """
- Joining relation U * relation has the effect of adding the attributes of U to the primary key of
- the other relation.
- :param relation: other relation
- :return: a copy of the other relation with the primary key extended.
- """
- if not isinstance(relation, RelationalOperand):
- raise DataJointError('Relation U can only be joined with another relation.')
- copy = relation.__class__(relation)
- copy._heading = copy.heading.extend_primary_key(self.primary_key)
- return copy
-
- def aggregate(self, group, **named_attributes):
- """
- Aggregation of the type U('attr1','attr2').aggregate(rel, computation="expression")
- has the primary key ('attr1','attr2') and performs aggregation computations for all matching tuples of relation.
- :param group: The other relation which will be aggregated
- :param named_attributes: computations of the form new_attribute="sql expression on attributes of group"
- :return: The new relation
- """
- return (
- GroupBy.create(self, group=group, keep_all_rows=False, attributes=(), named_attributes=named_attributes)
- if self.primary_key else
- Projection.create(group, attributes=(), named_attributes=named_attributes, include_primary_key=False))
-
- aggr = aggregate # shorthand
diff --git a/datajoint/schema.py b/datajoint/schema.py
deleted file mode 100644
index 9862408e9..000000000
--- a/datajoint/schema.py
+++ /dev/null
@@ -1,227 +0,0 @@
-import warnings
-import pymysql
-import logging
-import inspect
-import re
-from . import conn, DataJointError, config
-from .erd import ERD
-from .heading import Heading
-from .utils import user_choice, to_camel_case
-from .user_relations import Part, Computed, Imported, Manual, Lookup
-from .base_relation import lookup_class_name, Log
-
-logger = logging.getLogger(__name__)
-
-
-def ordered_dir(klass):
- """
- List (most) attributes of the class including inherited ones, similar to `dir` build-in function,
- but respects order of attribute declaration as much as possible.
- :param klass: class to list members for
- :return: a list of attributes declared in klass and its superclasses
- """
- m = []
- mro = klass.mro()
- for c in mro:
- if hasattr(c, '_ordered_class_members'):
- elements = c._ordered_class_members
- else:
- elements = c.__dict__.keys()
- m = [e for e in elements if e not in m] + m
- return m
-
-
-class Schema:
- """
- A schema object is a decorator for UserRelation classes that binds them to their database.
- It also specifies the namespace `context` in which other UserRelation classes are defined.
- """
-
- def __init__(self, database, context, connection=None, create_tables=True):
- """
- Associates the specified database with this schema object. If the target database does not exist
- already, will attempt on creating the database.
-
- :param database: name of the database to associate the decorated class with
- :param context: dictionary for looking up foreign keys references, usually set to locals()
- :param connection: Connection object. Defaults to datajoint.conn()
- """
- if connection is None:
- connection = conn()
- self._log = None
- self.database = database
- self.connection = connection
- self.context = context
- self.create_tables = create_tables
- if not self.exists:
- if not self.create_tables:
- raise DataJointError("Database named `{database}` was not defined. "
- "Set the create_tables flag to create it.".format(database=database))
- else:
- # create database
- logger.info("Database `{database}` could not be found. "
- "Attempting to create the database.".format(database=database))
- try:
- connection.query("CREATE DATABASE `{database}`".format(database=database))
- logger.info('Created database `{database}`.'.format(database=database))
- except pymysql.OperationalError:
- raise DataJointError("Database named `{database}` was not defined, and"
- " an attempt to create has failed. Check"
- " permissions.".format(database=database))
- 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 database: `{database}` in module: {context}\n'.format(
- database=self.database,
- context=self.context['__name__'] if '__name__' in self.context else "__")
-
- @property
- def size_on_disk(self):
- """
- :return: size of the database 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):
- """
- Creates the appropriate python user relation classes from tables in the database and places them
- in the context.
- """
- 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]), self.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 self.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
- self.context[class_name] = self(type(class_name, (cls,), dict()))
-
- # 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 = self.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_relation_class(part_class, context=self.context, assert_declared=True)
- setattr(master_class, class_name, part_class)
-
- def drop(self, force=False):
- """
- Drop the associated database if it exists
- """
- if not self.exists:
- logger.info("Database 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("Database `{database}` was dropped successfully.".format(database=self.database))
- except pymysql.OperationalError:
- raise DataJointError("An attempt to drop database named `{database}` "
- "has failed. Check permissions.".format(database=self.database))
-
- @property
- def exists(self):
- """
- :return: true if the associated database exists on the server
- """
- cur = self.connection.query("SHOW DATABASES LIKE '{database}'".format(database=self.database))
- return cur.rowcount > 0
-
- def process_relation_class(self, relation_class, context, assert_declared=False):
- """
- assign schema properties to the relation class and declare the table
- """
- relation_class.database = self.database
- relation_class._connection = self.connection
- relation_class._heading = Heading()
- relation_class._context = context
- # instantiate the class, declare the table if not already
- instance = relation_class()
- if not instance.is_declared:
- if not self.create_tables or assert_declared:
- raise DataJointError('Table not declared %s' % instance.table_name)
- else:
- instance.declare()
-
- # fill values in Lookup tables from their contents property
- if instance.is_declared and hasattr(instance, 'contents') and isinstance(instance, Lookup):
- 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):
- """
- Binds the passed in class object to a database. This is intended to be used as a decorator.
- :param cls: class to be decorated
- """
-
- if issubclass(cls, Part):
- raise DataJointError('The schema decorator should not be applied to Part relations')
- ext = {
- cls.__name__: cls,
- 'self': cls}
- self.process_relation_class(cls, context=dict(self.context, **ext))
-
- # 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'
- ext = {
- cls.__name__: cls,
- part.__name__: part,
- 'master': cls,
- 'self': part}
- self.process_relation_class(part, context=dict(self.context, **ext))
- return cls
-
- @property
- def jobs(self):
- """
- schema.jobs provides a view of the job reservation table for the schema
- :return: jobs relation
- """
- return self.connection.jobs[self.database]
-
- def erd(self):
- # get the caller's locals()
- import inspect
- frame = inspect.currentframe()
- context = frame.f_back.f_locals
- return ERD(self, context=context)
-
diff --git a/datajoint/settings.py b/datajoint/settings.py
deleted file mode 100644
index b7bc1a4e9..000000000
--- a/datajoint/settings.py
+++ /dev/null
@@ -1,179 +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 . import DataJointError
-
-LOCALCONFIG = 'dj_local_conf.json'
-GLOBALCONFIG = '.datajoint_config.json'
-
-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.keys()))
-
-
-server_error_codes = {
- 'command denied': 1142,
- 'tables does not exist': 1146,
- 'syntax error': 1149
-}
-
-
-default = OrderedDict({
- 'database.host': 'localhost',
- 'database.password': None,
- 'database.user': None,
- 'database.port': 3306,
- 'connection.init_function': None,
- 'database.reconnect': False,
- 'loglevel': 'INFO',
- 'safemode': True,
- 'display.limit': 7,
- 'display.width': 14,
- 'display.show_tuple_count': True
-})
-
-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 add_history(self, item):
- self.update({'history': self.get('history', []) + [item]})
-
- 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):
- """
- Saves the settings in JSON format to the given file path.
- :param filename: filename of the local JSON settings file.
- """
- with open(filename, 'w') as fid:
- json.dump(self._conf, fid, indent=4)
-
- 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))
- self.add_history('Updated from config file: %s' % filename)
-
- def save_local(self):
- """
- saves the settings in the local config file
- """
- self.save(LOCALCONFIG)
-
- def save_global(self):
- """
- saves the settings in the global config file
- """
- self.save(os.path.expanduser(os.path.join('~', GLOBALCONFIG)))
-
- @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 isinstance(value, collections.Mapping):
- raise ValueError("Nested settings are not supported!")
- if validators[key](value):
- self._conf[key] = value
- else:
- raise DataJointError(u'Validator for {0:s} did not pass'.format(key, ))
\ No newline at end of file
diff --git a/datajoint/user_relations.py b/datajoint/user_relations.py
deleted file mode 100644
index 6f7074c80..000000000
--- a/datajoint/user_relations.py
+++ /dev/null
@@ -1,147 +0,0 @@
-"""
-Hosts the table tiers, user relations should be derived from.
-"""
-
-import collections
-from .base_relation import BaseRelation
-from .autopopulate import AutoPopulate
-from .utils import from_camel_case, ClassProperty
-from . import DataJointError
-
-_base_regexp = r'[a-z][a-z0-9]*(_[a-z][a-z0-9]*)*'
-
-
-class OrderedClass(type):
- """
- Class whose members are ordered
- See https://docs.python.org/3/reference/datamodel.html#metaclass-example
-
- TODO: In Python 3.6, this will no longer be necessary and should be removed (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)
-
-
-class UserRelation(BaseRelation, metaclass=OrderedClass):
- """
- A subclass of UserRelation is a dedicated class interfacing a base relation.
- UserRelation is initialized by the decorator generated by schema().
- """
- _connection = None
- _context = 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 BaseRelation 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.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(UserRelation):
- """
- Inherit from this class if the table's values are entered manually.
- """
-
- _prefix = r''
- tier_regexp = r'(?P' + _prefix + _base_regexp + ')'
-
-
-class Lookup(UserRelation):
- """
- 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(UserRelation, 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(UserRelation, 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(UserRelation):
- """
- 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
- _context = 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__)
\ No newline at end of file
diff --git a/datajoint/utils.py b/datajoint/utils.py
deleted file mode 100644
index c202c232e..000000000
--- a/datajoint/utils.py
+++ /dev/null
@@ -1,68 +0,0 @@
-import re
-from datajoint import DataJointError
-
-class ClassProperty:
- def __init__(self, f):
- self.f = f
-
- def __get__(self, obj, owner):
- return self.f(owner)
-
-
-def user_choice(prompt, choices=("yes", "no"), default=None):
- """
- Prompts the user for confirmation. The default value, if any, is capitalized.
-
- :param prompt: Information to display to the user.
- :param choices: an iterable of possible choices.
- :param default: default choice
- :return: the user's choice
- """
- choice_list = ', '.join((choice.title() if choice == default else choice for choice in choices))
- valid = False
- while not valid:
- response = input(prompt + ' [' + choice_list + ']: ')
- response = response.lower() if response else default
- valid = response in choices
- 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)
diff --git a/datajoint/version.py b/datajoint/version.py
deleted file mode 100644
index 777f190df..000000000
--- a/datajoint/version.py
+++ /dev/null
@@ -1 +0,0 @@
-__version__ = "0.8.0"
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/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 929e8a2c5..000000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-version: '3'
-services:
- datajoint:
- build: .
- environment:
- - DJ_HOST=db
- - DJ_USER=root
- - DJ_PASS=simple
- volumes:
- - .:/src
- links:
- - db
- ports:
- - "8888:8888"
- db:
- image: datajoint/mysql
- environment:
- - MYSQL_ROOT_PASSWORD=simple
-
-
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/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 88a246fe7..000000000
--- a/requirements.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-numpy
-pymysql>=0.7.2
-pyparsing
-ipython
-networkx
-pydotplus
diff --git a/setup.py b/setup.py
deleted file mode 100644
index bd916c260..000000000
--- a/setup.py
+++ /dev/null
@@ -1,32 +0,0 @@
-#!/usr/bin/env python
-from setuptools import setup, find_packages
-from os import path
-import sys
-
-if sys.version_info < (3,4):
- sys.exit('DataJoint is only supported on Python 3.4 or higher')
-
-here = path.abspath(path.dirname(__file__))
-
-long_description = "An object-relational mapping and relational algebra to facilitate data definition and data manipulation in MySQL databases."
-
-# 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="An ORM with closed relational algebra",
- long_description=long_description,
- author='Dimitri Yatsenko',
- author_email='Dimitri.Yatsenko@gmail.com',
- license="GNU LGPL",
- url='https://github.com/datajoint/datajoint-python',
- keywords='database organization',
- packages=find_packages(exclude=['contrib', 'docs', 'tests*']),
- install_requires=requirements,
-)
diff --git a/src/datajoint/__init__.py b/src/datajoint/__init__.py
new file mode 100644
index 000000000..2e242ce84
--- /dev/null
+++ b/src/datajoint/__init__.py
@@ -0,0 +1,306 @@
+"""
+DataJoint for Python — a framework for scientific data pipelines.
+
+DataJoint introduces the Relational Workflow Model, where your database schema
+is an executable specification of your workflow. Tables represent workflow steps,
+foreign keys encode dependencies, and computations are declarative.
+
+Documentation: https://docs.datajoint.com
+Source: https://github.com/datajoint/datajoint-python
+
+Copyright 2014-2026 DataJoint Inc. and contributors.
+Licensed under the Apache License, Version 2.0.
+
+If DataJoint contributes to a publication, please cite:
+https://doi.org/10.1101/031658
+"""
+
+__author__ = "DataJoint Contributors"
+__date__ = "November 7, 2020"
+__all__ = [
+ "__author__",
+ "__version__",
+ "config",
+ "conn",
+ "Connection",
+ "Instance",
+ "Schema",
+ "VirtualModule",
+ "virtual_schema",
+ "list_schemas",
+ "Table",
+ "FreeTable",
+ "AutoPopulate",
+ "Job",
+ "Manual",
+ "Lookup",
+ "Imported",
+ "Computed",
+ "Part",
+ "Not",
+ "AndList",
+ "Top",
+ "U",
+ "Diagram",
+ "MatCell",
+ "MatStruct",
+ # Codec API
+ "Codec",
+ "SchemaCodec",
+ "list_codecs",
+ "get_codec",
+ "ObjectRef",
+ "NpyRef",
+ # SparkAdapter Codec Protocol
+ "SparkAdapter",
+ # Storage Adapter API
+ "StorageAdapter",
+ "get_storage_adapter",
+ # Other
+ "errors",
+ "migrate",
+ "deploy",
+ "DataJointError",
+ "ThreadSafetyError",
+ "logger",
+ "cli",
+ "ValidationResult",
+]
+
+# =============================================================================
+# Eager imports — core functionality needed immediately
+# =============================================================================
+from . import errors
+from . import migrate
+from . import deploy
+from .codecs import (
+ Codec,
+ get_codec,
+ list_codecs,
+)
+from .builtin_codecs import (
+ SchemaCodec,
+ NpyRef,
+)
+from .blob import MatCell, MatStruct
+from .connection import Connection
+from .errors import DataJointError, ThreadSafetyError
+from .expression import AndList, Not, Top, U
+from .instance import Instance, _ConfigProxy, _get_singleton_connection, _global_config, _check_thread_safe
+from .logging import logger
+from .objectref import ObjectRef
+from .spark import SparkAdapter
+from .storage_adapter import StorageAdapter, get_storage_adapter
+from .schemas import _Schema, VirtualModule, list_schemas, virtual_schema
+from .autopopulate import AutoPopulate
+from .jobs import Job
+from .table import FreeTable as _FreeTable, Table, ValidationResult
+from .user_tables import Computed, Imported, Lookup, Manual, Part
+from .version import __version__
+
+# =============================================================================
+# Singleton-aware API
+# =============================================================================
+# config is a proxy that delegates to the singleton instance's config
+config = _ConfigProxy()
+
+
+def conn(
+ host: str | None = None,
+ user: str | None = None,
+ password: str | None = None,
+ *,
+ reset: bool = False,
+ use_tls: bool | dict | None = None,
+) -> Connection:
+ """
+ Return a persistent connection object.
+
+ When called without arguments, returns the singleton connection using
+ credentials from dj.config. When connection parameters are provided,
+ updates the singleton connection with the new credentials.
+
+ Parameters
+ ----------
+ host : str, optional
+ Database hostname. If provided, updates singleton.
+ user : str, optional
+ Database username. If provided, updates singleton.
+ password : str, optional
+ Database password. If provided, updates singleton.
+ reset : bool, optional
+ If True, reset existing connection. Default False.
+ use_tls : bool or dict, optional
+ TLS encryption option.
+
+ Returns
+ -------
+ Connection
+ Database connection.
+
+ Raises
+ ------
+ ThreadSafetyError
+ If thread_safe mode is enabled.
+ """
+ import datajoint.instance as instance_module
+ from pydantic import SecretStr
+
+ _check_thread_safe()
+
+ # If reset requested, always recreate
+ # If credentials provided and no singleton exists, create one
+ # If credentials provided and singleton exists, return existing singleton
+ if reset or (
+ instance_module._singleton_connection is None and (host is not None or user is not None or password is not None)
+ ):
+ # Use provided values or fall back to config
+ host = host if host is not None else _global_config.database.host
+ user = user if user is not None else _global_config.database.user
+ raw_password = password if password is not None else _global_config.database.password
+ password = raw_password.get_secret_value() if isinstance(raw_password, SecretStr) else raw_password
+ port = _global_config.database.port
+ use_tls = use_tls if use_tls is not None else _global_config.database.use_tls
+
+ if user is None:
+ from .errors import DataJointError
+
+ raise DataJointError("Database user not configured. Set dj.config['database.user'] or pass user= argument.")
+ if password is None:
+ from .errors import DataJointError
+
+ raise DataJointError(
+ "Database password not configured. Set dj.config['database.password'] or pass password= argument."
+ )
+
+ instance_module._singleton_connection = Connection(host, user, password, port, use_tls, config_override=_global_config)
+
+ return _get_singleton_connection()
+
+
+class Schema(_Schema):
+ """
+ Decorator that binds table classes to a database schema.
+
+ When connection is not provided, uses the singleton connection.
+ In thread-safe mode (``DJ_THREAD_SAFE=true``), a connection must be
+ provided explicitly or use ``dj.Instance().Schema()`` instead.
+
+ Parameters
+ ----------
+ schema_name : str, optional
+ Database schema name. If omitted, call ``activate()`` later.
+ context : dict, optional
+ Namespace for foreign key lookup. None uses caller's context.
+ connection : Connection, optional
+ Database connection. Defaults to singleton connection.
+ create_schema : bool, optional
+ If False, raise error if schema doesn't exist. Default True.
+ create_tables : bool, optional
+ If False, raise error when accessing missing tables.
+ add_objects : dict, optional
+ Additional objects for declaration context.
+
+ Raises
+ ------
+ ThreadSafetyError
+ If thread_safe mode is enabled and no connection is provided.
+
+ Examples
+ --------
+ >>> schema = dj.Schema('my_schema')
+ >>> @schema
+ ... class Session(dj.Manual):
+ ... definition = '''
+ ... session_id : int
+ ... '''
+ """
+
+ def __init__(
+ self,
+ schema_name: str | None = None,
+ context: dict | None = None,
+ *,
+ connection: Connection | None = None,
+ create_schema: bool = True,
+ create_tables: bool | None = None,
+ add_objects: dict | None = None,
+ ) -> None:
+ if connection is None:
+ _check_thread_safe()
+ super().__init__(
+ schema_name,
+ context=context,
+ connection=connection,
+ create_schema=create_schema,
+ create_tables=create_tables,
+ add_objects=add_objects,
+ )
+
+
+def FreeTable(conn_or_name, full_table_name: str | None = None) -> _FreeTable:
+ """
+ Create a FreeTable for accessing a table without a dedicated class.
+
+ Can be called in two ways:
+ - ``FreeTable("schema.table")`` - uses singleton connection
+ - ``FreeTable(connection, "schema.table")`` - uses provided connection
+
+ Parameters
+ ----------
+ conn_or_name : Connection or str
+ Either a Connection object, or the full table name if using singleton.
+ full_table_name : str, optional
+ Full table name when first argument is a connection.
+
+ Returns
+ -------
+ FreeTable
+ A FreeTable instance for the specified table.
+
+ Raises
+ ------
+ ThreadSafetyError
+ If thread_safe mode is enabled and using singleton.
+ """
+ if full_table_name is None:
+ # Called as FreeTable("db.table") - use singleton connection
+ _check_thread_safe()
+ return _FreeTable(_get_singleton_connection(), conn_or_name)
+ else:
+ # Called as FreeTable(conn, "db.table") - use provided connection
+ return _FreeTable(conn_or_name, full_table_name)
+
+
+# =============================================================================
+# Lazy imports — heavy dependencies loaded on first access
+# =============================================================================
+# These modules import heavy dependencies (networkx, matplotlib, click, pymysql)
+# that slow down `import datajoint`. They are loaded on demand.
+
+_lazy_modules = {
+ # Diagram imports networkx and matplotlib
+ "Diagram": (".diagram", "Diagram"),
+ "diagram": (".diagram", None), # Return the module itself
+ # cli imports click
+ "cli": (".cli", "cli"),
+ # gc — exposed lazily so `dj.gc.scan(...)` works as documented in gc.py
+ # and in the user docs (how-to/garbage-collection.md).
+ "gc": (".gc", None), # Return the module itself
+}
+
+
+def __getattr__(name: str):
+ """Lazy import for heavy dependencies."""
+ if name in _lazy_modules:
+ module_path, attr_name = _lazy_modules[name]
+ import importlib
+
+ module = importlib.import_module(module_path, __package__)
+ # If attr_name is None, return the module itself
+ attr = module if attr_name is None else getattr(module, attr_name)
+ # Cache in module __dict__ to avoid repeated __getattr__ calls
+ # and to override the submodule that importlib adds automatically
+ globals()[name] = attr
+ return attr
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/src/datajoint/adapters/__init__.py b/src/datajoint/adapters/__init__.py
new file mode 100644
index 000000000..5115a982a
--- /dev/null
+++ b/src/datajoint/adapters/__init__.py
@@ -0,0 +1,54 @@
+"""
+Database adapter registry for DataJoint.
+
+This module provides the adapter factory function and exports all adapters.
+"""
+
+from __future__ import annotations
+
+from .base import DatabaseAdapter
+from .mysql import MySQLAdapter
+from .postgres import PostgreSQLAdapter
+
+__all__ = ["DatabaseAdapter", "MySQLAdapter", "PostgreSQLAdapter", "get_adapter"]
+
+# Adapter registry mapping backend names to adapter classes
+ADAPTERS: dict[str, type[DatabaseAdapter]] = {
+ "mysql": MySQLAdapter,
+ "postgresql": PostgreSQLAdapter,
+ "postgres": PostgreSQLAdapter, # Alias for postgresql
+}
+
+
+def get_adapter(backend: str) -> DatabaseAdapter:
+ """
+ Get adapter instance for the specified database backend.
+
+ Parameters
+ ----------
+ backend : str
+ Backend name: 'mysql', 'postgresql', or 'postgres'.
+
+ Returns
+ -------
+ DatabaseAdapter
+ Adapter instance for the specified backend.
+
+ Raises
+ ------
+ ValueError
+ If the backend is not supported.
+
+ Examples
+ --------
+ >>> from datajoint.adapters import get_adapter
+ >>> mysql_adapter = get_adapter('mysql')
+ >>> postgres_adapter = get_adapter('postgresql')
+ """
+ backend_lower = backend.lower()
+
+ if backend_lower not in ADAPTERS:
+ supported = sorted(set(ADAPTERS.keys()))
+ raise ValueError(f"Unknown database backend: {backend}. " f"Supported backends: {', '.join(supported)}")
+
+ return ADAPTERS[backend_lower]()
diff --git a/src/datajoint/adapters/base.py b/src/datajoint/adapters/base.py
new file mode 100644
index 000000000..e79a5d4df
--- /dev/null
+++ b/src/datajoint/adapters/base.py
@@ -0,0 +1,1332 @@
+"""
+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
+ ...
+
+ def find_upstream_schemas_sql(self, schemas_list: str) -> str:
+ """
+ Generate query to find schemas that the given schemas reference via FK.
+
+ Used to discover unloaded schemas that the loaded ones depend on
+ (the upstream / ancestor direction). Symmetric to
+ :meth:`find_downstream_schemas_sql`.
+
+ 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 are referenced by 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..4d2d4ca73
--- /dev/null
+++ b/src/datajoint/adapters/mysql.py
@@ -0,0 +1,1141 @@
+"""
+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 find_upstream_schemas_sql(self, schemas_list: str) -> str:
+ """Find schemas that the given schemas reference via FK."""
+ return (
+ f"SELECT DISTINCT referenced_table_schema as schema_name "
+ f"FROM information_schema.key_column_usage "
+ f"WHERE table_schema IN ({schemas_list}) "
+ f"AND referenced_table_schema IS NOT NULL "
+ f"AND referenced_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..07e0732a4
--- /dev/null
+++ b/src/datajoint/adapters/postgres.py
@@ -0,0 +1,1615 @@
+"""
+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 find_upstream_schemas_sql(self, schemas_list: str) -> str:
+ """Find schemas that the given schemas reference via FK."""
+ return (
+ f"SELECT DISTINCT ns2.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 ns1.nspname IN ({schemas_list}) "
+ f"AND ns2.nspname NOT IN ({schemas_list})"
+ )
+
+ def get_constraint_info_sql(self, constraint_name: str, schema_name: str, table_name: str) -> str:
+ """
+ Query to get FK constraint details from information_schema.
+
+ Returns matched pairs of (fk_column, parent_table, pk_column) for each
+ column in the foreign key constraint, ordered by position.
+ """
+ return (
+ "SELECT "
+ " kcu.column_name as fk_attrs, "
+ " '\"' || ccu.table_schema || '\".\"' || ccu.table_name || '\"' as parent, "
+ " ccu.column_name as pk_attrs "
+ "FROM information_schema.key_column_usage AS kcu "
+ "JOIN information_schema.referential_constraints AS rc "
+ " ON kcu.constraint_name = rc.constraint_name "
+ " AND kcu.constraint_schema = rc.constraint_schema "
+ "JOIN information_schema.key_column_usage AS ccu "
+ " ON rc.unique_constraint_name = ccu.constraint_name "
+ " AND rc.unique_constraint_schema = ccu.constraint_schema "
+ " AND kcu.ordinal_position = ccu.ordinal_position "
+ "WHERE kcu.constraint_name = %s "
+ " AND kcu.table_schema = %s "
+ " AND kcu.table_name = %s "
+ "ORDER BY kcu.ordinal_position"
+ )
+
+ def parse_foreign_key_error(self, error_message: str) -> dict[str, str | list[str] | None] | None:
+ """
+ Parse PostgreSQL foreign key violation error message.
+
+ PostgreSQL FK error format:
+ 'update or delete on table "X" violates foreign key constraint "Y" on table "Z"'
+ Where:
+ - "X" is the referenced table (being deleted/updated)
+ - "Z" is the referencing table (has the FK, needs cascade delete)
+ """
+ import re
+
+ pattern = re.compile(
+ r'.*table "(?P[^"]+)" violates foreign key constraint '
+ r'"(?P[^"]+)" on table "(?P[^"]+)"'
+ )
+
+ match = pattern.match(error_message)
+ if not match:
+ return None
+
+ result = match.groupdict()
+
+ # The child is the referencing table (the one with the FK that needs cascade delete)
+ # The parent is the referenced table (the one being deleted)
+ # The error doesn't include schema, so we return unqualified names
+ child = f'"{result["referencing_table"]}"'
+ parent = f'"{result["referenced_table"]}"'
+
+ return {
+ "child": child,
+ "name": f'"{result["name"]}"',
+ "fk_attrs": None, # Not in error message, will need constraint query
+ "parent": parent,
+ "pk_attrs": None, # Not in error message, will need constraint query
+ }
+
+ def get_indexes_sql(self, schema_name: str, table_name: str) -> str:
+ """Query to get index definitions."""
+ return (
+ f"SELECT indexname, indexdef FROM pg_indexes "
+ f"WHERE schemaname = {self.quote_string(schema_name)} "
+ f"AND tablename = {self.quote_string(table_name)}"
+ )
+
+ def parse_column_info(self, row: dict[str, Any]) -> dict[str, Any]:
+ """
+ Parse PostgreSQL column info into standardized format.
+
+ Parameters
+ ----------
+ row : dict
+ Row from information_schema.columns query with col_description() join.
+
+ Returns
+ -------
+ dict
+ Standardized column info with keys:
+ name, type, nullable, default, comment, key, extra
+ """
+ # For user-defined types (enums), use udt_name instead of data_type
+ # PostgreSQL reports enums as "USER-DEFINED" in data_type
+ data_type = row["data_type"]
+ if data_type == "USER-DEFINED":
+ data_type = row["udt_name"]
+
+ # Reconstruct parametrized types that PostgreSQL splits into separate fields
+ char_max_len = row.get("character_maximum_length")
+ num_precision = row.get("numeric_precision")
+ num_scale = row.get("numeric_scale")
+
+ if data_type == "character" and char_max_len is not None:
+ # char(n) - PostgreSQL reports as "character" with length in separate field
+ data_type = f"char({char_max_len})"
+ elif data_type == "character varying" and char_max_len is not None:
+ # varchar(n)
+ data_type = f"varchar({char_max_len})"
+ elif data_type == "numeric" and num_precision is not None:
+ # numeric(p,s) - reconstruct decimal type
+ if num_scale is not None and num_scale > 0:
+ data_type = f"decimal({num_precision},{num_scale})"
+ else:
+ data_type = f"decimal({num_precision})"
+
+ return {
+ "name": row["column_name"],
+ "type": data_type,
+ "nullable": row["is_nullable"] == "YES",
+ "default": row["column_default"],
+ "comment": row.get("column_comment"), # Retrieved via col_description()
+ "key": "", # PostgreSQL key info retrieved separately
+ "extra": "", # PostgreSQL doesn't have auto_increment in same way
+ }
+
+ # =========================================================================
+ # Transactions
+ # =========================================================================
+
+ def start_transaction_sql(self, isolation_level: str | None = None) -> str:
+ """Generate BEGIN statement for PostgreSQL."""
+ if isolation_level:
+ return f"BEGIN ISOLATION LEVEL {isolation_level}"
+ return "BEGIN"
+
+ def commit_sql(self) -> str:
+ """Generate COMMIT statement."""
+ return "COMMIT"
+
+ def rollback_sql(self) -> str:
+ """Generate ROLLBACK statement."""
+ return "ROLLBACK"
+
+ # =========================================================================
+ # Functions and Expressions
+ # =========================================================================
+
+ def current_timestamp_expr(self, precision: int | None = None) -> str:
+ """
+ CURRENT_TIMESTAMP expression for PostgreSQL.
+
+ Parameters
+ ----------
+ precision : int, optional
+ Fractional seconds precision (0-6).
+
+ Returns
+ -------
+ str
+ CURRENT_TIMESTAMP or CURRENT_TIMESTAMP(n).
+ """
+ if precision is not None:
+ return f"CURRENT_TIMESTAMP({precision})"
+ return "CURRENT_TIMESTAMP"
+
+ def interval_expr(self, value: int, unit: str) -> str:
+ """
+ INTERVAL expression for PostgreSQL.
+
+ Parameters
+ ----------
+ value : int
+ Interval value.
+ unit : str
+ Time unit (singular: 'second', 'minute', 'hour', 'day').
+
+ Returns
+ -------
+ str
+ INTERVAL 'n units' (e.g., "INTERVAL '5 seconds'").
+ """
+ # PostgreSQL uses plural unit names and quotes
+ unit_plural = unit.lower() + "s" if not unit.endswith("s") else unit.lower()
+ return f"INTERVAL '{value} {unit_plural}'"
+
+ def current_user_expr(self) -> str:
+ """PostgreSQL current user expression."""
+ return "current_user"
+
+ def json_path_expr(self, column: str, path: str, return_type: str | None = None) -> str:
+ """
+ Generate PostgreSQL jsonb_extract_path_text() expression.
+
+ Parameters
+ ----------
+ column : str
+ Column name containing JSON data.
+ path : str
+ JSON path (e.g., 'field' or 'nested.field').
+ return_type : str, optional
+ Return type specification for casting (e.g., 'float', 'decimal(10,2)').
+
+ Returns
+ -------
+ str
+ PostgreSQL jsonb_extract_path_text() expression, with optional cast.
+
+ Examples
+ --------
+ >>> adapter.json_path_expr('data', 'field')
+ 'jsonb_extract_path_text("data", \\'field\\')'
+ >>> adapter.json_path_expr('data', 'nested.field')
+ 'jsonb_extract_path_text("data", \\'nested\\', \\'field\\')'
+ >>> adapter.json_path_expr('data', 'value', 'float')
+ 'jsonb_extract_path_text("data", \\'value\\')::float'
+ """
+ quoted_col = self.quote_identifier(column)
+ # Split path by '.' for nested access, handling array notation
+ path_parts = []
+ for part in path.split("."):
+ # Handle array access like field[0]
+ if "[" in part:
+ base, rest = part.split("[", 1)
+ path_parts.append(base)
+ # Extract array indices
+ indices = rest.rstrip("]").split("][")
+ path_parts.extend(indices)
+ else:
+ path_parts.append(part)
+ path_args = ", ".join(f"'{part}'" for part in path_parts)
+ expr = f"jsonb_extract_path_text({quoted_col}, {path_args})"
+ # Add cast if return type specified
+ if return_type:
+ # Map DataJoint types to PostgreSQL types
+ pg_type = return_type.lower()
+ if pg_type in ("unsigned", "signed"):
+ pg_type = "integer"
+ elif pg_type == "double":
+ pg_type = "double precision"
+ expr = f"({expr})::{pg_type}"
+ return expr
+
+ def translate_expression(self, expr: str) -> str:
+ """
+ Translate SQL expression for PostgreSQL compatibility.
+
+ Converts MySQL-specific functions to PostgreSQL equivalents:
+ - GROUP_CONCAT(col) → STRING_AGG(col::text, ',')
+ - GROUP_CONCAT(col SEPARATOR 'sep') → STRING_AGG(col::text, 'sep')
+
+ Parameters
+ ----------
+ expr : str
+ SQL expression that may contain function calls.
+
+ Returns
+ -------
+ str
+ Translated expression for PostgreSQL.
+ """
+ import re
+
+ # GROUP_CONCAT(col) → STRING_AGG(col::text, ',')
+ # GROUP_CONCAT(col SEPARATOR 'sep') → STRING_AGG(col::text, 'sep')
+ def replace_group_concat(match):
+ inner = match.group(1).strip()
+ # Check for SEPARATOR clause
+ sep_match = re.match(r"(.+?)\s+SEPARATOR\s+(['\"])(.+?)\2", inner, re.IGNORECASE)
+ if sep_match:
+ col = sep_match.group(1).strip()
+ sep = sep_match.group(3)
+ return f"STRING_AGG({col}::text, '{sep}')"
+ else:
+ return f"STRING_AGG({inner}::text, ',')"
+
+ expr = re.sub(r"GROUP_CONCAT\s*\((.+?)\)", replace_group_concat, expr, flags=re.IGNORECASE)
+
+ # Replace simple functions FIRST before complex patterns
+ # CURDATE() → CURRENT_DATE
+ expr = re.sub(r"CURDATE\s*\(\s*\)", "CURRENT_DATE", expr, flags=re.IGNORECASE)
+
+ # NOW() → CURRENT_TIMESTAMP
+ expr = re.sub(r"\bNOW\s*\(\s*\)", "CURRENT_TIMESTAMP", expr, flags=re.IGNORECASE)
+
+ # YEAR(date) → EXTRACT(YEAR FROM date)::int
+ expr = re.sub(r"\bYEAR\s*\(\s*([^)]+)\s*\)", r"EXTRACT(YEAR FROM \1)::int", expr, flags=re.IGNORECASE)
+
+ # MONTH(date) → EXTRACT(MONTH FROM date)::int
+ expr = re.sub(r"\bMONTH\s*\(\s*([^)]+)\s*\)", r"EXTRACT(MONTH FROM \1)::int", expr, flags=re.IGNORECASE)
+
+ # DAY(date) → EXTRACT(DAY FROM date)::int
+ expr = re.sub(r"\bDAY\s*\(\s*([^)]+)\s*\)", r"EXTRACT(DAY FROM \1)::int", expr, flags=re.IGNORECASE)
+
+ # TIMESTAMPDIFF(YEAR, d1, d2) → EXTRACT(YEAR FROM AGE(d2, d1))::int
+ # Use a more robust regex that handles the comma-separated arguments
+ def replace_timestampdiff(match):
+ unit = match.group(1).upper()
+ date1 = match.group(2).strip()
+ date2 = match.group(3).strip()
+ if unit == "YEAR":
+ return f"EXTRACT(YEAR FROM AGE({date2}, {date1}))::int"
+ elif unit == "MONTH":
+ return f"(EXTRACT(YEAR FROM AGE({date2}, {date1})) * 12 + EXTRACT(MONTH FROM AGE({date2}, {date1})))::int"
+ elif unit == "DAY":
+ return f"({date2}::date - {date1}::date)"
+ else:
+ return f"EXTRACT({unit} FROM AGE({date2}, {date1}))::int"
+
+ # Match TIMESTAMPDIFF with proper argument parsing
+ # The arguments are: unit, date1, date2 - we need to handle identifiers and CURRENT_DATE
+ expr = re.sub(
+ r"TIMESTAMPDIFF\s*\(\s*(\w+)\s*,\s*([^,]+)\s*,\s*([^)]+)\s*\)",
+ replace_timestampdiff,
+ expr,
+ flags=re.IGNORECASE,
+ )
+
+ # SUM(expr='value') → SUM((expr='value')::int) for PostgreSQL boolean handling
+ # This handles patterns like SUM(sex='F') which produce boolean in PostgreSQL
+ def replace_sum_comparison(match):
+ inner = match.group(1).strip()
+ # Check if inner contains a comparison operator
+ if re.search(r"[=<>!]", inner) and not inner.startswith("("):
+ return f"SUM(({inner})::int)"
+ return match.group(0) # Return unchanged if no comparison
+
+ expr = re.sub(r"\bSUM\s*\(\s*([^)]+)\s*\)", replace_sum_comparison, expr, flags=re.IGNORECASE)
+
+ return expr
+
+ # =========================================================================
+ # DDL Generation
+ # =========================================================================
+
+ def format_column_definition(
+ self,
+ name: str,
+ sql_type: str,
+ nullable: bool = False,
+ default: str | None = None,
+ comment: str | None = None,
+ ) -> str:
+ """
+ Format a column definition for PostgreSQL DDL.
+
+ Examples
+ --------
+ >>> adapter.format_column_definition('user_id', 'bigint', nullable=False, comment='user ID')
+ '"user_id" bigint NOT NULL'
+ """
+ parts = [self.quote_identifier(name), sql_type]
+ if default:
+ parts.append(default)
+ elif not nullable:
+ parts.append("NOT NULL")
+ # Note: PostgreSQL comments handled separately via COMMENT ON
+ return " ".join(parts)
+
+ def table_options_clause(self, comment: str | None = None) -> str:
+ """
+ Generate PostgreSQL table options clause (empty - no ENGINE in PostgreSQL).
+
+ Examples
+ --------
+ >>> adapter.table_options_clause('test table')
+ ''
+ >>> adapter.table_options_clause()
+ ''
+ """
+ return "" # PostgreSQL uses COMMENT ON TABLE separately
+
+ def table_comment_ddl(self, full_table_name: str, comment: str) -> str | None:
+ """
+ Generate COMMENT ON TABLE statement for PostgreSQL.
+
+ Examples
+ --------
+ >>> adapter.table_comment_ddl('"schema"."table"', 'test comment')
+ 'COMMENT ON TABLE "schema"."table" IS \\'test comment\\''
+ """
+ # Escape single quotes by doubling them
+ escaped_comment = comment.replace("'", "''")
+ return f"COMMENT ON TABLE {full_table_name} IS '{escaped_comment}'"
+
+ def column_comment_ddl(self, full_table_name: str, column_name: str, comment: str) -> str | None:
+ """
+ Generate COMMENT ON COLUMN statement for PostgreSQL.
+
+ Examples
+ --------
+ >>> adapter.column_comment_ddl('"schema"."table"', 'column', 'test comment')
+ 'COMMENT ON COLUMN "schema"."table"."column" IS \\'test comment\\''
+ """
+ quoted_col = self.quote_identifier(column_name)
+ # Escape single quotes by doubling them (PostgreSQL string literal syntax)
+ escaped_comment = comment.replace("'", "''")
+ return f"COMMENT ON COLUMN {full_table_name}.{quoted_col} IS '{escaped_comment}'"
+
+ def enum_type_ddl(self, type_name: str, values: list[str]) -> str | None:
+ """
+ Generate CREATE TYPE statement for PostgreSQL enum.
+
+ Examples
+ --------
+ >>> adapter.enum_type_ddl('status_type', ['active', 'inactive'])
+ 'CREATE TYPE "status_type" AS ENUM (\\'active\\', \\'inactive\\')'
+ """
+ quoted_values = ", ".join(f"'{v}'" for v in values)
+ return f"CREATE TYPE {self.quote_identifier(type_name)} AS ENUM ({quoted_values})"
+
+ def replica_identity_ddl(self, full_table_name: str, mode: str) -> str:
+ """
+ Generate ALTER TABLE ... REPLICA IDENTITY statement.
+
+ Controls how much of the old row PostgreSQL writes to WAL on UPDATE/DELETE.
+ ``"default"`` logs only primary-key columns; ``"full"`` logs the entire row.
+ Required by some CDC tools (e.g. Databricks Lakehouse Sync) that need the
+ full pre-image to drive Slowly-Changing-Dimension history.
+
+ The ALTER is metadata-only, instant, and idempotent — re-applying the same
+ mode is a no-op at the storage layer.
+
+ Examples
+ --------
+ >>> adapter.replica_identity_ddl('"schema"."table"', 'full')
+ 'ALTER TABLE "schema"."table" REPLICA IDENTITY FULL'
+ >>> adapter.replica_identity_ddl('"schema"."table"', 'default')
+ 'ALTER TABLE "schema"."table" REPLICA IDENTITY DEFAULT'
+ """
+ if mode not in ("default", "full"):
+ from ..errors import DataJointError
+
+ raise DataJointError(f"Unsupported replica_identity mode: {mode!r}. Expected 'default' or 'full'.")
+ return f"ALTER TABLE {full_table_name} REPLICA IDENTITY {mode.upper()}"
+
+ def get_pending_enum_ddl(self, schema_name: str) -> list[str]:
+ """
+ Get DDL statements for pending enum types and clear the pending list.
+
+ PostgreSQL requires CREATE TYPE statements before using enum types in
+ column definitions. This method returns DDL for enum types accumulated
+ during type conversion and clears the pending list.
+
+ Parameters
+ ----------
+ schema_name : str
+ Schema name to qualify enum type names.
+
+ Returns
+ -------
+ list[str]
+ List of CREATE TYPE statements (if any pending).
+ """
+ ddl_statements = []
+ if hasattr(self, "_pending_enum_types") and self._pending_enum_types:
+ for type_name, values in self._pending_enum_types.items():
+ # Generate CREATE TYPE with schema qualification
+ quoted_type = f"{self.quote_identifier(schema_name)}.{self.quote_identifier(type_name)}"
+ quoted_values = ", ".join(f"'{v}'" for v in values)
+ ddl_statements.append(f"CREATE TYPE {quoted_type} AS ENUM ({quoted_values})")
+ self._pending_enum_types = {}
+ return ddl_statements
+
+ def job_metadata_columns(self) -> list[str]:
+ """
+ Return PostgreSQL-specific job metadata column definitions.
+
+ Examples
+ --------
+ >>> adapter.job_metadata_columns()
+ ['"_job_start_time" timestamp DEFAULT NULL',
+ '"_job_duration" real DEFAULT NULL',
+ '"_job_version" varchar(64) DEFAULT \\'\\'']
+ """
+ return [
+ '"_job_start_time" timestamp DEFAULT NULL',
+ '"_job_duration" real DEFAULT NULL',
+ "\"_job_version\" varchar(64) DEFAULT ''",
+ ]
+
+ # =========================================================================
+ # Error Translation
+ # =========================================================================
+
+ def translate_error(self, error: Exception, query: str = "") -> Exception:
+ """
+ Translate PostgreSQL error to DataJoint exception.
+
+ Parameters
+ ----------
+ error : Exception
+ PostgreSQL exception (typically psycopg2 error).
+ query : str, optional
+ SQL query that caused the error (for context).
+
+ Returns
+ -------
+ Exception
+ DataJoint exception or original error.
+ """
+ if not hasattr(error, "pgcode"):
+ return error
+
+ pgcode = error.pgcode
+
+ # PostgreSQL error code mapping
+ # Reference: https://www.postgresql.org/docs/current/errcodes-appendix.html
+ match pgcode:
+ # Integrity constraint violations
+ case "23505": # unique_violation
+ return errors.DuplicateError(str(error))
+ case "23503": # foreign_key_violation
+ return errors.IntegrityError(str(error))
+ case "23502": # not_null_violation
+ return errors.MissingAttributeError(str(error))
+
+ # Syntax errors
+ case "42601": # syntax_error
+ return errors.QuerySyntaxError(str(error), "")
+
+ # Undefined errors
+ case "42P01": # undefined_table
+ return errors.MissingTableError(str(error), "")
+ case "42703": # undefined_column
+ return errors.UnknownAttributeError(str(error))
+
+ # Connection errors
+ case "08006" | "08003" | "08000": # connection_failure
+ return errors.LostConnectionError(str(error))
+ case "57P01": # admin_shutdown
+ return errors.LostConnectionError(str(error))
+
+ # Access errors
+ case "42501": # insufficient_privilege
+ return errors.AccessError("Insufficient privileges.", str(error), "")
+
+ # All other errors pass through unchanged
+ case _:
+ return error
+
+ # =========================================================================
+ # Native Type Validation
+ # =========================================================================
+
+ def validate_native_type(self, type_str: str) -> bool:
+ """
+ Check if a native PostgreSQL type string is valid.
+
+ Parameters
+ ----------
+ type_str : str
+ Type string to validate.
+
+ Returns
+ -------
+ bool
+ True if valid PostgreSQL type.
+ """
+ type_lower = type_str.lower().strip()
+
+ # PostgreSQL native types (simplified validation)
+ valid_types = {
+ # Integer types
+ "smallint",
+ "integer",
+ "int",
+ "bigint",
+ "smallserial",
+ "serial",
+ "bigserial",
+ # Floating point
+ "real",
+ "double precision",
+ "numeric",
+ "decimal",
+ # String types
+ "char",
+ "varchar",
+ "text",
+ # Binary
+ "bytea",
+ # Boolean
+ "boolean",
+ "bool",
+ # Temporal types
+ "date",
+ "time",
+ "timetz",
+ "timestamp",
+ "timestamptz",
+ "interval",
+ # UUID
+ "uuid",
+ # JSON
+ "json",
+ "jsonb",
+ # Network types
+ "inet",
+ "cidr",
+ "macaddr",
+ # Geometric types
+ "point",
+ "line",
+ "lseg",
+ "box",
+ "path",
+ "polygon",
+ "circle",
+ # Other
+ "money",
+ "xml",
+ }
+
+ # Extract base type (before parentheses or brackets)
+ base_type = type_lower.split("(")[0].split("[")[0].strip()
+
+ return base_type in valid_types
+
+ # =========================================================================
+ # PostgreSQL-Specific Enum Handling
+ # =========================================================================
+
+ def create_enum_type_sql(
+ self,
+ schema: str,
+ table: str,
+ column: str,
+ values: list[str],
+ ) -> str:
+ """
+ Generate CREATE TYPE statement for PostgreSQL enum.
+
+ Parameters
+ ----------
+ schema : str
+ Schema name.
+ table : str
+ Table name.
+ column : str
+ Column name.
+ values : list[str]
+ Enum values.
+
+ Returns
+ -------
+ str
+ CREATE TYPE ... AS ENUM statement.
+ """
+ type_name = f"{schema}_{table}_{column}_enum"
+ quoted_values = ", ".join(self.quote_string(v) for v in values)
+ return f"CREATE TYPE {self.quote_identifier(type_name)} AS ENUM ({quoted_values})"
+
+ def drop_enum_type_sql(self, schema: str, table: str, column: str) -> str:
+ """
+ Generate DROP TYPE statement for PostgreSQL enum.
+
+ Parameters
+ ----------
+ schema : str
+ Schema name.
+ table : str
+ Table name.
+ column : str
+ Column name.
+
+ Returns
+ -------
+ str
+ DROP TYPE statement.
+ """
+ type_name = f"{schema}_{table}_{column}_enum"
+ return f"DROP TYPE IF EXISTS {self.quote_identifier(type_name)} CASCADE"
+
+ def get_table_enum_types_sql(self, schema_name: str, table_name: str) -> str:
+ """
+ Query to get enum types used by a table's columns.
+
+ Parameters
+ ----------
+ schema_name : str
+ Schema name.
+ table_name : str
+ Table name.
+
+ Returns
+ -------
+ str
+ SQL query that returns enum type names (schema-qualified).
+ """
+ return f"""
+ SELECT DISTINCT
+ n.nspname || '.' || t.typname as enum_type
+ FROM pg_catalog.pg_type t
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
+ JOIN pg_catalog.pg_attribute a ON a.atttypid = t.oid
+ JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
+ JOIN pg_catalog.pg_namespace cn ON cn.oid = c.relnamespace
+ WHERE t.typtype = 'e'
+ AND cn.nspname = {self.quote_string(schema_name)}
+ AND c.relname = {self.quote_string(table_name)}
+ """
+
+ def drop_enum_types_for_table(self, schema_name: str, table_name: str) -> list[str]:
+ """
+ Generate DROP TYPE statements for all enum types used by a table.
+
+ Parameters
+ ----------
+ schema_name : str
+ Schema name.
+ table_name : str
+ Table name.
+
+ Returns
+ -------
+ list[str]
+ List of DROP TYPE IF EXISTS statements.
+ """
+ # Returns list of DDL statements - caller should execute query first
+ # to get actual enum types, then call this with results
+ return [] # Placeholder - actual implementation requires query execution
+
+ def drop_enum_type_ddl(self, enum_type_name: str) -> str:
+ """
+ Generate DROP TYPE IF EXISTS statement for a PostgreSQL enum.
+
+ Parameters
+ ----------
+ enum_type_name : str
+ Fully qualified enum type name (schema.typename).
+
+ Returns
+ -------
+ str
+ DROP TYPE IF EXISTS statement with CASCADE.
+ """
+ # Split schema.typename and quote each part
+ parts = enum_type_name.split(".")
+ if len(parts) == 2:
+ qualified_name = f"{self.quote_identifier(parts[0])}.{self.quote_identifier(parts[1])}"
+ else:
+ qualified_name = self.quote_identifier(enum_type_name)
+ return f"DROP TYPE IF EXISTS {qualified_name} CASCADE"
diff --git a/src/datajoint/autopopulate.py b/src/datajoint/autopopulate.py
new file mode 100644
index 000000000..d33e6ccf0
--- /dev/null
+++ b/src/datajoint/autopopulate.py
@@ -0,0 +1,873 @@
+"""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
+ _upstream = None # set per-make() by _populate_one; see `upstream` property below
+
+ @property
+ def upstream(self):
+ """
+ Pre-restricted ancestor view for the current ``make(self, key)`` call.
+
+ Inside ``make()``, ``self.upstream`` is a ``Diagram`` constructed via
+ :meth:`Diagram.trace(self & key) `. Use
+ ``self.upstream[T]`` to obtain a pre-restricted ``QueryExpression``
+ (or ``FreeTable``, when indexed by a string) for any ancestor of
+ ``self``.
+
+ Reading via ``self.upstream`` is the provenance-safe pattern: the
+ framework guarantees the restriction matches the current ``key``,
+ and indexing a non-ancestor table raises ``DataJointError``. See
+ :doc:`reference/specs/provenance` for the contract.
+
+ Raises
+ ------
+ DataJointError
+ If accessed outside ``make()`` execution. To construct a trace
+ explicitly, use ``dj.Diagram.trace(self & key)``.
+
+ Examples
+ --------
+ ::
+
+ def make(self, key):
+ date = self.upstream[Session].fetch1("session_date")
+ traces = self.upstream[ExtractTraces].to_arrays("trace")
+ self.insert1({**key, "summary": compute(traces, date)})
+ """
+ if self._upstream is None:
+ raise DataJointError(
+ "self.upstream is only available inside make(). "
+ "Outside make(), construct a trace explicitly: "
+ "dj.Diagram.trace(self & key)."
+ )
+ return self._upstream
+
+ 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
+
+ # Pre-construct the upstream view for this make() call. Lazy — only
+ # `dj.Diagram.trace(self & key)` runs here (graph copy); the
+ # expensive SQL fetch fires when the user accesses self.upstream[T].
+ from .diagram import Diagram
+
+ self._upstream = Diagram.trace(self & dict(key))
+
+ # If strict_provenance is on, push the active-make context so the
+ # runtime gates in expression.cursor / table.insert can check this
+ # make()'s reads and writes. The context is popped in the finally
+ # block below.
+ strict_token = None
+ if self.connection._config.get("strict_provenance", False):
+ from .provenance import push_strict_make_context
+ from .user_tables import Part
+
+ allowed_tables = set(self._upstream._cascade_restrictions.keys()) | {self.full_table_name}
+ # Add Part tables of self to the allowed set. Use class __dict__
+ # (not dir/getattr) to avoid triggering descriptors like the
+ # _JobsDescriptor that lazy-declares the ~~ job table.
+ for cls in type(self).__mro__:
+ for attr_name, attr in cls.__dict__.items():
+ if attr_name.startswith("_"):
+ continue
+ if isinstance(attr, type) and issubclass(attr, Part):
+ # Instantiate to get full_table_name resolved against
+ # this schema. The Part class is already attached via
+ # @schema decoration of the master.
+ try:
+ part_ftn = attr().full_table_name
+ allowed_tables.add(part_ftn)
+ except Exception:
+ pass
+ strict_token = push_strict_make_context(self, frozenset(allowed_tables), dict(key))
+
+ 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
+ # Clear the per-make() upstream view so subsequent attribute
+ # access raises a clear error rather than silently using a
+ # stale trace from the previous make() call.
+ self._upstream = None
+ # Pop the strict-make context, if any.
+ if strict_token is not None:
+ from .provenance import pop_strict_make_context
+
+ pop_strict_make_context(strict_token)
+
+ 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/provenance.py b/src/datajoint/provenance.py
new file mode 100644
index 000000000..8f196194f
--- /dev/null
+++ b/src/datajoint/provenance.py
@@ -0,0 +1,206 @@
+"""
+Runtime gates for ``dj.config["strict_provenance"]``.
+
+When the flag is enabled, this module's context (set by ``AutoPopulate._populate_one``)
+tracks which tables and primary key the currently-executing ``make()`` is
+allowed to read and write. The read gate in :func:`assert_read_allowed`
+fires inside ``QueryExpression.cursor``. The write gate has two parts: the
+target check in :func:`assert_write_allowed` fires inside ``Table.insert``
+(before rows are materialized), and the per-row key-consistency check in
+:func:`assert_row_key_allowed` fires inside ``Table._insert_rows`` as each row
+is materialized — so the gate never consumes the caller's ``rows`` iterable.
+
+The contract is documented in
+``datajoint-docs/src/reference/specs/provenance.md`` §3.
+
+Implementation note: the active-make context is stored in a
+``contextvars.ContextVar`` so it propagates correctly across threads
+that share the parent's context (e.g. the populate-in-subprocess path
+which uses ``multiprocessing`` workers, each of which inherits its
+parent's contextvar binding at fork time).
+"""
+
+from __future__ import annotations
+
+from contextvars import ContextVar
+from typing import TYPE_CHECKING, Optional, Tuple
+
+from .errors import DataJointError
+
+if TYPE_CHECKING:
+ from .table import Table
+
+
+# Active context: (the target table, the set of allowed full table names, the current key dict)
+_active_strict_make: ContextVar[Optional[Tuple["Table", frozenset[str], dict]]] = ContextVar(
+ "_dj_active_strict_make", default=None
+)
+
+
+def push_strict_make_context(target: "Table", allowed_tables: frozenset[str], key: dict):
+ """
+ Push a strict-make context for the duration of one ``make()`` invocation.
+
+ Returns a token that the caller must pass to :func:`pop_strict_make_context`
+ in a ``finally`` block.
+ """
+ return _active_strict_make.set((target, allowed_tables, key))
+
+
+def pop_strict_make_context(token) -> None:
+ """Pop the strict-make context using a token from :func:`push_strict_make_context`."""
+ _active_strict_make.reset(token)
+
+
+def get_active_context():
+ """Return the currently-active strict-make context, or None."""
+ return _active_strict_make.get()
+
+
+def _base_tables(query_expression) -> set[str]:
+ """
+ Return the set of base-table SQL names that a QueryExpression reads from.
+
+ For a single-table expression (FreeTable / Table / restricted variants),
+ returns ``{full_table_name}``. For compound expressions (joins,
+ projections of joins), traverses ``support`` recursively.
+ """
+ # FreeTable / Table: has full_table_name directly
+ ftn = getattr(query_expression, "full_table_name", None)
+ if isinstance(ftn, str):
+ return {ftn}
+
+ bases: set[str] = set()
+ support = getattr(query_expression, "_support", None) or []
+ for s in support:
+ if isinstance(s, str):
+ # Direct table name in the support list
+ bases.add(s)
+ else:
+ # Subquery — recurse
+ bases.update(_base_tables(s))
+ return bases
+
+
+def assert_read_allowed(query_expression) -> None:
+ """
+ Verify a fetch is allowed under the active strict-make context.
+
+ Called from ``QueryExpression.cursor`` before SQL is issued. No-op when
+ no strict-make context is active (i.e. outside ``make()`` or when
+ ``strict_provenance`` is False).
+
+ Allowed reads:
+
+ - Any table in the active context's ``allowed_tables`` set. The set is
+ built from ``self.upstream`` (the ancestor graph) plus the target
+ table and its Parts.
+
+ Anything else raises ``DataJointError``.
+
+ Known limitation (will sharpen in a follow-up): the check does not
+ distinguish reads that came *through* ``self.upstream`` from reads of
+ the same ancestor via a direct expression. Both are allowed if the
+ table is in the allowed set. The intent is to catch reads from
+ *undeclared* dependencies; tightening the "must come through
+ ``self.upstream``" path requires propagating an attribution marker
+ through QueryExpression composition and is deferred.
+ """
+ ctx = _active_strict_make.get()
+ if ctx is None:
+ return # strict mode off, or outside make()
+
+ _target, allowed_tables, _key = ctx
+ bases = _base_tables(query_expression)
+ if not bases:
+ return # nothing to check (e.g. dj.U expressions)
+
+ disallowed = bases - allowed_tables
+ if disallowed:
+ raise DataJointError(
+ f"strict_provenance=True: read from undeclared table(s) "
+ f"{sorted(disallowed)} is not permitted inside make(). "
+ f"Use self.upstream[T] for declared ancestors, or declare a "
+ f"foreign-key dependency on the table you want to read."
+ )
+
+
+def assert_write_allowed(target_table) -> None:
+ """
+ Verify the *target* of an insert is allowed under the active strict-make context.
+
+ Called from ``Table.insert`` after the existing ``_allow_insert`` check and
+ before any rows are materialized. No-op when no strict-make context is active.
+
+ Allowed targets:
+
+ - The current ``make()`` target (``self``) or one of its Part tables.
+
+ Per-row key consistency is checked separately by :func:`assert_row_key_allowed`
+ as rows are materialized, so this gate never consumes the caller's ``rows``
+ iterable — a one-shot generator must survive to reach ``insert``.
+
+ Raises ``DataJointError`` if the target is not permitted.
+ """
+ ctx = _active_strict_make.get()
+ if ctx is None:
+ return
+
+ make_target, _allowed_tables, _key = ctx
+
+ # Target must be `make_target` (self) or one of its Parts.
+ target_name = getattr(target_table, "full_table_name", None)
+ target_set = {make_target.full_table_name}
+ # Collect Part tables of make_target via class __dict__ (not dir/getattr,
+ # which would trigger descriptors like the _JobsDescriptor).
+ from .user_tables import Part # local import to avoid circular dep
+
+ for cls in type(make_target).__mro__:
+ for attr_name, attr in cls.__dict__.items():
+ if attr_name.startswith("_"):
+ continue
+ if isinstance(attr, type) and issubclass(attr, Part):
+ try:
+ part_ftn = attr().full_table_name
+ target_set.add(part_ftn)
+ except Exception:
+ pass
+
+ if target_name not in target_set:
+ raise DataJointError(
+ f"strict_provenance=True: insert into {target_name!r} is not permitted "
+ f"inside make() for {make_target.full_table_name!r}. Only the target "
+ f"table and its Part tables may be written."
+ )
+
+
+def assert_row_key_allowed(row) -> None:
+ """
+ Verify a single insert row's key columns match the active ``make()`` key.
+
+ Called per row from ``Table._insert_rows`` as rows are materialized, so the
+ check sees a concrete row without the write gate having to consume the
+ caller's ``rows`` iterable. No-op when no strict-make context is active or
+ when ``row`` is not a dict (numpy records / bare sequences carry no field
+ names to check by — same as the previous behavior).
+
+ Raises ``DataJointError`` on a mismatch.
+ """
+ ctx = _active_strict_make.get()
+ if ctx is None:
+ return
+ if not isinstance(row, dict):
+ return
+ _make_target, _allowed_tables, key = ctx
+ _check_row_key(row, key)
+
+
+def _check_row_key(row: dict, current_key: dict) -> None:
+ """Raise if any row attribute overlapping with the current key has a different value."""
+ for k, v in current_key.items():
+ if k in row and row[k] != v:
+ raise DataJointError(
+ f"strict_provenance=True: inserted row's {k!r}={row[k]!r} does not "
+ f"match the current make() key's {k!r}={v!r}. Inserts must be "
+ f"consistent with the key being populated."
+ )
diff --git a/src/datajoint/schemas.py b/src/datajoint/schemas.py
new file mode 100644
index 000000000..fa934d569
--- /dev/null
+++ b/src/datajoint/schemas.py
@@ -0,0 +1,838 @@
+"""
+Schema management for DataJoint.
+
+This module provides the Schema class for binding Python table classes to
+database schemas, and utilities for schema introspection and management.
+"""
+
+from __future__ import annotations
+
+import inspect
+import logging
+import re
+import types
+import warnings
+from typing import TYPE_CHECKING, Any
+
+from .errors import AccessError, DataJointError
+from .instance import _get_singleton_connection
+
+if TYPE_CHECKING:
+ from .connection import Connection
+from .heading import Heading
+from .jobs import Job
+from .table import FreeTable, lookup_class_name
+from .user_tables import Computed, Imported, Lookup, Manual, Part, _get_tier
+from .utils import to_camel_case, user_choice
+
+logger = logging.getLogger(__name__.split(".")[0])
+
+
+def ordered_dir(class_: type) -> list[str]:
+ """
+ List class attributes respecting declaration order.
+
+ Similar to the ``dir()`` built-in, but preserves attribute declaration
+ order as much as possible.
+
+ Parameters
+ ----------
+ class_ : type
+ Class to list members for.
+
+ Returns
+ -------
+ list[str]
+ Attributes declared in class_ and its superclasses.
+ """
+ attr_list = list()
+ for c in reversed(class_.mro()):
+ attr_list.extend(e for e in c.__dict__ if e not in attr_list)
+ return attr_list
+
+
+class _Schema:
+ """
+ Decorator that binds table classes to a database schema.
+
+ Schema objects associate Python table classes with database schemas and
+ provide the namespace context for foreign key resolution.
+
+ Parameters
+ ----------
+ schema_name : str, optional
+ Database schema name. If omitted, call ``activate()`` later.
+ context : dict, optional
+ Namespace for foreign key lookup. None uses caller's context.
+ connection : Connection, optional
+ Database connection. Defaults to ``dj.conn()``.
+ create_schema : bool, optional
+ If False, raise error if schema doesn't exist. Default True.
+ create_tables : bool, optional
+ If False, raise error when accessing missing tables.
+ Default from ``dj.config.database.create_tables`` (True unless configured).
+ add_objects : dict, optional
+ Additional objects for the declaration context.
+
+ Examples
+ --------
+ >>> schema = dj.Schema('my_schema')
+ >>> @schema
+ ... class Session(dj.Manual):
+ ... definition = '''
+ ... session_id : int
+ ... '''
+ """
+
+ def __init__(
+ self,
+ schema_name: str | None = None,
+ context: dict[str, Any] | None = None,
+ *,
+ connection: Connection | None = None,
+ create_schema: bool = True,
+ create_tables: bool | None = None,
+ add_objects: dict[str, Any] | None = None,
+ ) -> None:
+ """
+ Initialize the schema object.
+
+ Parameters
+ ----------
+ schema_name : str, optional
+ Database schema name. If omitted, call ``activate()`` later.
+ context : dict, optional
+ Namespace for foreign key lookup. None uses caller's context.
+ connection : Connection, optional
+ Database connection. Defaults to ``dj.conn()``.
+ create_schema : bool, optional
+ If False, raise error if schema doesn't exist. Default True.
+ create_tables : bool, optional
+ If False, raise error when accessing missing tables.
+ Default from ``dj.config.database.create_tables`` (True unless configured).
+ add_objects : dict, optional
+ Additional objects for the declaration context.
+ """
+ self.connection = connection
+ self.database = None
+ self.context = context
+ self.create_schema = create_schema
+ self.create_tables = create_tables # None means "use connection config default"
+ self.add_objects = add_objects
+ self.declare_list = []
+ if schema_name:
+ self.activate(schema_name)
+
+ def is_activated(self) -> bool:
+ """Check if the schema has been activated."""
+ return self.database is not None
+
+ def activate(
+ self,
+ schema_name: str | None = None,
+ *,
+ connection: Connection | None = None,
+ create_schema: bool | None = None,
+ create_tables: bool | None = None,
+ add_objects: dict[str, Any] | None = None,
+ ) -> None:
+ """
+ Associate with a database schema.
+
+ If the schema does not exist, attempts to create it on the server.
+
+ Parameters
+ ----------
+ schema_name : str, optional
+ Database schema name. None asserts schema is already activated.
+ connection : Connection, optional
+ Database connection. Defaults to ``dj.conn()``.
+ create_schema : bool, optional
+ If False, raise error if schema doesn't exist.
+ create_tables : bool, optional
+ If False, raise error when accessing missing tables.
+ add_objects : dict, optional
+ Additional objects for the declaration context.
+
+ Raises
+ ------
+ DataJointError
+ If schema_name is None and schema not yet activated, or if
+ schema already activated for a different database.
+ """
+ if schema_name is None:
+ if self.exists:
+ return
+ raise DataJointError("Please provide a schema_name to activate the schema.")
+ if self.database is not None and self.exists:
+ if self.database == schema_name: # already activated
+ return
+ raise DataJointError("The schema is already activated for schema {db}.".format(db=self.database))
+ if connection is not None:
+ self.connection = connection
+ if self.connection is None:
+ self.connection = _get_singleton_connection()
+ if self.connection._config.get("database.database_prefix"):
+ warnings.warn(
+ "database_prefix is deprecated and will be removed in DataJoint 2.3. "
+ "Use database.name to select a PostgreSQL database instead.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ self.database = schema_name
+ if create_schema is not None:
+ self.create_schema = create_schema
+ if create_tables is not None:
+ self.create_tables = create_tables
+ if add_objects:
+ self.add_objects = add_objects
+ if not self.exists:
+ if not self.create_schema or not self.database:
+ raise DataJointError(
+ "Database `{name}` has not yet been declared. Set argument create_schema=True to create it.".format(
+ name=schema_name
+ )
+ )
+ # create database
+ logger.debug("Creating schema `{name}`.".format(name=schema_name))
+ try:
+ create_sql = self.connection.adapter.create_schema_sql(schema_name)
+ self.connection.query(create_sql)
+ except AccessError:
+ raise DataJointError(
+ "Schema `{name}` does not exist and could not be created. Check permissions.".format(name=schema_name)
+ )
+ self.connection.register(self)
+
+ # decorate all tables already decorated
+ for cls, context in self.declare_list:
+ if self.add_objects:
+ context = dict(context, **self.add_objects)
+ self._decorate_master(cls, context)
+
+ def _assert_exists(self, message=None):
+ if not self.exists:
+ raise DataJointError(message or "Schema `{db}` has not been created.".format(db=self.database))
+
+ def __call__(self, cls: type, *, context: dict[str, Any] | None = None) -> type:
+ """
+ Bind a table class to this schema. Used as a decorator.
+
+ Parameters
+ ----------
+ cls : type
+ Table class to decorate.
+ context : dict, optional
+ Declaration context. Supplied by make_classes.
+
+ Returns
+ -------
+ type
+ The decorated class.
+
+ Raises
+ ------
+ DataJointError
+ If applied to a Part table (use on master only).
+ """
+ context = context or self.context or inspect.currentframe().f_back.f_locals
+ if issubclass(cls, Part):
+ raise DataJointError("The schema decorator should not be applied to Part tables.")
+ if self.is_activated():
+ self._decorate_master(cls, context)
+ else:
+ self.declare_list.append((cls, context))
+ return cls
+
+ def _decorate_master(self, cls: type, context: dict[str, Any]) -> None:
+ """
+ Process a master table class and its part tables.
+
+ Parameters
+ ----------
+ cls : type
+ Master table class to process.
+ context : dict
+ Declaration context for foreign key resolution.
+ """
+ self._decorate_table(cls, context=dict(context, self=cls, **{cls.__name__: cls}))
+ # Process part tables
+ for part in ordered_dir(cls):
+ if part[0].isupper():
+ part = getattr(cls, part)
+ if inspect.isclass(part) and issubclass(part, Part):
+ part._master = cls
+ # allow addressing master by name or keyword 'master'
+ self._decorate_table(
+ part,
+ context=dict(context, master=cls, self=part, **{cls.__name__: cls}),
+ )
+
+ def _decorate_table(self, table_class: type, context: dict[str, Any], assert_declared: bool = False) -> None:
+ """
+ Assign schema properties to the table class and declare the table.
+
+ Parameters
+ ----------
+ table_class : type
+ Table class to decorate.
+ context : dict
+ Declaration context for foreign key resolution.
+ assert_declared : bool, optional
+ If True, assert table is already declared. Default False.
+ """
+ table_class.database = self.database
+ table_class._connection = self.connection
+ table_class._heading = Heading(
+ table_info=dict(
+ conn=self.connection,
+ database=self.database,
+ table_name=table_class.table_name,
+ context=context,
+ )
+ )
+ table_class._support = [table_class.full_table_name]
+ table_class.declaration_context = context
+
+ # instantiate the class, declare the table if not already
+ instance = table_class()
+ is_declared = instance.is_declared
+ create_tables = (
+ self.create_tables if self.create_tables is not None else self.connection._config.database.create_tables
+ )
+ if not is_declared and not assert_declared and create_tables:
+ instance.declare(context)
+ self.connection.dependencies.clear()
+ elif is_declared and create_tables:
+ # Table already exists — declare() didn't run, so _populate_lineage
+ # didn't either. Scan the already-loaded heading for the symptom
+ # of stale/missing lineage rows (#1454): any PK attribute with
+ # lineage=None indicates the ~lineage table is missing rows for
+ # this table. Only then trigger a refresh — no extra DB queries
+ # on healthy schemas, automatic repair when the bug is present.
+ #
+ # Note: stale-but-non-None rows (DJ version skew that wrote a
+ # different string format) are not auto-detected here; users hit
+ # the tailored "rebuild_lineage" error message on first join.
+ try:
+ pk_lineages = [instance.heading[attr].lineage for attr in instance.primary_key]
+ except Exception:
+ pk_lineages = []
+ if pk_lineages and any(lineage is None for lineage in pk_lineages):
+ instance._refresh_lineage(context)
+ is_declared = is_declared or instance.is_declared
+
+ # add table definition to the doc string
+ if isinstance(table_class.definition, str):
+ table_class.__doc__ = (table_class.__doc__ or "") + "\nTable definition:\n\n" + table_class.definition
+
+ # fill values in Lookup tables from their contents property
+ if isinstance(instance, Lookup) and hasattr(instance, "contents") and is_declared:
+ contents = list(instance.contents)
+ if len(contents) > len(instance):
+ if instance.heading.has_autoincrement:
+ warnings.warn(
+ ("Contents has changed but cannot be inserted because {table} has autoincrement.").format(
+ table=instance.__class__.__name__
+ )
+ )
+ else:
+ instance.insert(contents, skip_duplicates=True)
+
+ def __repr__(self):
+ return "Schema `{name}`\n".format(name=self.database)
+
+ def make_classes(self, into: dict[str, Any] | None = None) -> None:
+ """
+ Create Python table classes for tables in the schema.
+
+ Introspects the database schema and creates appropriate Python classes
+ (Lookup, Manual, Imported, Computed, Part) for tables that don't have
+ corresponding classes in the target namespace.
+
+ Parameters
+ ----------
+ into : dict, optional
+ Namespace to place created classes into. Defaults to caller's
+ local namespace.
+ """
+ self._assert_exists()
+ if into is None:
+ if self.context is not None:
+ into = self.context
+ else:
+ # if into is missing, use the calling namespace
+ frame = inspect.currentframe().f_back
+ into = frame.f_locals
+ del frame
+ adapter = self.connection.adapter
+ tables = [
+ row[0]
+ for row in self.connection.query(adapter.list_tables_sql(self.database))
+ if lookup_class_name(adapter.make_full_table_name(self.database, row[0]), into, 0) is None
+ ]
+ master_classes = (Lookup, Manual, Imported, Computed)
+ part_tables = []
+ for table_name in tables:
+ class_name = to_camel_case(table_name)
+ if class_name not in into:
+ try:
+ cls = next(cls for cls in master_classes if re.fullmatch(cls.tier_regexp, table_name))
+ except StopIteration:
+ if re.fullmatch(Part.tier_regexp, table_name):
+ part_tables.append(table_name)
+ else:
+ # declare and decorate master table classes
+ into[class_name] = self(type(class_name, (cls,), dict()), context=into)
+
+ # attach parts to masters
+ for table_name in part_tables:
+ groups = re.fullmatch(Part.tier_regexp, table_name).groupdict()
+ class_name = to_camel_case(groups["part"])
+ try:
+ master_class = into[to_camel_case(groups["master"])]
+ except KeyError:
+ raise DataJointError("The table %s does not follow DataJoint naming conventions" % table_name)
+ part_class = type(class_name, (Part,), dict(definition=...))
+ part_class._master = master_class
+ self._decorate_table(part_class, context=into, assert_declared=True)
+ setattr(master_class, class_name, part_class)
+
+ def drop(self, prompt: bool | None = None) -> None:
+ """
+ Drop the associated schema and all its tables.
+
+ Parameters
+ ----------
+ prompt : bool, optional
+ If True, show confirmation prompt before dropping.
+ If False, drop without confirmation.
+ If None (default), use ``dj.config['safemode']`` setting.
+
+ Raises
+ ------
+ AccessError
+ If insufficient permissions to drop the schema.
+ """
+ prompt = self.connection._config["safemode"] if prompt is None else prompt
+
+ if not self.exists:
+ logger.info("Schema named `{database}` does not exist. Doing nothing.".format(database=self.database))
+ elif not prompt or user_choice("Proceed to delete entire schema `%s`?" % self.database, default="no") == "yes":
+ logger.debug("Dropping `{database}`.".format(database=self.database))
+ try:
+ drop_sql = self.connection.adapter.drop_schema_sql(self.database)
+ self.connection.query(drop_sql)
+ logger.debug("Schema `{database}` was dropped successfully.".format(database=self.database))
+ except AccessError:
+ raise AccessError(
+ "An attempt to drop schema `{database}` has failed. Check permissions.".format(database=self.database)
+ )
+
+ @property
+ def exists(self) -> bool:
+ """
+ Check if the associated schema exists on the server.
+
+ Returns
+ -------
+ bool
+ True if the schema exists.
+
+ Raises
+ ------
+ DataJointError
+ If schema has not been activated.
+ """
+ if self.database is None:
+ raise DataJointError("Schema must be activated first.")
+ return bool(self.connection.query(self.connection.adapter.schema_exists_sql(self.database)).rowcount)
+
+ @property
+ def lineage_table_exists(self) -> bool:
+ """
+ Check if the ~lineage table exists in this schema.
+
+ Returns
+ -------
+ bool
+ True if the lineage table exists.
+ """
+ from .lineage import lineage_table_exists
+
+ self._assert_exists()
+ return lineage_table_exists(self.connection, self.database)
+
+ @property
+ def lineage(self) -> dict[str, str]:
+ """
+ Get all lineages for tables in this schema.
+
+ Returns
+ -------
+ dict[str, str]
+ Mapping of ``'schema.table.attribute'`` to its lineage origin.
+ """
+ from .lineage import get_schema_lineages
+
+ self._assert_exists()
+ return get_schema_lineages(self.connection, self.database)
+
+ def rebuild_lineage(self) -> None:
+ """
+ Rebuild the ~lineage table for all tables in this schema.
+
+ Recomputes lineage for all attributes by querying FK relationships
+ from the information_schema. Use to restore lineage for schemas that
+ predate the lineage system or after corruption.
+
+ Notes
+ -----
+ After rebuilding, restart the Python kernel and reimport to pick up
+ the new lineage information.
+
+ Upstream schemas (referenced via cross-schema foreign keys) must
+ have their lineage rebuilt first.
+ """
+ from .lineage import rebuild_schema_lineage
+
+ self._assert_exists()
+ rebuild_schema_lineage(self.connection, self.database)
+
+ @property
+ def jobs(self) -> list[Job]:
+ """
+ Return Job objects for auto-populated tables with job tables.
+
+ Only returns Job objects when both the target table and its
+ ``~~table_name`` job table exist in the database. Job tables are
+ created lazily on first access to ``table.jobs`` or
+ ``populate(reserve_jobs=True)``.
+
+ Returns
+ -------
+ list[Job]
+ Job objects for existing job tables.
+ """
+ self._assert_exists()
+ jobs_list = []
+
+ # Get all existing job tables (~~prefix)
+ # Note: %% escapes the % in pymysql/psycopg2
+ adapter = self.connection.adapter
+ sql = adapter.list_tables_sql(self.database, pattern="~~%%")
+ result = self.connection.query(sql).fetchall()
+ existing_job_tables = {row[0] for row in result}
+
+ # Iterate over auto-populated tables and check if their job table exists
+ for table_name in self.list_tables():
+ adapter = self.connection.adapter
+ full_name = adapter.make_full_table_name(self.database, table_name)
+ table = FreeTable(self.connection, full_name)
+ tier = _get_tier(table.full_table_name)
+ if tier in (Computed, Imported):
+ # Compute expected job table name: ~~base_name
+ base_name = table_name.lstrip("_")
+ job_table_name = f"~~{base_name}"
+ if job_table_name in existing_job_tables:
+ jobs_list.append(Job(table))
+
+ return jobs_list
+
+ def list_tables(self) -> list[str]:
+ """
+ Return all user tables in the schema.
+
+ Excludes hidden tables (starting with ``~``) such as ``~lineage``
+ and job tables (``~~``).
+
+ Returns
+ -------
+ list[str]
+ Table names in topological order.
+ """
+ self.connection.dependencies.load()
+ return [
+ t
+ for d, t in (
+ self.connection.adapter.split_full_table_name(table_name)
+ for table_name in self.connection.dependencies.topo_sort()
+ )
+ if d == self.database
+ ]
+
+ def _find_table_name(self, name: str) -> str | None:
+ """
+ Find the actual SQL table name for a given base name.
+
+ Handles tier prefixes: Manual (none), Lookup (#), Imported (_), Computed (__).
+
+ Parameters
+ ----------
+ name : str
+ Base table name without tier prefix.
+
+ Returns
+ -------
+ str or None
+ The actual SQL table name, or None if not found.
+ """
+ tables = self.list_tables()
+ # Check exact match first
+ if name in tables:
+ return name
+ # Check with tier prefixes
+ for prefix in ("", "#", "_", "__"):
+ candidate = f"{prefix}{name}"
+ if candidate in tables:
+ return candidate
+ return None
+
+ def get_table(self, name: str) -> FreeTable:
+ """
+ Get a table instance by name.
+
+ Returns a FreeTable instance for the given table name. This is useful
+ for accessing tables when you don't have the Python class available.
+
+ Parameters
+ ----------
+ name : str
+ Table name (e.g., 'experiment', 'session__trial' for parts).
+ Can be snake_case (SQL name) or CamelCase (class name).
+ Tier prefixes are optional and will be auto-detected.
+
+ Returns
+ -------
+ FreeTable
+ A FreeTable instance for the table.
+
+ Raises
+ ------
+ DataJointError
+ If the table does not exist.
+
+ Examples
+ --------
+ >>> schema = dj.Schema('my_schema')
+ >>> experiment = schema.get_table('experiment')
+ >>> experiment.fetch()
+ """
+ self._assert_exists()
+ # Convert CamelCase to snake_case if needed
+ if name[0].isupper():
+ name = re.sub(r"(? FreeTable:
+ """
+ Get a table instance by name using bracket notation.
+
+ Parameters
+ ----------
+ name : str
+ Table name (snake_case or CamelCase).
+
+ Returns
+ -------
+ FreeTable
+ A FreeTable instance for the table.
+
+ Examples
+ --------
+ >>> schema = dj.Schema('my_schema')
+ >>> schema['Experiment'].fetch()
+ >>> schema['session'].fetch()
+ """
+ return self.get_table(name)
+
+ def __iter__(self):
+ """
+ Iterate over all tables in the schema.
+
+ Yields FreeTable instances for each table in topological order.
+
+ Yields
+ ------
+ FreeTable
+ Table instances in dependency order.
+
+ Examples
+ --------
+ >>> for table in schema:
+ ... print(table.full_table_name, len(table))
+ """
+ self._assert_exists()
+ for table_name in self.list_tables():
+ yield self.get_table(table_name)
+
+ def __contains__(self, name: str) -> bool:
+ """
+ Check if a table exists in the schema.
+
+ Parameters
+ ----------
+ name : str
+ Table name (snake_case or CamelCase).
+ Tier prefixes are optional and will be auto-detected.
+
+ Returns
+ -------
+ bool
+ True if the table exists.
+
+ Examples
+ --------
+ >>> 'Experiment' in schema
+ True
+ """
+ if name[0].isupper():
+ name = re.sub(r"(?>> lab = dj.VirtualModule('lab', 'my_lab_schema')
+ >>> lab.Subject.fetch()
+ """
+
+ def __init__(
+ self,
+ module_name: str,
+ schema_name: str,
+ *,
+ create_schema: bool = False,
+ create_tables: bool = False,
+ connection: Connection | None = None,
+ add_objects: dict[str, Any] | None = None,
+ ) -> None:
+ """
+ Initialize the virtual module.
+
+ Parameters
+ ----------
+ module_name : str
+ Display name for the module.
+ schema_name : str
+ Database schema name.
+ create_schema : bool, optional
+ If True, create the schema if it doesn't exist. Default False.
+ create_tables : bool, optional
+ If True, allow declaring new tables. Default False.
+ connection : Connection, optional
+ Database connection. Defaults to ``dj.conn()``.
+ add_objects : dict, optional
+ Additional objects to add to the module namespace.
+ """
+ super(VirtualModule, self).__init__(name=module_name)
+ _schema = _Schema(
+ schema_name,
+ create_schema=create_schema,
+ create_tables=create_tables,
+ connection=connection,
+ )
+ if add_objects:
+ self.__dict__.update(add_objects)
+ self.__dict__["schema"] = _schema
+ _schema.make_classes(into=self.__dict__)
+
+
+def list_schemas(connection: Connection | None = None) -> list[str]:
+ """
+ List all accessible schemas on the server.
+
+ Parameters
+ ----------
+ connection : Connection, optional
+ Database connection. Defaults to ``dj.conn()``.
+
+ Returns
+ -------
+ list[str]
+ Names of all accessible schemas.
+ """
+ conn = connection or _get_singleton_connection()
+ return [r[0] for r in conn.query(conn.adapter.list_schemas_sql())]
+
+
+def virtual_schema(
+ schema_name: str,
+ *,
+ connection: Connection | None = None,
+ create_schema: bool = False,
+ create_tables: bool = False,
+ add_objects: dict[str, Any] | None = None,
+) -> VirtualModule:
+ """
+ Create a virtual module for an existing database schema.
+
+ This is the recommended way to access database schemas when you don't have
+ the Python source code that defined them. Returns a module-like object with
+ table classes as attributes.
+
+ Parameters
+ ----------
+ schema_name : str
+ Database schema name.
+ connection : Connection, optional
+ Database connection. Defaults to ``dj.conn()``.
+ create_schema : bool, optional
+ If True, create the schema if it doesn't exist. Default False.
+ create_tables : bool, optional
+ If True, allow declaring new tables. Default False.
+ add_objects : dict, optional
+ Additional objects to add to the module namespace.
+
+ Returns
+ -------
+ VirtualModule
+ A module-like object with table classes as attributes.
+
+ Examples
+ --------
+ >>> lab = dj.virtual_schema('my_lab')
+ >>> lab.Subject.fetch()
+ >>> lab.Session & "subject_id='M001'"
+
+ See Also
+ --------
+ Schema : For defining new schemas with Python classes.
+ VirtualModule : The underlying class (prefer virtual_schema function).
+ """
+ return VirtualModule(
+ schema_name,
+ schema_name,
+ connection=connection,
+ create_schema=create_schema,
+ create_tables=create_tables,
+ add_objects=add_objects,
+ )
diff --git a/src/datajoint/settings.py b/src/datajoint/settings.py
new file mode 100644
index 000000000..6ae23478b
--- /dev/null
+++ b/src/datajoint/settings.py
@@ -0,0 +1,1050 @@
+"""
+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",
+ "strict_provenance": "DJ_STRICT_PROVENANCE",
+ "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.*",
+ )
+
+ strict_provenance: bool = Field(
+ default=False,
+ validation_alias="DJ_STRICT_PROVENANCE",
+ description="If True, enforces the upstream-only convention inside make(): "
+ "reads must go through self.upstream[Ancestor], writes must target self "
+ "or self's Part tables with primary keys consistent with the current key. "
+ "Off by default; opt-in for deployments that need runtime provenance "
+ "guarantees backing downstream lineage / CDC tooling. *New in 2.3.*",
+ )
+
+ # Cache path for query results
+ query_cache: Path | None = None
+
+ # Download path for attachments and filepaths
+ download_path: str = "."
+
+ # Internal: track where config was loaded from
+ _config_path: Path | None = None
+ _secrets_dir: Path | None = None
+
+ @field_validator("loglevel", mode="after")
+ @classmethod
+ def set_logger_level(cls, v: str) -> str:
+ """Update logger level when loglevel changes."""
+ logger.setLevel(v)
+ return v
+
+ @field_validator("query_cache", mode="before")
+ @classmethod
+ def convert_path(cls, v: Any) -> Path | None:
+ """Convert string paths to Path objects."""
+ if v is None:
+ return None
+ return Path(v) if not isinstance(v, Path) else v
+
+ def get_store_spec(self, store: str | None = None, *, use_filepath_default: bool = False) -> dict[str, Any]:
+ """
+ Get configuration for a storage store.
+
+ Parameters
+ ----------
+ store : str, optional
+ Name of the store to retrieve. If None, uses the appropriate default.
+ use_filepath_default : bool, optional
+ If True and store is None, uses stores.filepath_default instead of
+ stores.default. Use for filepath references which are not part of OAS.
+ Default: False (use stores.default for integrated storage).
+
+ Returns
+ -------
+ dict[str, Any]
+ Store configuration dict with validated fields.
+
+ Raises
+ ------
+ DataJointError
+ If store is not configured or has invalid config.
+ """
+ # Handle default store
+ if store is None:
+ if use_filepath_default:
+ # Filepath references use separate default (not part of OAS)
+ if "filepath_default" not in self.stores:
+ raise DataJointError(
+ "stores.filepath_default is not configured. "
+ "Set stores.filepath_default or specify store explicitly with "
+ )
+ store = self.stores["filepath_default"]
+ else:
+ # Integrated storage (hash, schema) uses stores.default
+ if "default" not in self.stores:
+ raise DataJointError("stores.default is not configured")
+ store = self.stores["default"]
+
+ if not isinstance(store, str):
+ default_key = "filepath_default" if use_filepath_default else "default"
+ raise DataJointError(f"stores.{default_key} must be a string")
+
+ # Check store exists
+ if store not in self.stores:
+ raise DataJointError(f"Storage '{store}' is requested but not configured in stores")
+
+ spec = dict(self.stores[store])
+
+ self._apply_common_store_defaults(spec)
+
+ # Validate protocol
+ protocol = spec.get("protocol", "").lower()
+ supported_protocols = ("file", "s3", "gcs", "azure")
+ if protocol not in supported_protocols:
+ from .storage_adapter import get_storage_adapter
+
+ adapter = get_storage_adapter(protocol)
+ if adapter is None:
+ raise DataJointError(
+ f'Unknown protocol "{protocol}" in config.stores["{store}"]. '
+ f"Built-in: {', '.join(supported_protocols)}. "
+ f"Install a plugin package for additional protocols."
+ )
+ adapter.validate_spec(spec)
+ self._validate_prefix_separation(
+ store_name=store,
+ hash_prefix=spec.get("hash_prefix"),
+ schema_prefix=spec.get("schema_prefix"),
+ filepath_prefix=spec.get("filepath_prefix"),
+ )
+ return spec
+
+ # Set protocol-specific defaults
+ if protocol == "s3":
+ spec.setdefault("secure", True) # HTTPS by default for S3
+
+ # Define required and allowed keys by protocol
+ required_keys: dict[str, tuple[str, ...]] = {
+ "file": ("protocol", "location"),
+ "s3": ("protocol", "endpoint", "bucket", "access_key", "secret_key", "location"),
+ "gcs": ("protocol", "bucket", "location"),
+ "azure": ("protocol", "container", "location"),
+ }
+ allowed_keys: dict[str, tuple[str, ...]] = {
+ "file": (
+ "protocol",
+ "location",
+ "subfolding",
+ "partition_pattern",
+ "token_length",
+ "hash_prefix",
+ "schema_prefix",
+ "filepath_prefix",
+ "stage",
+ ),
+ "s3": (
+ "protocol",
+ "endpoint",
+ "bucket",
+ "access_key",
+ "secret_key",
+ "location",
+ "secure",
+ "subfolding",
+ "partition_pattern",
+ "token_length",
+ "hash_prefix",
+ "schema_prefix",
+ "filepath_prefix",
+ "stage",
+ "proxy_server",
+ ),
+ "gcs": (
+ "protocol",
+ "bucket",
+ "location",
+ "token",
+ "project",
+ "subfolding",
+ "partition_pattern",
+ "token_length",
+ "hash_prefix",
+ "schema_prefix",
+ "filepath_prefix",
+ "stage",
+ ),
+ "azure": (
+ "protocol",
+ "container",
+ "location",
+ "account_name",
+ "account_key",
+ "connection_string",
+ "subfolding",
+ "partition_pattern",
+ "token_length",
+ "hash_prefix",
+ "schema_prefix",
+ "filepath_prefix",
+ "stage",
+ ),
+ }
+
+ # Check required keys
+ missing = [k for k in required_keys[protocol] if k not in spec]
+ if missing:
+ raise DataJointError(f'config.stores["{store}"] is missing: {", ".join(missing)}')
+
+ # Check for invalid keys
+ invalid = [k for k in spec if k not in allowed_keys[protocol]]
+ if invalid:
+ raise DataJointError(f'Invalid key(s) in config.stores["{store}"]: {", ".join(invalid)}')
+
+ # Validate prefix separation to prevent overlap
+ self._validate_prefix_separation(
+ store_name=store,
+ hash_prefix=spec.get("hash_prefix"),
+ schema_prefix=spec.get("schema_prefix"),
+ filepath_prefix=spec.get("filepath_prefix"),
+ )
+
+ return spec
+
+ def _validate_prefix_separation(
+ self,
+ store_name: str,
+ hash_prefix: str | None,
+ schema_prefix: str | None,
+ filepath_prefix: str | None,
+ ) -> None:
+ """
+ Validate that storage section prefixes don't overlap.
+
+ Parameters
+ ----------
+ store_name : str
+ Name of the store being validated (for error messages).
+ hash_prefix : str or None
+ Prefix for hash-addressed storage.
+ schema_prefix : str or None
+ Prefix for schema-addressed storage.
+ filepath_prefix : str or None
+ Prefix for filepath storage (None means unrestricted).
+
+ Raises
+ ------
+ DataJointError
+ If any prefixes overlap (one is a parent/child of another).
+ """
+ # Collect non-null prefixes with their names
+ prefixes = []
+ if hash_prefix:
+ prefixes.append(("hash_prefix", hash_prefix))
+ if schema_prefix:
+ prefixes.append(("schema_prefix", schema_prefix))
+ if filepath_prefix:
+ prefixes.append(("filepath_prefix", filepath_prefix))
+
+ # Normalize prefixes: remove leading/trailing slashes, ensure trailing slash for comparison
+ def normalize(p: str) -> str:
+ return p.strip("/") + "/"
+
+ normalized = [(name, normalize(prefix)) for name, prefix in prefixes]
+
+ # Check each pair for overlap
+ for i, (name1, p1) in enumerate(normalized):
+ for j, (name2, p2) in enumerate(normalized[i + 1 :], start=i + 1):
+ # Check if one prefix is a parent of another
+ if p1.startswith(p2) or p2.startswith(p1):
+ raise DataJointError(
+ f'config.stores["{store_name}"]: {name1}="{prefixes[i][1]}" and '
+ f'{name2}="{prefixes[j][1]}" overlap. '
+ f"Storage section prefixes must be mutually exclusive."
+ )
+
+ @staticmethod
+ def _apply_common_store_defaults(spec: dict[str, Any]) -> None:
+ """Apply defaults shared by every store protocol (built-in and plugin)."""
+ spec.setdefault("subfolding", None)
+ spec.setdefault("partition_pattern", None)
+ spec.setdefault("token_length", 8)
+ spec.setdefault("hash_prefix", "_hash")
+ spec.setdefault("schema_prefix", "_schema")
+ spec.setdefault("filepath_prefix", None)
+
+ def load(self, filename: str | Path) -> None:
+ """
+ Load settings from a JSON file.
+
+ Parameters
+ ----------
+ filename : str or Path
+ Path to load configuration from.
+ """
+ filepath = Path(filename)
+ if not filepath.exists():
+ raise FileNotFoundError(f"Config file not found: {filepath}")
+
+ logger.info(f"Loading configuration from {filepath.absolute()}")
+
+ with open(filepath) as f:
+ data = json.load(f)
+
+ self._update_from_flat_dict(data)
+ self._config_path = filepath
+
+ def _update_from_flat_dict(self, data: dict[str, Any]) -> None:
+ """
+ Update settings from a dict (flat dot-notation or nested).
+
+ Environment variables take precedence over config file values.
+ If an env var is set for a setting, the file value is skipped.
+ """
+ for key, value in data.items():
+ # Special handling for stores - accept nested dict directly
+ if key == "stores" and isinstance(value, dict):
+ # Merge stores dict
+ for store_key, store_value in value.items():
+ self.stores[store_key] = store_value
+ continue
+
+ # Handle nested dicts by recursively updating
+ if isinstance(value, dict) and hasattr(self, key):
+ group_obj = getattr(self, key)
+ for nested_key, nested_value in value.items():
+ if hasattr(group_obj, nested_key):
+ # Check if env var is set for this nested key
+ full_key = f"{key}.{nested_key}"
+ env_var = ENV_VAR_MAPPING.get(full_key)
+ if env_var and os.environ.get(env_var):
+ logger.debug(f"Skipping {full_key} from file (env var {env_var} takes precedence)")
+ continue
+ setattr(group_obj, nested_key, nested_value)
+ continue
+
+ # Handle flat dot-notation keys
+ parts = key.split(".")
+ if len(parts) == 1:
+ if hasattr(self, key) and not key.startswith("_"):
+ # Check if env var is set for this key
+ env_var = ENV_VAR_MAPPING.get(key)
+ if env_var and os.environ.get(env_var):
+ logger.debug(f"Skipping {key} from file (env var {env_var} takes precedence)")
+ continue
+ setattr(self, key, value)
+ elif len(parts) == 2:
+ group, attr = parts
+ if hasattr(self, group):
+ group_obj = getattr(self, group)
+ if hasattr(group_obj, attr):
+ # Check if env var is set for this key
+ env_var = ENV_VAR_MAPPING.get(key)
+ if env_var and os.environ.get(env_var):
+ logger.debug(f"Skipping {key} from file (env var {env_var} takes precedence)")
+ continue
+ setattr(group_obj, attr, value)
+ elif len(parts) == 3:
+ # Handle stores.. pattern
+ group, store_name, attr = parts
+ if group == "stores":
+ if store_name not in self.stores:
+ self.stores[store_name] = {}
+ self.stores[store_name][attr] = value
+
+ def _load_secrets(self, secrets_dir: Path) -> None:
+ """Load secrets from a secrets directory."""
+ self._secrets_dir = secrets_dir
+
+ # Load database secrets
+ db_user = read_secret_file(secrets_dir, "database.user")
+ if db_user is not None and self.database.user is None:
+ self.database.user = db_user
+ logger.debug(f"Loaded database.user from {secrets_dir}")
+
+ db_password = read_secret_file(secrets_dir, "database.password")
+ if db_password is not None and self.database.password is None:
+ self.database.password = db_password
+ logger.debug(f"Loaded database.password from {secrets_dir}")
+
+ # Load per-store secrets from any stores.. file.
+ # The attr name is recorded as-is on stores.; this lets
+ # plugin-registered adapters define their own secret fields
+ # (e.g. a Bearer ``token`` for HTTP-based protocols) without
+ # forcing AWS-style ``access_key`` / ``secret_key`` naming.
+ if secrets_dir.is_dir():
+ for secret_file in secrets_dir.iterdir():
+ if not secret_file.is_file() or secret_file.name.startswith("."):
+ continue
+
+ parts = secret_file.name.split(".")
+ if len(parts) == 3 and parts[0] == "stores":
+ store_name, attr = parts[1], parts[2]
+ value = secret_file.read_text().strip()
+ # Initialize store dict if needed
+ if store_name not in self.stores:
+ self.stores[store_name] = {}
+ # Only set if not already present (config / env vars win)
+ if attr not in self.stores[store_name]:
+ self.stores[store_name][attr] = value
+ logger.debug(f"Loaded stores.{store_name}.{attr} from {secrets_dir}")
+
+ def _apply_stores_env(self) -> None:
+ """Replace ``self.stores`` from the ``DJ_STORES`` env var if set.
+
+ ``DJ_STORES`` holds a JSON object in the same shape as the ``stores``
+ block of ``datajoint.json``. This lets env-var-only deployments
+ configure plugin-registered storage adapters with arbitrary attr
+ names (e.g. a Bearer ``token`` field) without negotiating an env-var
+ naming scheme per attr.
+
+ *New in 2.2.3.*
+ """
+ raw = os.environ.get("DJ_STORES")
+ if not raw:
+ return
+ try:
+ data = json.loads(raw)
+ except json.JSONDecodeError as e:
+ raise ValueError(f"DJ_STORES contains invalid JSON: {e}") from e
+ if not isinstance(data, dict):
+ raise ValueError(f"DJ_STORES must be a JSON object, got {type(data).__name__}")
+ self.stores = data
+ logger.debug("Loaded stores from DJ_STORES env var")
+
+ @contextmanager
+ def override(self, **kwargs: Any) -> Iterator["Config"]:
+ """
+ Temporarily override configuration values.
+
+ Parameters
+ ----------
+ **kwargs : Any
+ Settings to override. Use double underscore for nested settings
+ (e.g., ``database__host="localhost"``).
+
+ Yields
+ ------
+ Config
+ The config instance with overridden values.
+
+ Examples
+ --------
+ >>> with config.override(safemode=False, database__host="test"):
+ ... # config.safemode is False here
+ ... pass
+ >>> # config.safemode is restored
+ """
+ # Store original values
+ backup = {}
+
+ # Convert double underscore to nested access
+ converted = {}
+ for key, value in kwargs.items():
+ if "__" in key:
+ parts = key.split("__")
+ converted[tuple(parts)] = value
+ else:
+ converted[(key,)] = value
+
+ try:
+ # Save originals and apply overrides
+ for key_parts, value in converted.items():
+ if len(key_parts) == 1:
+ key = key_parts[0]
+ if hasattr(self, key):
+ backup[key_parts] = deepcopy(getattr(self, key))
+ setattr(self, key, value)
+ elif len(key_parts) == 2:
+ group, attr = key_parts
+ if hasattr(self, group):
+ group_obj = getattr(self, group)
+ if hasattr(group_obj, attr):
+ backup[key_parts] = deepcopy(getattr(group_obj, attr))
+ setattr(group_obj, attr, value)
+
+ yield self
+
+ finally:
+ # Restore original values
+ for key_parts, original in backup.items():
+ if len(key_parts) == 1:
+ setattr(self, key_parts[0], original)
+ elif len(key_parts) == 2:
+ group, attr = key_parts
+ setattr(getattr(self, group), attr, original)
+
+ @staticmethod
+ def save_template(
+ path: str | Path = "datajoint.json",
+ minimal: bool = True,
+ create_secrets_dir: bool = True,
+ ) -> Path:
+ """
+ Create a template datajoint.json configuration file.
+
+ Credentials should NOT be stored in datajoint.json. Instead, use either:
+
+ - Environment variables (``DJ_USER``, ``DJ_PASS``, ``DJ_HOST``,
+ ``DJ_STORES`` for JSON-encoded store configs, etc.)
+ - The ``.secrets/`` directory (created alongside datajoint.json)
+
+ Set ``DJ_IGNORE_CONFIG_FILE=true`` to skip both ``datajoint.json`` and
+ the secrets directory entirely (env-var-only configuration).
+
+ Parameters
+ ----------
+ path : str or Path, optional
+ Where to save the template. Default ``'datajoint.json'``.
+ minimal : bool, optional
+ If True (default), create minimal template with just database settings.
+ If False, create full template with all available settings.
+ create_secrets_dir : bool, optional
+ If True (default), also create a ``.secrets/`` directory with
+ template files for credentials.
+
+ Returns
+ -------
+ Path
+ Absolute path to the created config file.
+
+ Raises
+ ------
+ FileExistsError
+ If config file already exists (won't overwrite).
+
+ Examples
+ --------
+ >>> import datajoint as dj
+ >>> dj.config.save_template() # Creates minimal template + .secrets/
+ >>> dj.config.save_template("full-config.json", minimal=False)
+ """
+ filepath = Path(path)
+ if filepath.exists():
+ raise FileExistsError(f"File already exists: {filepath}. Remove it first or choose a different path.")
+
+ if minimal:
+ template = {
+ "database": {
+ "host": "localhost",
+ "port": 3306,
+ },
+ }
+ else:
+ template = {
+ "database": {
+ "host": "localhost",
+ "port": 3306,
+ "reconnect": True,
+ "use_tls": None,
+ },
+ "connection": {
+ "charset": "",
+ },
+ "display": {
+ "limit": 12,
+ "width": 14,
+ "show_tuple_count": True,
+ },
+ "stores": {
+ "default": "main",
+ "filepath_default": "raw_data",
+ "main": {
+ "protocol": "file",
+ "location": "/data/my-project/main",
+ "partition_pattern": None,
+ "token_length": 8,
+ "subfolding": None,
+ },
+ "raw_data": {
+ "protocol": "file",
+ "location": "/data/my-project/raw",
+ },
+ },
+ "loglevel": "INFO",
+ "safemode": True,
+ "query_cache": None,
+ "download_path": ".",
+ }
+
+ with open(filepath, "w") as f:
+ json.dump(template, f, indent=2)
+ f.write("\n")
+
+ logger.info(f"Created template configuration at {filepath.absolute()}")
+
+ # Create .secrets/ directory with template files
+ if create_secrets_dir:
+ secrets_dir = filepath.parent / SECRETS_DIRNAME
+ secrets_dir.mkdir(exist_ok=True)
+
+ # Create placeholder secret files
+ secret_templates = {
+ "database.user": "your_username",
+ "database.password": "your_password",
+ }
+ for secret_name, placeholder in secret_templates.items():
+ secret_file = secrets_dir / secret_name
+ if not secret_file.exists():
+ secret_file.write_text(placeholder)
+
+ # Create .gitignore to prevent committing secrets
+ gitignore_path = secrets_dir / ".gitignore"
+ if not gitignore_path.exists():
+ gitignore_path.write_text("# Never commit secrets\n*\n!.gitignore\n")
+
+ logger.info(
+ f"Created {SECRETS_DIRNAME}/ directory with credential templates. "
+ f"Edit the files in {secrets_dir.absolute()}/ to set your credentials."
+ )
+
+ return filepath.absolute()
+
+ # Dict-like access for convenience
+ def __getitem__(self, key: str) -> Any:
+ """Get setting by dot-notation key (e.g., 'database.host')."""
+ parts = key.split(".")
+ obj: Any = self
+ for part in parts:
+ if hasattr(obj, part):
+ obj = getattr(obj, part)
+ elif isinstance(obj, dict):
+ obj = obj[part]
+ else:
+ raise KeyError(f"Setting '{key}' not found")
+ # Unwrap SecretStr for compatibility
+ if isinstance(obj, SecretStr):
+ return obj.get_secret_value()
+ return obj
+
+ def __setitem__(self, key: str, value: Any) -> None:
+ """Set setting by dot-notation key (e.g., 'database.host')."""
+ parts = key.split(".")
+ if len(parts) == 1:
+ if hasattr(self, key):
+ setattr(self, key, value)
+ else:
+ raise KeyError(f"Setting '{key}' not found")
+ else:
+ obj: Any = self
+ for part in parts[:-1]:
+ obj = getattr(obj, part)
+ setattr(obj, parts[-1], value)
+
+ def __delitem__(self, key: str) -> None:
+ """Reset setting to default by dot-notation key."""
+ # Get the default value from the model fields (access from class, not instance)
+ parts = key.split(".")
+ if len(parts) == 1:
+ field_info = type(self).model_fields.get(key)
+ if field_info is not None:
+ default = field_info.default
+ if default is not None:
+ setattr(self, key, default)
+ elif field_info.default_factory is not None:
+ setattr(self, key, field_info.default_factory())
+ else:
+ setattr(self, key, None)
+ else:
+ raise KeyError(f"Setting '{key}' not found")
+ else:
+ # For nested settings, reset to None or empty
+ obj: Any = self
+ for part in parts[:-1]:
+ obj = getattr(obj, part)
+ setattr(obj, parts[-1], None)
+
+ def get(self, key: str, default: Any = None) -> Any:
+ """Get setting with optional default value."""
+ try:
+ return self[key]
+ except KeyError:
+ return default
+
+
+def _create_config() -> Config:
+ """Create and initialize the global config instance."""
+ cfg = Config()
+
+ config_path: Path | None = None
+ if not cfg.ignore_config_file:
+ config_path = find_config_file()
+ if config_path is not None:
+ try:
+ cfg.load(config_path)
+ except Exception as e:
+ warnings.warn(f"Failed to load config from {config_path}: {e}")
+ else:
+ warnings.warn(
+ f"No {CONFIG_FILENAME} found. Using defaults and environment variables. "
+ f"Run `dj.config.save_template()` to create a template configuration.",
+ stacklevel=2,
+ )
+
+ # DJ_STORES (if set) overrides the stores dict from the config file
+ cfg._apply_stores_env()
+
+ # Secrets fill missing attrs in whatever ended up in self.stores
+ if not cfg.ignore_config_file:
+ secrets_dir = find_secrets_dir(config_path)
+ if secrets_dir is not None:
+ cfg._load_secrets(secrets_dir)
+
+ # Set initial log level
+ logger.setLevel(cfg.loglevel)
+
+ return cfg
+
+
+# Global config instance
+config = _create_config()
diff --git a/src/datajoint/spark.py b/src/datajoint/spark.py
new file mode 100644
index 000000000..29397b64f
--- /dev/null
+++ b/src/datajoint/spark.py
@@ -0,0 +1,92 @@
+"""
+SparkAdapter Codec Protocol.
+
+Opt-in contract for codecs that adapt their decoded values to Spark-native
+types — primitives, lists, dicts, and nested combinations.
+
+Codecs implement this method when they want their column eligible for
+downstream typed-query systems (Spark SQL, Delta Sharing, BI tools).
+Generic codecs like ```` and ```` deliberately do not
+implement it: their decoded values can be arbitrary Python objects with
+no fixed Spark-native shape.
+
+The contract is intentionally a Protocol rather than an abstract method
+on :class:`datajoint.Codec`:
+
+- Generic codecs need no acknowledgement (no ``NotImplementedError`` stubs).
+- Existing plugin codecs continue to work unchanged.
+- Codec authors opt in by adding the method on their own release cadence.
+- Consumers detect support structurally via ``isinstance(codec, SparkAdapter)``.
+
+See ``datajoint-docs/src/reference/specs/spark-adapter.md`` for the
+normative specification (signature, return-value shape constraints,
+worked codec examples).
+"""
+
+from __future__ import annotations
+
+from typing import Any, Protocol, runtime_checkable
+
+
+@runtime_checkable
+class SparkAdapter(Protocol):
+ """
+ A codec that adapts its decoded values to Spark-native types.
+
+ Opt-in. Codecs implementing this method declare that their decoded
+ values can be expressed as primitives, lists, or dicts of the same —
+ i.e., shapes that map cleanly to Spark's ``StructType`` /
+ ``ArrayType`` / ``MapType``.
+
+ Consumers (e.g., a Databricks silver-layer publish pipeline) check
+ ``isinstance(codec, SparkAdapter)`` per column to determine eligibility.
+
+ Allowed return-value shapes:
+
+ - Primitives: ``bool``, ``int``, ``float``, ``str``, ``bytes``,
+ ``None``, ``datetime.date``, ``datetime.datetime``.
+ - ``list[T]`` where ``T`` is any allowed shape (→ Spark ``ArrayType``).
+ - ``dict[str, T]`` where ``T`` is any allowed shape (→ Spark
+ ``StructType`` or ``MapType``, consumer-decided).
+
+ NumPy arrays must be converted to lists; no tuples, sets, or custom
+ objects in the return value.
+
+ Examples
+ --------
+ A 1D float-array codec (shipped as a plugin, not in datajoint-python)::
+
+ class FloatArrayCodec(dj.Codec):
+ name = "float_array"
+
+ def encode(self, value, *, key=None, store_name=None): ...
+ def decode(self, stored, *, key=None) -> np.ndarray: ...
+
+ def to_spark(self, decoded: np.ndarray, *, key=None) -> list[float]:
+ return decoded.tolist() # → Spark ARRAY
+
+ Eligibility check::
+
+ from datajoint import SparkAdapter
+ isinstance(FloatArrayCodec(), SparkAdapter) # True
+ """
+
+ def to_spark(self, decoded: Any, *, key: dict | None = None) -> Any:
+ """
+ Adapt a decoded codec value to a Spark-native shape.
+
+ Parameters
+ ----------
+ decoded : Any
+ The Python value produced by the codec's ``decode()``.
+ key : dict, optional
+ Optional context dict — same shape as ``Codec.encode``'s
+ ``key`` parameter. Most codecs ignore it.
+
+ Returns
+ -------
+ Any
+ A value composed entirely of allowed Spark-native shapes
+ (see class docstring).
+ """
+ ...
diff --git a/src/datajoint/staged_insert.py b/src/datajoint/staged_insert.py
new file mode 100644
index 000000000..ffbe8a8f2
--- /dev/null
+++ b/src/datajoint/staged_insert.py
@@ -0,0 +1,274 @@
+"""
+Staged insert context manager for direct object storage writes.
+
+This module provides the StagedInsert class which allows writing directly
+to object storage before finalizing the database insert.
+"""
+
+from contextlib import contextmanager
+from datetime import datetime, timezone
+from typing import IO, TYPE_CHECKING, Any
+
+import fsspec
+
+from .codecs import resolve_dtype
+from .errors import DataJointError
+from .hash_registry import get_store_backend
+from .storage import build_object_path
+
+if TYPE_CHECKING:
+ from .storage import StorageBackend
+
+
+class StagedInsert:
+ """
+ Context manager for staged insert operations.
+
+ Allows direct writes to object storage before finalizing the database insert.
+ Used for large objects like Zarr arrays where copying from local storage
+ is inefficient.
+
+ Usage:
+ with table.staged_insert1 as staged:
+ staged.rec['subject_id'] = 123
+ staged.rec['session_id'] = 45
+
+ # Write directly to object storage
+ z = zarr.open(staged.store('raw_data', '.zarr'), mode='w', shape=(1000, 1000))
+ z[:] = data
+
+ # On clean exit: metadata is computed and the row is inserted.
+ # The caller does NOT assign anything to staged.rec[] —
+ # the framework computes the metadata dict.
+ # On exception: storage cleaned up, no row inserted.
+ """
+
+ def __init__(self, table):
+ """
+ Initialize a staged insert.
+
+ Args:
+ table: The Table instance to insert into
+ """
+ self._table = table
+ self._rec: dict[str, Any] = {}
+ self._staged_objects: dict[str, dict] = {} # field -> {relative_path, ext, token, store_name}
+
+ @property
+ def rec(self) -> dict[str, Any]:
+ """Record dict for setting attribute values."""
+ return self._rec
+
+ @property
+ def fs(self) -> fsspec.AbstractFileSystem:
+ """
+ Return fsspec filesystem for the default store, for advanced operations.
+
+ For per-field access, prefer ``staged.store(field)`` or ``staged.open(field)`` —
+ those route to the store resolved from the field's type spec.
+ """
+ return self._default_backend().fs
+
+ def _default_backend(self):
+ """Return the StorageBackend for the default store, or raise a clear error."""
+ try:
+ return get_store_backend(None, config=self._table.connection._config)
+ except DataJointError:
+ raise DataJointError("Storage is not configured. Set stores.default and stores. settings in datajoint.json.")
+
+ def _resolve_field(self, field: str, ext: str) -> tuple[str, "StorageBackend"]:
+ """
+ Resolve a field to its (relative_path, backend), caching on first call.
+
+ Validates the field is an ```` attribute and that the full
+ primary key is set on ``staged.rec``.
+ """
+ if field in self._staged_objects:
+ info = self._staged_objects[field]
+ return info["relative_path"], self._field_backend(info["store_name"])
+
+ if field not in self._table.heading:
+ raise DataJointError(f"Attribute '{field}' not found in table heading")
+
+ attr = self._table.heading[field]
+ if not (attr.codec and attr.codec.name == "object"):
+ raise DataJointError(f"Attribute '{field}' is not an type")
+
+ primary_key = {k: self._rec[k] for k in self._table.primary_key if k in self._rec}
+ if len(primary_key) != len(self._table.primary_key):
+ raise DataJointError(
+ "Primary key values must be set in staged.rec before calling store() or open(). "
+ f"Missing: {set(self._table.primary_key) - set(primary_key)}"
+ )
+
+ # Resolve the store name from the field's type spec (e.g., -> "local")
+ _, _, store_name = resolve_dtype(f"<{attr.codec.name}>", store_name=attr.store)
+
+ config = self._table.connection._config
+ try:
+ spec = config.get_store_spec(store_name)
+ except DataJointError:
+ raise DataJointError("Storage is not configured. Set stores.default and stores. settings in datajoint.json.")
+ partition_pattern = spec.get("partition_pattern")
+ token_length = spec.get("token_length", 8)
+
+ relative_path, token = build_object_path(
+ schema=self._table.database,
+ table=self._table.class_name,
+ field=field,
+ primary_key=primary_key,
+ ext=ext if ext else None,
+ partition_pattern=partition_pattern,
+ token_length=token_length,
+ )
+
+ self._staged_objects[field] = {
+ "relative_path": relative_path,
+ "ext": ext if ext else None,
+ "token": token,
+ "store_name": store_name,
+ }
+
+ return relative_path, self._field_backend(store_name)
+
+ def _field_backend(self, store_name: str | None):
+ """Return the StorageBackend for the named store."""
+ try:
+ return get_store_backend(store_name, config=self._table.connection._config)
+ except DataJointError:
+ raise DataJointError("Storage is not configured. Set stores.default and stores. settings in datajoint.json.")
+
+ def store(self, field: str, ext: str = "") -> fsspec.FSMap:
+ """
+ Get an FSMap for direct writes to an ```` field.
+
+ Args:
+ field: Name of the object attribute
+ ext: Optional extension (e.g., ".zarr", ".hdf5")
+
+ Returns:
+ fsspec.FSMap suitable for Zarr/xarray
+ """
+ relative_path, backend = self._resolve_field(field, ext)
+ return backend.get_fsmap(relative_path)
+
+ def open(self, field: str, ext: str = "", mode: str = "wb") -> IO:
+ """
+ Open a file for direct writes to an ```` field.
+
+ Args:
+ field: Name of the object attribute
+ ext: Optional extension (e.g., ".bin", ".dat")
+ mode: File mode (default: "wb")
+
+ Returns:
+ File-like object for writing
+ """
+ relative_path, backend = self._resolve_field(field, ext)
+ return backend.open(relative_path, mode)
+
+ def _compute_metadata(self, field: str) -> dict:
+ """
+ Compute the canonical ```` metadata dict for a staged write.
+
+ The returned dict is structurally equal to what ``ObjectCodec.encode``
+ would produce for the same content, modulo ``timestamp``.
+
+ Returns
+ -------
+ dict
+ ``{path, store, size, ext, is_dir, item_count, timestamp}``
+ """
+ info = self._staged_objects[field]
+ relative_path = info["relative_path"]
+ ext = info["ext"]
+ store_name = info["store_name"]
+ backend = self._field_backend(store_name)
+
+ full_remote_path = backend._full_path(relative_path)
+
+ try:
+ is_dir = backend.fs.isdir(full_remote_path)
+ except Exception:
+ is_dir = False
+
+ if is_dir:
+ total_size = 0
+ item_count = 0
+ for root, _dirs, filenames in backend.fs.walk(full_remote_path):
+ for filename in filenames:
+ try:
+ total_size += backend.fs.size(f"{root}/{filename}")
+ item_count += 1
+ except Exception:
+ pass
+ size = total_size
+ else:
+ try:
+ size = backend.size(relative_path)
+ except Exception:
+ size = 0
+ item_count = None
+
+ return {
+ "path": relative_path,
+ "store": store_name,
+ "size": size,
+ "ext": ext,
+ "is_dir": is_dir,
+ "item_count": item_count,
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ }
+
+ def _finalize(self):
+ """
+ Compute metadata for each staged object and insert the row.
+ """
+ for field in list(self._staged_objects.keys()):
+ self._rec[field] = self._compute_metadata(field)
+ self._table.insert1(self._rec)
+
+ def _cleanup(self):
+ """
+ Best-effort removal of staged objects on failure.
+ """
+ for field, info in self._staged_objects.items():
+ relative_path = info["relative_path"]
+ try:
+ backend = self._field_backend(info["store_name"])
+ full_remote_path = backend._full_path(relative_path)
+ if backend.fs.exists(full_remote_path):
+ if backend.fs.isdir(full_remote_path):
+ backend.remove_folder(relative_path)
+ else:
+ backend.remove(relative_path)
+ except Exception:
+ pass # Best-effort cleanup
+
+
+@contextmanager
+def staged_insert1(table):
+ """
+ Context manager for staged insert operations.
+
+ Args:
+ table: The Table instance to insert into
+
+ Yields:
+ StagedInsert instance for setting record values and getting storage handles
+
+ Example:
+ with staged_insert1(Recording) as staged:
+ staged.rec['subject_id'] = 123
+ staged.rec['session_id'] = 45
+ z = zarr.open(staged.store('raw_data', '.zarr'), mode='w')
+ z[:] = data
+ # Metadata for 'raw_data' is computed on clean exit; do not assign it here.
+ """
+ staged = StagedInsert(table)
+ try:
+ yield staged
+ staged._finalize()
+ except Exception:
+ staged._cleanup()
+ raise
diff --git a/src/datajoint/storage.py b/src/datajoint/storage.py
new file mode 100644
index 000000000..6a8260163
--- /dev/null
+++ b/src/datajoint/storage.py
@@ -0,0 +1,1040 @@
+"""
+Storage backend abstraction using fsspec for unified file operations.
+
+This module provides a unified interface for storage operations across different
+backends (local filesystem, S3, GCS, Azure, etc.) using the fsspec library.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import secrets
+import urllib.parse
+from datetime import datetime, timezone
+from pathlib import Path, PurePosixPath
+from typing import Any
+
+import fsspec
+
+from . import errors
+
+logger = logging.getLogger(__name__.split(".")[0])
+
+# Characters safe for use in filenames and URLs
+TOKEN_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
+
+# Supported URL protocols
+URL_PROTOCOLS = ("file://", "s3://", "gs://", "gcs://", "az://", "abfs://", "http://", "https://")
+
+
+def is_url(path: str) -> bool:
+ """
+ Check if a path is a URL.
+
+ Parameters
+ ----------
+ path : str
+ Path string to check.
+
+ Returns
+ -------
+ bool
+ True if path starts with a supported URL protocol.
+ """
+ return path.lower().startswith(URL_PROTOCOLS)
+
+
+def normalize_to_url(path: str) -> str:
+ """
+ Normalize a path to URL form.
+
+ Converts local filesystem paths to file:// URLs. URLs are returned unchanged.
+
+ Parameters
+ ----------
+ path : str
+ Path string (local path or URL).
+
+ Returns
+ -------
+ str
+ URL form of the path.
+
+ Examples
+ --------
+ >>> normalize_to_url("/data/file.dat")
+ 'file:///data/file.dat'
+ >>> normalize_to_url("s3://bucket/key")
+ 's3://bucket/key'
+ >>> normalize_to_url("file:///already/url")
+ 'file:///already/url'
+ """
+ if is_url(path):
+ return path
+ # Convert local path to file:// URL
+ # Ensure absolute path and proper format
+ abs_path = str(Path(path).resolve())
+ # Handle Windows paths (C:\...) vs Unix paths (/...)
+ if abs_path.startswith("/"):
+ return f"file://{abs_path}"
+ else:
+ # Windows: file:///C:/path
+ return f"file:///{abs_path.replace(chr(92), '/')}"
+
+
+def parse_url(url: str) -> tuple[str, str]:
+ """
+ Parse a URL into protocol and path.
+
+ Parameters
+ ----------
+ url : str
+ URL (e.g., ``'s3://bucket/path/file.dat'`` or ``'file:///path/to/file'``).
+
+ Returns
+ -------
+ tuple[str, str]
+ ``(protocol, path)`` where protocol is fsspec-compatible.
+
+ Raises
+ ------
+ DataJointError
+ If URL protocol is not supported.
+
+ Examples
+ --------
+ >>> parse_url("s3://bucket/key/file.dat")
+ ('s3', 'bucket/key/file.dat')
+ >>> parse_url("file:///data/file.dat")
+ ('file', '/data/file.dat')
+ """
+ url_lower = url.lower()
+
+ # Map URL schemes to fsspec protocols
+ protocol_map = {
+ "file://": "file",
+ "s3://": "s3",
+ "gs://": "gcs",
+ "gcs://": "gcs",
+ "az://": "abfs",
+ "abfs://": "abfs",
+ "http://": "http",
+ "https://": "https",
+ }
+
+ for prefix, protocol in protocol_map.items():
+ if url_lower.startswith(prefix):
+ path = url[len(prefix) :]
+ return protocol, path
+
+ raise errors.DataJointError(f"Unsupported URL protocol: {url}")
+
+
+def generate_token(length: int = 8) -> str:
+ """
+ Generate a random token for filename collision avoidance.
+
+ Parameters
+ ----------
+ length : int, optional
+ Token length, clamped to 4-16 characters. Default 8.
+
+ Returns
+ -------
+ str
+ Random URL-safe string.
+ """
+ length = max(4, min(16, length))
+ return "".join(secrets.choice(TOKEN_ALPHABET) for _ in range(length))
+
+
+def encode_pk_value(value: Any) -> str:
+ """
+ Encode a primary key value for use in storage paths.
+
+ Parameters
+ ----------
+ value : any
+ Primary key value (int, str, date, datetime, etc.).
+
+ Returns
+ -------
+ str
+ Path-safe string representation.
+ """
+ if isinstance(value, (int, float)):
+ return str(value)
+ if isinstance(value, datetime):
+ # Use ISO format with safe separators
+ return value.strftime("%Y-%m-%dT%H-%M-%S")
+ if hasattr(value, "isoformat"):
+ # Handle date objects
+ return value.isoformat()
+
+ # String handling
+ s = str(value)
+ # Check if path-safe (no special characters)
+ unsafe_chars = '/\\:*?"<>|'
+ if any(c in s for c in unsafe_chars) or len(s) > 100:
+ # URL-encode unsafe strings or truncate long ones
+ if len(s) > 100:
+ # Truncate and add hash suffix for uniqueness
+ import hashlib
+
+ hash_suffix = hashlib.md5(s.encode()).hexdigest()[:8]
+ s = s[:50] + "_" + hash_suffix
+ return urllib.parse.quote(s, safe="")
+ return s
+
+
+def build_object_path(
+ schema: str,
+ table: str,
+ field: str,
+ primary_key: dict[str, Any],
+ ext: str | None,
+ partition_pattern: str | None = None,
+ token_length: int = 8,
+) -> tuple[str, str]:
+ """
+ Build the storage path for an object attribute.
+
+ Parameters
+ ----------
+ schema : str
+ Schema name.
+ table : str
+ Table name.
+ field : str
+ Field/attribute name.
+ primary_key : dict[str, Any]
+ Dict of primary key attribute names to values.
+ ext : str or None
+ File extension (e.g., ``".dat"``).
+ partition_pattern : str, optional
+ Partition pattern with ``{attr}`` placeholders.
+ token_length : int, optional
+ Length of random token suffix. Default 8.
+
+ Returns
+ -------
+ tuple[str, str]
+ ``(relative_path, token)``.
+ """
+ token = generate_token(token_length)
+
+ # Build filename: field_token.ext
+ filename = f"{field}_{token}"
+ if ext:
+ if not ext.startswith("."):
+ ext = "." + ext
+ filename += ext
+
+ # Build primary key path components
+ pk_parts = []
+ partition_attrs = set()
+ partition_attr_list = []
+
+ # Extract partition attributes if pattern specified
+ if partition_pattern:
+ import re
+
+ # Preserve order from pattern
+ partition_attr_list = re.findall(r"\{(\w+)\}", partition_pattern)
+ partition_attrs = set(partition_attr_list) # For fast lookup
+
+ # Build partition prefix (attributes in order from partition pattern)
+ partition_parts = []
+ for attr in partition_attr_list:
+ if attr in primary_key:
+ partition_parts.append(f"{attr}={encode_pk_value(primary_key[attr])}")
+
+ # Build remaining PK path (attributes not in partition)
+ for attr, value in primary_key.items():
+ if attr not in partition_attrs:
+ pk_parts.append(f"{attr}={encode_pk_value(value)}")
+
+ # Construct full path
+ # Pattern: {partition_attrs}/{schema}/{table}/{remaining_pk}/{filename}
+ parts = []
+ if partition_parts:
+ parts.extend(partition_parts)
+ parts.append(schema)
+ parts.append(table)
+ if pk_parts:
+ parts.extend(pk_parts)
+ parts.append(filename)
+
+ return "/".join(parts), token
+
+
+class StorageBackend:
+ """
+ Unified storage backend using fsspec.
+
+ Provides a consistent interface for file operations across different storage
+ backends including local filesystem and cloud object storage (S3, GCS, Azure).
+
+ Parameters
+ ----------
+ spec : dict[str, Any]
+ Storage configuration dictionary. See ``__init__`` for details.
+
+ Attributes
+ ----------
+ spec : dict
+ Storage configuration dictionary.
+ protocol : str
+ Storage protocol (``'file'``, ``'s3'``, ``'gcs'``, ``'azure'``).
+ """
+
+ def __init__(self, spec: dict[str, Any]) -> None:
+ """
+ Initialize storage backend from configuration spec.
+
+ Parameters
+ ----------
+ spec : dict[str, Any]
+ Storage configuration dictionary containing:
+
+ - ``protocol``: Storage protocol (``'file'``, ``'s3'``, ``'gcs'``, ``'azure'``)
+ - ``location``: Base path or bucket prefix
+ - ``bucket``: Bucket name (for cloud storage)
+ - ``endpoint``: Endpoint URL (for S3-compatible storage)
+ - ``access_key``: Access key (for cloud storage)
+ - ``secret_key``: Secret key (for cloud storage)
+ - ``secure``: Use HTTPS (default True for cloud)
+ """
+ self.spec = spec
+ self.protocol = spec.get("protocol", "file")
+ self._fs = None
+ self._validate_spec()
+
+ def _validate_spec(self):
+ """Validate configuration spec for the protocol."""
+ if self.protocol == "file":
+ location = self.spec.get("location")
+ if location and not Path(location).is_dir():
+ raise FileNotFoundError(f"Inaccessible local directory {location}")
+ elif self.protocol == "s3":
+ required = ["endpoint", "bucket", "access_key", "secret_key"]
+ missing = [k for k in required if not self.spec.get(k)]
+ if missing:
+ raise errors.DataJointError(f"Missing S3 configuration: {', '.join(missing)}")
+
+ @property
+ def fs(self) -> fsspec.AbstractFileSystem:
+ """Get or create the fsspec filesystem instance."""
+ if self._fs is None:
+ self._fs = self._create_filesystem()
+ return self._fs
+
+ def _require_adapter(self):
+ """Look up a registered storage adapter, raising if none is registered."""
+ from .storage_adapter import get_storage_adapter
+
+ adapter = get_storage_adapter(self.protocol)
+ if adapter is None:
+ raise errors.DataJointError(f"Unsupported storage protocol: {self.protocol}")
+ return adapter
+
+ def _create_filesystem(self) -> fsspec.AbstractFileSystem:
+ """Create fsspec filesystem based on protocol."""
+ if self.protocol == "file":
+ return fsspec.filesystem("file", auto_mkdir=True)
+
+ elif self.protocol == "s3":
+ # Build S3 configuration
+ endpoint = self.spec["endpoint"]
+ # Determine if endpoint includes protocol
+ if not endpoint.startswith(("http://", "https://")):
+ secure = self.spec.get("secure", False)
+ endpoint_url = f"{'https' if secure else 'http'}://{endpoint}"
+ else:
+ endpoint_url = endpoint
+
+ return fsspec.filesystem(
+ "s3",
+ key=self.spec["access_key"],
+ secret=self.spec["secret_key"],
+ client_kwargs={"endpoint_url": endpoint_url},
+ )
+
+ elif self.protocol == "gcs":
+ return fsspec.filesystem(
+ "gcs",
+ token=self.spec.get("token"),
+ project=self.spec.get("project"),
+ )
+
+ elif self.protocol == "azure":
+ return fsspec.filesystem(
+ "abfs",
+ account_name=self.spec.get("account_name"),
+ account_key=self.spec.get("account_key"),
+ connection_string=self.spec.get("connection_string"),
+ )
+
+ else:
+ return self._require_adapter().create_filesystem(self.spec)
+
+ def _full_path(self, path: str | PurePosixPath) -> str:
+ """
+ Construct full path including location/bucket prefix.
+
+ Parameters
+ ----------
+ path : str or PurePosixPath
+ Relative path within the storage location.
+
+ Returns
+ -------
+ str
+ Full path suitable for fsspec operations.
+ """
+ path = str(path)
+ if self.protocol == "s3":
+ bucket = self.spec["bucket"]
+ location = self.spec.get("location", "")
+ if location:
+ return f"{bucket}/{location}/{path}"
+ return f"{bucket}/{path}"
+ elif self.protocol in ("gcs", "azure"):
+ bucket = self.spec.get("bucket") or self.spec.get("container")
+ location = self.spec.get("location", "")
+ if location:
+ return f"{bucket}/{location}/{path}"
+ return f"{bucket}/{path}"
+ elif self.protocol == "file":
+ location = self.spec.get("location", "")
+ if location:
+ return str(Path(location) / path)
+ return path
+ else:
+ return self._require_adapter().full_path(self.spec, path)
+
+ def get_url(self, path: str | PurePosixPath) -> str:
+ """
+ Get the full URL for a path in storage.
+
+ Returns a consistent URL representation for any storage backend,
+ including file:// URLs for local filesystem.
+
+ Parameters
+ ----------
+ path : str or PurePosixPath
+ Relative path within the storage location.
+
+ Returns
+ -------
+ str
+ Full URL (e.g., 's3://bucket/path' or 'file:///data/path').
+
+ Examples
+ --------
+ >>> backend = StorageBackend({"protocol": "file", "location": "/data"})
+ >>> backend.get_url("schema/table/file.dat")
+ 'file:///data/schema/table/file.dat'
+
+ >>> backend = StorageBackend({"protocol": "s3", "bucket": "mybucket", ...})
+ >>> backend.get_url("schema/table/file.dat")
+ 's3://mybucket/schema/table/file.dat'
+ """
+ full_path = self._full_path(path)
+
+ if self.protocol == "file":
+ # Ensure absolute path for file:// URL
+ abs_path = str(Path(full_path).resolve())
+ if abs_path.startswith("/"):
+ return f"file://{abs_path}"
+ else:
+ # Windows path
+ return f"file:///{abs_path.replace(chr(92), '/')}"
+ elif self.protocol == "s3":
+ return f"s3://{full_path}"
+ elif self.protocol == "gcs":
+ return f"gs://{full_path}"
+ elif self.protocol == "azure":
+ return f"az://{full_path}"
+ else:
+ return self._require_adapter().get_url(self.spec, full_path)
+
+ def put_file(self, local_path: str | Path, remote_path: str | PurePosixPath, metadata: dict | None = None) -> None:
+ """
+ Upload a file from local filesystem to storage.
+
+ Parameters
+ ----------
+ local_path : str or Path
+ Path to local file.
+ remote_path : str or PurePosixPath
+ Destination path in storage.
+ metadata : dict, optional
+ Metadata to attach to the file (cloud storage only).
+ """
+ full_path = self._full_path(remote_path)
+ logger.debug(f"put_file: {local_path} -> {self.protocol}:{full_path}")
+
+ if self.protocol == "file":
+ # For local filesystem, use safe copy with atomic rename
+ from .utils import safe_copy
+
+ Path(full_path).parent.mkdir(parents=True, exist_ok=True)
+ safe_copy(local_path, full_path, overwrite=True)
+ else:
+ # For cloud storage, use fsspec put
+ self.fs.put_file(str(local_path), full_path)
+
+ def get_file(self, remote_path: str | PurePosixPath, local_path: str | Path) -> None:
+ """
+ Download a file from storage to local filesystem.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+ local_path : str or Path
+ Destination path on local filesystem.
+ """
+ full_path = self._full_path(remote_path)
+ logger.debug(f"get_file: {self.protocol}:{full_path} -> {local_path}")
+
+ local_path = Path(local_path)
+ local_path.parent.mkdir(parents=True, exist_ok=True)
+
+ if self.protocol == "file":
+ from .utils import safe_copy
+
+ safe_copy(full_path, local_path)
+ else:
+ self.fs.get_file(full_path, str(local_path))
+
+ def put_buffer(self, buffer: bytes, remote_path: str | PurePosixPath) -> None:
+ """
+ Write bytes to storage.
+
+ Parameters
+ ----------
+ buffer : bytes
+ Bytes to write.
+ remote_path : str or PurePosixPath
+ Destination path in storage.
+ """
+ full_path = self._full_path(remote_path)
+ logger.debug(f"put_buffer: {len(buffer)} bytes -> {self.protocol}:{full_path}")
+
+ if self.protocol == "file":
+ from .utils import safe_write
+
+ Path(full_path).parent.mkdir(parents=True, exist_ok=True)
+ safe_write(full_path, buffer)
+ else:
+ self.fs.pipe_file(full_path, buffer)
+
+ def get_buffer(self, remote_path: str | PurePosixPath) -> bytes:
+ """
+ Read bytes from storage.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+
+ Returns
+ -------
+ bytes
+ File contents.
+
+ Raises
+ ------
+ MissingExternalFile
+ If the file does not exist.
+ """
+ full_path = self._full_path(remote_path)
+ logger.debug(f"get_buffer: {self.protocol}:{full_path}")
+
+ try:
+ if self.protocol == "file":
+ return Path(full_path).read_bytes()
+ else:
+ return self.fs.cat_file(full_path)
+ except FileNotFoundError:
+ raise errors.MissingExternalFile(f"Missing external file {full_path}") from None
+
+ def exists(self, remote_path: str | PurePosixPath) -> bool:
+ """
+ Check if a path (file or directory) exists in storage.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+
+ Returns
+ -------
+ bool
+ True if the path exists.
+ """
+ full_path = self._full_path(remote_path)
+ logger.debug(f"exists: {self.protocol}:{full_path}")
+ return self.fs.exists(full_path)
+
+ def isdir(self, remote_path: str | PurePosixPath) -> bool:
+ """
+ Check if a path refers to a directory in storage.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+
+ Returns
+ -------
+ bool
+ True if the path is a directory.
+ """
+ full_path = self._full_path(remote_path)
+ return self.fs.isdir(full_path)
+
+ def remove(self, remote_path: str | PurePosixPath) -> None:
+ """
+ Remove a file from storage.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+ """
+ full_path = self._full_path(remote_path)
+ logger.debug(f"remove: {self.protocol}:{full_path}")
+
+ try:
+ if self.protocol == "file":
+ Path(full_path).unlink(missing_ok=True)
+ else:
+ self.fs.rm(full_path)
+ except FileNotFoundError:
+ pass # Already gone
+
+ def size(self, remote_path: str | PurePosixPath) -> int:
+ """
+ Get file size in bytes.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+
+ Returns
+ -------
+ int
+ File size in bytes.
+ """
+ full_path = self._full_path(remote_path)
+
+ if self.protocol == "file":
+ return Path(full_path).stat().st_size
+ else:
+ return self.fs.size(full_path)
+
+ def open(self, remote_path: str | PurePosixPath, mode: str = "rb"):
+ """
+ Open a file in storage.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+ mode : str, optional
+ File mode (``'rb'``, ``'wb'``, etc.). Default ``'rb'``.
+
+ Returns
+ -------
+ file-like
+ File-like object for reading or writing.
+ """
+ full_path = self._full_path(remote_path)
+
+ # For write modes on local filesystem, ensure parent directory exists
+ if self.protocol == "file" and "w" in mode:
+ Path(full_path).parent.mkdir(parents=True, exist_ok=True)
+
+ return self.fs.open(full_path, mode)
+
+ def put_folder(self, local_path: str | Path, remote_path: str | PurePosixPath) -> dict:
+ """
+ Upload a folder to storage.
+
+ Parameters
+ ----------
+ local_path : str or Path
+ Path to local folder.
+ remote_path : str or PurePosixPath
+ Destination path in storage.
+
+ Returns
+ -------
+ dict
+ Manifest with keys ``'files'``, ``'total_size'``, ``'item_count'``,
+ ``'created'``.
+ """
+ local_path = Path(local_path)
+ if not local_path.is_dir():
+ raise errors.DataJointError(f"Not a directory: {local_path}")
+
+ full_path = self._full_path(remote_path)
+ logger.debug(f"put_folder: {local_path} -> {self.protocol}:{full_path}")
+
+ # Collect file info for manifest
+ files = []
+ total_size = 0
+
+ # Use os.walk for Python 3.10 compatibility (Path.walk() requires 3.12+)
+ import os
+
+ for root, dirs, filenames in os.walk(local_path):
+ root_path = Path(root)
+ for filename in filenames:
+ file_path = root_path / filename
+ rel_path = file_path.relative_to(local_path).as_posix()
+ file_size = file_path.stat().st_size
+ files.append({"path": rel_path, "size": file_size})
+ total_size += file_size
+
+ # Upload folder contents
+ if self.protocol == "file":
+ import shutil
+
+ dest = Path(full_path)
+ dest.mkdir(parents=True, exist_ok=True)
+ for item in local_path.iterdir():
+ if item.is_file():
+ shutil.copy2(item, dest / item.name)
+ else:
+ shutil.copytree(item, dest / item.name, dirs_exist_ok=True)
+ else:
+ self.fs.put(str(local_path), full_path, recursive=True)
+
+ # Build manifest
+ manifest = {
+ "files": files,
+ "total_size": total_size,
+ "item_count": len(files),
+ "created": datetime.now(timezone.utc).isoformat(),
+ }
+
+ # Write manifest alongside folder
+ manifest_path = f"{remote_path}.manifest.json"
+ self.put_buffer(json.dumps(manifest, indent=2).encode(), manifest_path)
+
+ return manifest
+
+ def remove_folder(self, remote_path: str | PurePosixPath) -> None:
+ """
+ Remove a folder and its manifest from storage.
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path to folder in storage.
+ """
+ full_path = self._full_path(remote_path)
+ logger.debug(f"remove_folder: {self.protocol}:{full_path}")
+
+ try:
+ if self.protocol == "file":
+ import shutil
+
+ shutil.rmtree(full_path, ignore_errors=True)
+ else:
+ self.fs.rm(full_path, recursive=True)
+ except FileNotFoundError:
+ pass
+
+ # Also remove manifest
+ manifest_path = f"{remote_path}.manifest.json"
+ self.remove(manifest_path)
+
+ def get_fsmap(self, remote_path: str | PurePosixPath) -> fsspec.FSMap:
+ """
+ Get an FSMap for a path (useful for Zarr/xarray).
+
+ Parameters
+ ----------
+ remote_path : str or PurePosixPath
+ Path in storage.
+
+ Returns
+ -------
+ fsspec.FSMap
+ Mapping interface for the storage path.
+ """
+ full_path = self._full_path(remote_path)
+ return fsspec.FSMap(full_path, self.fs)
+
+ def copy_from_url(self, source_url: str, dest_path: str | PurePosixPath) -> int:
+ """
+ Copy a file from a remote URL to managed storage.
+
+ Parameters
+ ----------
+ source_url : str
+ Remote URL (``s3://``, ``gs://``, ``http://``, etc.).
+ dest_path : str or PurePosixPath
+ Destination path in managed storage.
+
+ Returns
+ -------
+ int
+ Size of copied file in bytes.
+ """
+ protocol, source_path = parse_url(source_url)
+ full_dest = self._full_path(dest_path)
+
+ logger.debug(f"copy_from_url: {protocol}://{source_path} -> {self.protocol}:{full_dest}")
+
+ # Get source filesystem
+ source_fs = fsspec.filesystem(protocol)
+
+ # Check if source is a directory
+ if source_fs.isdir(source_path):
+ return self._copy_folder_from_url(source_fs, source_path, dest_path)
+
+ # Copy single file
+ if self.protocol == "file":
+ # Download to local destination
+ Path(full_dest).parent.mkdir(parents=True, exist_ok=True)
+ source_fs.get_file(source_path, full_dest)
+ return Path(full_dest).stat().st_size
+ else:
+ # Remote-to-remote copy via streaming
+ with source_fs.open(source_path, "rb") as src:
+ content = src.read()
+ self.fs.pipe_file(full_dest, content)
+ return len(content)
+
+ def _copy_folder_from_url(
+ self, source_fs: fsspec.AbstractFileSystem, source_path: str, dest_path: str | PurePosixPath
+ ) -> dict:
+ """
+ Copy a folder from a remote URL to managed storage.
+
+ Parameters
+ ----------
+ source_fs : fsspec.AbstractFileSystem
+ Source filesystem.
+ source_path : str
+ Path in source filesystem.
+ dest_path : str or PurePosixPath
+ Destination path in managed storage.
+
+ Returns
+ -------
+ dict
+ Manifest with keys ``'files'``, ``'total_size'``, ``'item_count'``,
+ ``'created'``.
+ """
+ full_dest = self._full_path(dest_path)
+ logger.debug(f"copy_folder_from_url: {source_path} -> {self.protocol}:{full_dest}")
+
+ # Collect file info for manifest
+ files = []
+ total_size = 0
+
+ # Walk source directory
+ for root, dirs, filenames in source_fs.walk(source_path):
+ for filename in filenames:
+ src_file = f"{root}/{filename}" if root != source_path else f"{source_path}/{filename}"
+ rel_path = src_file[len(source_path) :].lstrip("/")
+ file_size = source_fs.size(src_file)
+ files.append({"path": rel_path, "size": file_size})
+ total_size += file_size
+
+ # Copy file
+ dest_file = f"{full_dest}/{rel_path}"
+ if self.protocol == "file":
+ Path(dest_file).parent.mkdir(parents=True, exist_ok=True)
+ source_fs.get_file(src_file, dest_file)
+ else:
+ with source_fs.open(src_file, "rb") as src:
+ content = src.read()
+ self.fs.pipe_file(dest_file, content)
+
+ # Build manifest
+ manifest = {
+ "files": files,
+ "total_size": total_size,
+ "item_count": len(files),
+ "created": datetime.now(timezone.utc).isoformat(),
+ }
+
+ # Write manifest alongside folder
+ manifest_path = f"{dest_path}.manifest.json"
+ self.put_buffer(json.dumps(manifest, indent=2).encode(), manifest_path)
+
+ return manifest
+
+ def source_is_directory(self, source: str) -> bool:
+ """
+ Check if a source path (local or remote URL) is a directory.
+
+ Parameters
+ ----------
+ source : str
+ Local path or remote URL.
+
+ Returns
+ -------
+ bool
+ True if source is a directory.
+ """
+ if is_url(source):
+ protocol, path = parse_url(source)
+ source_fs = fsspec.filesystem(protocol)
+ return source_fs.isdir(path)
+ else:
+ return Path(source).is_dir()
+
+ def source_exists(self, source: str) -> bool:
+ """
+ Check if a source path (local or remote URL) exists.
+
+ Parameters
+ ----------
+ source : str
+ Local path or remote URL.
+
+ Returns
+ -------
+ bool
+ True if source exists.
+ """
+ if is_url(source):
+ protocol, path = parse_url(source)
+ source_fs = fsspec.filesystem(protocol)
+ return source_fs.exists(path)
+ else:
+ return Path(source).exists()
+
+ def get_source_size(self, source: str) -> int | None:
+ """
+ Get the size of a source file (local or remote URL).
+
+ Parameters
+ ----------
+ source : str
+ Local path or remote URL.
+
+ Returns
+ -------
+ int or None
+ Size in bytes, or None if directory or cannot determine.
+ """
+ try:
+ if is_url(source):
+ protocol, path = parse_url(source)
+ source_fs = fsspec.filesystem(protocol)
+ if source_fs.isdir(path):
+ return None
+ return source_fs.size(path)
+ else:
+ p = Path(source)
+ if p.is_dir():
+ return None
+ return p.stat().st_size
+ except Exception:
+ return None
+
+
+STORE_METADATA_FILENAME = "datajoint_store.json"
+
+
+def get_storage_backend(spec: dict[str, Any]) -> StorageBackend:
+ """
+ Factory function to create a storage backend from configuration.
+
+ Parameters
+ ----------
+ spec : dict[str, Any]
+ Storage configuration dictionary.
+
+ Returns
+ -------
+ StorageBackend
+ Configured storage backend instance.
+ """
+ return StorageBackend(spec)
+
+
+def verify_or_create_store_metadata(backend: StorageBackend, spec: dict[str, Any]) -> dict:
+ """
+ Verify or create the store metadata file at the storage root.
+
+ On first use, creates the ``datajoint_store.json`` file with project info.
+ On subsequent uses, verifies the ``project_name`` matches.
+
+ Parameters
+ ----------
+ backend : StorageBackend
+ Storage backend instance.
+ spec : dict[str, Any]
+ Object storage configuration spec.
+
+ Returns
+ -------
+ dict
+ Store metadata dictionary.
+
+ Raises
+ ------
+ DataJointError
+ If ``project_name`` mismatch detected.
+ """
+ from .version import __version__ as dj_version
+
+ project_name = spec.get("project_name")
+ location = spec.get("location", "")
+
+ # Metadata file path at storage root
+ metadata_path = f"{location}/{STORE_METADATA_FILENAME}" if location else STORE_METADATA_FILENAME
+
+ try:
+ # Try to read existing metadata
+ if backend.exists(metadata_path):
+ metadata_content = backend.get_buffer(metadata_path)
+ metadata = json.loads(metadata_content)
+
+ # Verify project_name matches
+ store_project = metadata.get("project_name")
+ if store_project and store_project != project_name:
+ raise errors.DataJointError(
+ f"Object store project name mismatch.\n"
+ f' Client configured: "{project_name}"\n'
+ f' Store metadata: "{store_project}"\n'
+ f"Ensure all clients use the same object_storage.project_name setting."
+ )
+
+ return metadata
+ else:
+ # Create new metadata
+ metadata = {
+ "project_name": project_name,
+ "created": datetime.now(timezone.utc).isoformat(),
+ "format_version": "1.0",
+ "datajoint_version": dj_version,
+ }
+
+ # Optional database info - not enforced, just informational
+ # These would need to be passed in from the connection context
+ # For now, omit them
+
+ backend.put_buffer(json.dumps(metadata, indent=2).encode(), metadata_path)
+ return metadata
+
+ except errors.DataJointError:
+ raise
+ except Exception as e:
+ # Log warning but don't fail - metadata is informational
+ logger.warning(f"Could not verify/create store metadata: {e}")
+ return {"project_name": project_name}
diff --git a/src/datajoint/storage_adapter.py b/src/datajoint/storage_adapter.py
new file mode 100644
index 000000000..0cb93031b
--- /dev/null
+++ b/src/datajoint/storage_adapter.py
@@ -0,0 +1,102 @@
+"""Plugin system for third-party storage protocols.
+
+Third-party packages register adapters via entry points::
+
+ [project.entry-points."datajoint.storage"]
+ myprotocol = "my_package:MyStorageAdapter"
+
+The adapter is auto-discovered when DataJoint encounters the protocol name
+in a store configuration. No explicit import is needed.
+"""
+
+from abc import ABC, abstractmethod
+from typing import Any
+import logging
+
+import fsspec
+
+from . import errors
+
+logger = logging.getLogger(__name__)
+
+
+class StorageAdapter(ABC):
+ """Base class for storage protocol adapters.
+
+ Subclass this and declare an entry point to add a new storage protocol
+ to DataJoint. At minimum, implement ``create_filesystem`` and set
+ ``protocol``, ``required_keys``, and ``allowed_keys``.
+ """
+
+ protocol: str
+ required_keys: tuple[str, ...] = ()
+ allowed_keys: tuple[str, ...] = ()
+
+ @abstractmethod
+ def create_filesystem(self, spec: dict[str, Any]) -> fsspec.AbstractFileSystem:
+ """Return an fsspec filesystem instance for this protocol."""
+ ...
+
+ def validate_spec(self, spec: dict[str, Any]) -> None:
+ """Validate protocol-specific config fields."""
+ missing = [k for k in self.required_keys if k not in spec]
+ if missing:
+ raise errors.DataJointError(f'{self.protocol} store is missing: {", ".join(missing)}')
+ all_allowed = set(self.allowed_keys) | _COMMON_STORE_KEYS
+ invalid = [k for k in spec if k not in all_allowed]
+ if invalid:
+ raise errors.DataJointError(f'Invalid key(s) for {self.protocol}: {", ".join(invalid)}')
+
+ def full_path(self, spec: dict[str, Any], relpath: str) -> str:
+ """Construct storage path from a relative path."""
+ location = spec.get("location", "")
+ return f"{location}/{relpath}" if location else relpath
+
+ def get_url(self, spec: dict[str, Any], path: str) -> str:
+ """Return a display URL for the stored object."""
+ return f"{self.protocol}://{path}"
+
+
+_COMMON_STORE_KEYS = frozenset(
+ {
+ "protocol",
+ "location",
+ "subfolding",
+ "partition_pattern",
+ "token_length",
+ "hash_prefix",
+ "schema_prefix",
+ "filepath_prefix",
+ "stage",
+ }
+)
+
+_adapter_registry: dict[str, StorageAdapter] = {}
+_adapters_loaded: bool = False
+
+
+def get_storage_adapter(protocol: str) -> StorageAdapter | None:
+ """Look up a registered storage adapter by protocol name."""
+ global _adapters_loaded
+ if not _adapters_loaded:
+ _discover_adapters()
+ _adapters_loaded = True
+ return _adapter_registry.get(protocol)
+
+
+def _discover_adapters() -> None:
+ """Load storage adapters from datajoint.storage entry points."""
+ from importlib.metadata import entry_points
+
+ eps = entry_points(group="datajoint.storage")
+
+ for ep in eps:
+ if ep.name in _adapter_registry:
+ continue
+ try:
+ adapter_cls = ep.load()
+ adapter = adapter_cls()
+ _adapter_registry[adapter.protocol] = adapter
+ logger.debug(f"Loaded storage adapter: {adapter.protocol}")
+ except Exception as e:
+ logger.warning(f"Failed to load storage adapter '{ep.name}': {e}")
diff --git a/src/datajoint/table.py b/src/datajoint/table.py
new file mode 100644
index 000000000..4f44ffbf6
--- /dev/null
+++ b/src/datajoint/table.py
@@ -0,0 +1,1637 @@
+import collections
+import csv
+import inspect
+import itertools
+import json
+import logging
+import uuid
+import warnings
+from dataclasses import dataclass, field
+from pathlib import Path
+
+import numpy as np
+import pandas
+
+from .condition import make_condition
+from .declare import alter, declare
+from .dependencies import extract_master
+from .errors import (
+ AccessError,
+ DataJointError,
+ DuplicateError,
+ IntegrityError,
+ UnknownAttributeError,
+)
+from .expression import QueryExpression
+from .heading import Heading
+from .staged_insert import staged_insert1 as _staged_insert1
+from .utils import is_camel_case, user_choice
+
+logger = logging.getLogger(__name__.split(".")[0])
+
+# Note: Foreign key error parsing is now handled by adapter methods
+# Legacy regexp and query kept for reference but no longer used
+
+
+@dataclass
+class ValidationResult:
+ """
+ Result of table.validate() call.
+
+ Attributes:
+ is_valid: True if all rows passed validation
+ errors: List of (row_index, field_name, error_message) tuples
+ rows_checked: Number of rows that were validated
+ """
+
+ is_valid: bool
+ errors: list = field(default_factory=list) # list of (row_index, field_name | None, message)
+ rows_checked: int = 0
+
+ def __bool__(self) -> bool:
+ """Allow using ValidationResult in boolean context."""
+ return self.is_valid
+
+ def raise_if_invalid(self):
+ """Raise DataJointError if validation failed."""
+ if not self.is_valid:
+ raise DataJointError(self.summary())
+
+ def summary(self) -> str:
+ """Return formatted error summary."""
+ if self.is_valid:
+ return f"Validation passed: {self.rows_checked} rows checked"
+ lines = [f"Validation failed: {len(self.errors)} error(s) in {self.rows_checked} rows"]
+ for row_idx, field_name, message in self.errors[:10]: # Show first 10 errors
+ field_str = f" in field '{field_name}'" if field_name else ""
+ lines.append(f" Row {row_idx}{field_str}: {message}")
+ if len(self.errors) > 10:
+ lines.append(f" ... and {len(self.errors) - 10} more errors")
+ return "\n".join(lines)
+
+
+class Table(QueryExpression):
+ """
+ Table is an abstract class that represents a table in the schema.
+ It implements insert and delete methods and inherits query functionality.
+ To make it a concrete class, override the abstract properties specifying the connection,
+ table name, database, and definition.
+ """
+
+ _table_name = None # must be defined in subclass
+
+ # These properties must be set by the schema decorator (schemas.py) at class level
+ # or by FreeTable at instance level
+ database = None
+ declaration_context = None
+
+ @property
+ def table_name(self):
+ # For UserTable subclasses, table_name is computed by the metaclass.
+ # Delegate to the class's table_name if _table_name is not set.
+ if self._table_name is None:
+ return type(self).table_name
+ return self._table_name
+
+ @property
+ def class_name(self):
+ return self.__class__.__name__
+
+ # Base tier class names that should not raise errors when heading is None
+ _base_tier_classes = frozenset({"Table", "UserTable", "Lookup", "Manual", "Imported", "Computed", "Part"})
+
+ @property
+ def heading(self):
+ """
+ Return the table's heading, or raise a helpful error if not configured.
+
+ Overrides QueryExpression.heading to provide a clear error message
+ when the table is not properly associated with an activated schema.
+ For base tier classes (Lookup, Manual, etc.), returns None to support
+ introspection (e.g., help()).
+ """
+ if self._heading is None:
+ # Don't raise error for base tier classes - they're used for introspection
+ if self.__class__.__name__ in self._base_tier_classes:
+ return None
+ raise DataJointError(
+ f"Table `{self.__class__.__name__}` is not properly configured. "
+ "Ensure the schema is activated before using the table. "
+ "Example: schema.activate('database_name') or schema = dj.Schema('database_name')"
+ )
+ return self._heading
+
+ @property
+ def definition(self):
+ raise NotImplementedError("Subclasses of Table must implement the `definition` property")
+
+ def declare(self, context=None):
+ """
+ Declare the table in the schema based on self.definition.
+
+ Parameters
+ ----------
+ context : dict, optional
+ The context for foreign key resolution. If None, foreign keys are
+ not allowed.
+ """
+ if self.connection.in_transaction:
+ raise DataJointError("Cannot declare new tables inside a transaction, e.g. from inside a populate/make call")
+ # Validate class name #1150
+ class_name = self.class_name
+ if "_" in class_name:
+ warnings.warn(
+ f"Table class name `{class_name}` contains underscores. CamelCase names without underscores are recommended.",
+ UserWarning,
+ stacklevel=2,
+ )
+ class_name = class_name.replace("_", "")
+ if not is_camel_case(class_name):
+ raise DataJointError(
+ f"Table class name `{self.class_name}` is invalid. "
+ "Class names must be in CamelCase, starting with a capital letter."
+ )
+ sql, _external_stores, primary_key, fk_attribute_map, pre_ddl, post_ddl = declare(
+ self.full_table_name, self.definition, context, self.connection.adapter, config=self.connection._config
+ )
+
+ # Call declaration hook for validation (subclasses like AutoPopulate can override)
+ self._declare_check(primary_key, fk_attribute_map)
+
+ sql = sql.format(database=self.database)
+ try:
+ # Execute pre-DDL statements (e.g., CREATE TYPE for PostgreSQL enums)
+ for ddl in pre_ddl:
+ try:
+ self.connection.query(ddl.format(database=self.database))
+ except Exception:
+ # Ignore errors (type may already exist)
+ pass
+ self.connection.query(sql)
+ # Execute post-DDL statements (e.g., COMMENT ON for PostgreSQL)
+ for ddl in post_ddl:
+ self.connection.query(ddl.format(database=self.database))
+ except AccessError:
+ # Only suppress if table already exists (idempotent declaration)
+ # Otherwise raise - user needs to know about permission issues
+ if self.is_declared:
+ return
+ raise AccessError(
+ f"Cannot declare table {self.full_table_name}. "
+ f"Check that you have CREATE privilege on schema `{self.database}` "
+ f"and REFERENCES privilege on any referenced parent tables."
+ ) from None
+
+ # Populate lineage table for this table's attributes
+ self._populate_lineage(primary_key, fk_attribute_map)
+
+ def _declare_check(self, primary_key, fk_attribute_map):
+ """
+ Hook for declaration-time validation. Subclasses can override.
+
+ Called before the table is created in the database. Override this method
+ to add validation logic (e.g., AutoPopulate validates FK-only primary keys).
+
+ Parameters
+ ----------
+ primary_key : list
+ List of primary key attribute names.
+ fk_attribute_map : dict
+ Dict mapping child_attr -> (parent_table, parent_attr).
+ """
+ pass # Default: no validation
+
+ def _populate_lineage(self, primary_key, fk_attribute_map):
+ """
+ Populate the ~lineage table with lineage information for this table's attributes.
+
+ Lineage is stored for:
+ - All FK attributes (traced to their origin)
+ - Native primary key attributes (lineage = self)
+
+ Parameters
+ ----------
+ primary_key : list
+ List of primary key attribute names.
+ fk_attribute_map : dict
+ Dict mapping child_attr -> (parent_table, parent_attr).
+ """
+ from .lineage import (
+ ensure_lineage_table,
+ get_lineage,
+ delete_table_lineages,
+ insert_lineages,
+ )
+
+ # Ensure the ~lineage table exists
+ ensure_lineage_table(self.connection, self.database)
+
+ # Delete any existing lineage entries for this table (for idempotent re-declaration)
+ delete_table_lineages(self.connection, self.database, self.table_name)
+
+ entries = []
+
+ # FK attributes: copy lineage from parent (whether in PK or not)
+ for attr, (parent_table, parent_attr) in fk_attribute_map.items():
+ # Parse parent table name: `schema`.`table` or "schema"."table" -> (schema, table)
+ parent_db, parent_tbl = self.connection.adapter.split_full_table_name(parent_table)
+
+ # Get parent's lineage for this attribute
+ parent_lineage = get_lineage(self.connection, parent_db, parent_tbl, parent_attr)
+ if parent_lineage:
+ # Copy parent's lineage
+ entries.append((self.table_name, attr, parent_lineage))
+ else:
+ # Parent doesn't have lineage entry - use parent as origin
+ # This can happen for legacy/external schemas without lineage tracking
+ lineage = f"{parent_db}.{parent_tbl}.{parent_attr}"
+ entries.append((self.table_name, attr, lineage))
+ logger.warning(
+ f"Lineage for `{parent_db}`.`{parent_tbl}`.`{parent_attr}` not found "
+ f"(parent schema's ~lineage table may be missing or incomplete). "
+ f"Using it as origin. Once the parent schema's lineage is rebuilt, "
+ f"run schema.rebuild_lineage() on this schema to correct the lineage."
+ )
+
+ # Native PK attributes (in PK but not FK): this table is the origin
+ for attr in primary_key:
+ if attr not in fk_attribute_map:
+ lineage = f"{self.database}.{self.table_name}.{attr}"
+ entries.append((self.table_name, attr, lineage))
+
+ if entries:
+ insert_lineages(self.connection, self.database, entries)
+
+ def _refresh_lineage(self, context=None):
+ """
+ Re-derive ``~lineage`` rows from the current definition and overwrite them.
+
+ Called by ``@schema`` decoration on every pass — including when the table
+ is already declared — so that stale rows from earlier DataJoint versions
+ or partial declares do not survive a redeclare. The actual deletion +
+ re-insertion happens in ``_populate_lineage``; this method just parses
+ the definition to obtain ``primary_key`` and ``fk_attribute_map`` without
+ executing any DDL.
+
+ Errors during refresh (e.g. missing write permission on ``~lineage``) are
+ logged and swallowed; a stale row is preferable to a failed import.
+ """
+ try:
+ (
+ _,
+ _,
+ primary_key,
+ fk_attribute_map,
+ _,
+ _,
+ ) = declare(
+ self.full_table_name,
+ self.definition,
+ context,
+ self.connection.adapter,
+ config=self.connection._config,
+ )
+ self._populate_lineage(primary_key, fk_attribute_map)
+ except Exception as exc: # noqa: BLE001 — defensive; see docstring
+ logger.warning(
+ f"Could not refresh lineage for {self.full_table_name}: {exc}. "
+ "If you encounter `different lineages` errors, run "
+ "`schema.rebuild_lineage()` to rebuild from current FK definitions."
+ )
+
+ def alter(self, prompt=True, context=None):
+ """
+ Alter the table definition from self.definition
+ """
+ if self.connection.in_transaction:
+ raise DataJointError("Cannot update table declaration inside a transaction, e.g. from inside a populate/make call")
+ if context is None:
+ frame = inspect.currentframe().f_back
+ context = dict(frame.f_globals, **frame.f_locals)
+ del frame
+ old_definition = self.describe(context=context)
+ sql, _external_stores = alter(self.definition, old_definition, context, self.connection.adapter)
+ if not sql:
+ if prompt:
+ logger.warning("Nothing to alter.")
+ else:
+ sql = "ALTER TABLE {tab}\n\t".format(tab=self.full_table_name) + ",\n\t".join(sql)
+ if not prompt or user_choice(sql + "\n\nExecute?") == "yes":
+ try:
+ self.connection.query(sql)
+ except AccessError:
+ # skip if no create privilege
+ pass
+ else:
+ # reset heading
+ self.__class__._heading = Heading(table_info=self.heading.table_info)
+ if prompt:
+ logger.info("Table altered")
+
+ def from_clause(self):
+ """
+ Return the FROM clause of SQL SELECT statements.
+
+ Returns
+ -------
+ str
+ The full table name for use in SQL FROM clauses.
+ """
+ return self.full_table_name
+
+ def get_select_fields(self, select_fields=None):
+ """
+ Return the selected attributes from the SQL SELECT statement.
+
+ Parameters
+ ----------
+ select_fields : list, optional
+ List of attribute names to select. If None, selects all attributes.
+
+ Returns
+ -------
+ str
+ The SQL field selection string.
+ """
+ return "*" if select_fields is None else self.heading.project(select_fields).as_sql
+
+ def parents(self, primary=None, as_objects=False, foreign_key_info=False):
+ """
+ Return the list of parent tables.
+
+ Parameters
+ ----------
+ primary : bool, optional
+ If None, then all parents are returned. If True, then only foreign keys
+ composed of primary key attributes are considered. If False, return
+ foreign keys including at least one secondary attribute.
+ as_objects : bool, optional
+ If False, return table names. If True, return table objects.
+ foreign_key_info : bool, optional
+ If True, each element in result also includes foreign key info.
+
+ Returns
+ -------
+ list
+ List of parents as table names or table objects with (optional) foreign
+ key information.
+ """
+ get_edge = self.connection.dependencies.parents
+ nodes = [
+ next(iter(get_edge(name).items())) if name.isdigit() else (name, props)
+ for name, props in get_edge(self.full_table_name, primary).items()
+ ]
+ if as_objects:
+ nodes = [(FreeTable(self.connection, name), props) for name, props in nodes]
+ if not foreign_key_info:
+ nodes = [name for name, props in nodes]
+ return nodes
+
+ def children(self, primary=None, as_objects=False, foreign_key_info=False):
+ """
+ Return the list of child tables.
+
+ Parameters
+ ----------
+ primary : bool, optional
+ If None, then all children are returned. If True, then only foreign keys
+ composed of primary key attributes are considered. If False, return
+ foreign keys including at least one secondary attribute.
+ as_objects : bool, optional
+ If False, return table names. If True, return table objects.
+ foreign_key_info : bool, optional
+ If True, each element in result also includes foreign key info.
+
+ Returns
+ -------
+ list
+ List of children as table names or table objects with (optional) foreign
+ key information.
+ """
+ get_edge = self.connection.dependencies.children
+ nodes = [
+ next(iter(get_edge(name).items())) if name.isdigit() else (name, props)
+ for name, props in get_edge(self.full_table_name, primary).items()
+ ]
+ if as_objects:
+ nodes = [(FreeTable(self.connection, name), props) for name, props in nodes]
+ if not foreign_key_info:
+ nodes = [name for name, props in nodes]
+ return nodes
+
+ def descendants(self, as_objects=False):
+ """
+ Return list of descendant tables in topological order.
+
+ Parameters
+ ----------
+ as_objects : bool, optional
+ If False (default), return a list of table names. If True, return a
+ list of table objects.
+
+ Returns
+ -------
+ list
+ List of descendant tables in topological order.
+ """
+ return [
+ FreeTable(self.connection, node) if as_objects else node
+ for node in self.connection.dependencies.descendants(self.full_table_name)
+ if not node.isdigit()
+ ]
+
+ def ancestors(self, as_objects=False):
+ """
+ Return list of ancestor tables in topological order.
+
+ Parameters
+ ----------
+ as_objects : bool, optional
+ If False (default), return a list of table names. If True, return a
+ list of table objects.
+
+ Returns
+ -------
+ list
+ List of ancestor tables in topological order.
+ """
+ return [
+ FreeTable(self.connection, node) if as_objects else node
+ for node in self.connection.dependencies.ancestors(self.full_table_name)
+ if not node.isdigit()
+ ]
+
+ def parts(self, as_objects=False):
+ """
+ Return part tables for this master table.
+
+ Parameters
+ ----------
+ as_objects : bool, optional
+ If False (default), the output is a list of full table names. If True,
+ return table objects.
+
+ Returns
+ -------
+ list
+ List of part table names or table objects.
+ """
+ self.connection.dependencies.load(force=False)
+ nodes = [
+ node
+ for node in self.connection.dependencies.nodes
+ if not node.isdigit() and node.startswith(self.full_table_name[:-1] + "__")
+ ]
+ return [FreeTable(self.connection, c) for c in nodes] if as_objects else nodes
+
+ @property
+ def is_declared(self):
+ """
+ Check if the table is declared in the schema.
+
+ Returns
+ -------
+ bool
+ True if the table is declared in the schema.
+ """
+ query = self.connection.adapter.get_table_info_sql(self.database, self.table_name)
+ return self.connection.query(query).rowcount > 0
+
+ @property
+ def full_table_name(self):
+ """
+ Return the full table name in the schema.
+
+ Returns
+ -------
+ str
+ Full table name in the format `database`.`table_name`.
+ """
+ if self.database is None or self.table_name is None:
+ raise DataJointError(
+ f"Class {self.__class__.__name__} is not associated with a schema. "
+ "Apply a schema decorator or use schema() to bind it."
+ )
+ return self.adapter.make_full_table_name(self.database, self.table_name)
+
+ @property
+ def adapter(self):
+ """Database adapter for backend-agnostic SQL generation."""
+ return self.connection.adapter
+
+ def update1(self, row):
+ """
+ Update one existing entry in the table.
+
+ Caution: In DataJoint the primary modes for data manipulation is to ``insert`` and
+ ``delete`` entire records since referential integrity works on the level of records,
+ not fields. Therefore, updates are reserved for corrective operations outside of main
+ workflow. Use UPDATE methods sparingly with full awareness of potential violations of
+ assumptions.
+
+ The primary key attributes must always be provided.
+
+ Parameters
+ ----------
+ row : dict
+ A dict containing the primary key values and the attributes to update.
+ Setting an attribute value to None will reset it to the default value (if any).
+
+ Examples
+ --------
+ >>> table.update1({'id': 1, 'value': 3}) # update value in record with id=1
+ >>> table.update1({'id': 1, 'value': None}) # reset value to default
+ """
+ # argument validations
+ if not isinstance(row, collections.abc.Mapping):
+ raise DataJointError("The argument of update1 must be dict-like.")
+ if not set(row).issuperset(self.primary_key):
+ raise DataJointError("The argument of update1 must supply all primary key values.")
+ try:
+ raise DataJointError("Attribute `%s` not found." % next(k for k in row if k not in self.heading.names))
+ except StopIteration:
+ pass # ok
+ if len(self.restriction):
+ raise DataJointError("Update cannot be applied to a restricted table.")
+ key = {k: row[k] for k in self.primary_key}
+ if len(self & key) != 1:
+ raise DataJointError("Update can only be applied to one existing entry.")
+ # UPDATE query
+ row = [self.__make_placeholder(k, v) for k, v in row.items() if k not in self.primary_key]
+ assignments = ",".join(f"{self.adapter.quote_identifier(r[0])}={r[1]}" for r in row)
+ query = "UPDATE {table} SET {assignments} WHERE {where}".format(
+ table=self.full_table_name,
+ assignments=assignments,
+ where=make_condition(self, key, set()),
+ )
+ self.connection.query(query, args=list(r[2] for r in row if r[2] is not None))
+
+ def validate(self, rows, *, ignore_extra_fields=False) -> ValidationResult:
+ """
+ Validate rows without inserting them.
+
+ Validates:
+ - Field existence (all fields must be in table heading)
+ - Row format (correct number of attributes for positional inserts)
+ - Codec validation (type checking via codec.validate())
+ - NULL constraints (non-nullable fields must have values)
+ - Primary key completeness (all PK fields must be present)
+ - UUID format and JSON serializability
+
+ Cannot validate (database-enforced):
+ - Foreign key constraints
+ - Unique constraints (other than PK)
+ - Custom MySQL constraints
+
+ Parameters
+ ----------
+ rows : iterable
+ Same format as insert() - iterable of dicts, tuples, numpy records,
+ or a pandas DataFrame.
+ ignore_extra_fields : bool, optional
+ If True, ignore fields not in the table heading.
+
+ Returns
+ -------
+ ValidationResult
+ Result with is_valid, errors list, and rows_checked count.
+
+ Examples
+ --------
+ >>> result = table.validate(rows)
+ >>> if result:
+ ... table.insert(rows)
+ ... else:
+ ... print(result.summary())
+ """
+ errors = []
+
+ # Convert DataFrame to records
+ if isinstance(rows, pandas.DataFrame):
+ rows = rows.reset_index(drop=len(rows.index.names) == 1 and not rows.index.names[0]).to_records(index=False)
+
+ # Convert Path (CSV) to list of dicts
+ if isinstance(rows, Path):
+ with open(rows, newline="") as data_file:
+ rows = list(csv.DictReader(data_file, delimiter=","))
+
+ rows = list(rows) # Materialize iterator
+ row_count = len(rows)
+
+ for row_idx, row in enumerate(rows):
+ # Validate row format and fields
+ row_dict = None
+ try:
+ if isinstance(row, np.void): # numpy record
+ fields = list(row.dtype.fields.keys())
+ row_dict = {name: row[name] for name in fields}
+ elif isinstance(row, collections.abc.Mapping):
+ fields = list(row.keys())
+ row_dict = dict(row)
+ else: # positional tuple/list
+ if len(row) != len(self.heading):
+ errors.append(
+ (
+ row_idx,
+ None,
+ f"Incorrect number of attributes: {len(row)} given, {len(self.heading)} expected",
+ )
+ )
+ continue
+ fields = list(self.heading.names)
+ row_dict = dict(zip(fields, row))
+ except TypeError:
+ errors.append((row_idx, None, f"Invalid row type: {type(row).__name__}"))
+ continue
+
+ # Check for unknown fields
+ if not ignore_extra_fields:
+ for field_name in fields:
+ if field_name not in self.heading:
+ errors.append((row_idx, field_name, f"Field '{field_name}' not in table heading"))
+
+ # Validate each field value
+ for name in self.heading.names:
+ if name not in row_dict:
+ # Check if field is required (non-nullable, no default, not autoincrement)
+ attr = self.heading[name]
+ if not attr.nullable and attr.default is None and not attr.autoincrement:
+ errors.append((row_idx, name, f"Required field '{name}' is missing"))
+ continue
+
+ value = row_dict[name]
+ attr = self.heading[name]
+
+ # Skip validation for None values on nullable columns
+ if value is None:
+ if not attr.nullable and attr.default is None:
+ errors.append((row_idx, name, f"NULL value not allowed for non-nullable field '{name}'"))
+ continue
+
+ # Codec validation
+ if attr.codec:
+ try:
+ attr.codec.validate(value)
+ except (TypeError, ValueError) as e:
+ errors.append((row_idx, name, f"Codec validation failed: {e}"))
+ continue
+
+ # UUID validation
+ if attr.uuid and not isinstance(value, uuid.UUID):
+ try:
+ uuid.UUID(value)
+ except (AttributeError, ValueError):
+ errors.append((row_idx, name, f"Invalid UUID format: {value}"))
+ continue
+
+ # JSON serialization check
+ if attr.json:
+ try:
+ json.dumps(value)
+ except (TypeError, ValueError) as e:
+ errors.append((row_idx, name, f"Value not JSON serializable: {e}"))
+ continue
+
+ # Numeric NaN check
+ if attr.numeric and value != "" and not isinstance(value, (bool, np.bool_)):
+ try:
+ if np.isnan(float(value)):
+ # NaN is allowed - will be converted to NULL
+ pass
+ except (TypeError, ValueError):
+ # Not a number that can be checked for NaN - let it pass
+ pass
+
+ # Check primary key completeness
+ for pk_field in self.primary_key:
+ if pk_field not in row_dict or row_dict[pk_field] is None:
+ pk_attr = self.heading[pk_field]
+ if not pk_attr.autoincrement:
+ errors.append((row_idx, pk_field, f"Primary key field '{pk_field}' is missing or NULL"))
+
+ return ValidationResult(is_valid=len(errors) == 0, errors=errors, rows_checked=row_count)
+
+ def insert1(self, row, **kwargs):
+ """
+ Insert one data record into the table.
+
+ For ``kwargs``, see ``insert()``.
+
+ Parameters
+ ----------
+ row : numpy.void, dict, or sequence
+ A numpy record, a dict-like object, or an ordered sequence to be inserted
+ as one row.
+ **kwargs
+ Additional arguments passed to ``insert()``.
+
+ See Also
+ --------
+ insert : Insert multiple data records.
+ """
+ self.insert((row,), **kwargs)
+
+ @property
+ def staged_insert1(self):
+ """
+ Context manager for staged insert with direct object storage writes.
+
+ Use this for large objects like Zarr arrays where copying from local storage
+ is inefficient. Allows writing directly to the destination storage before
+ finalizing the database insert.
+
+ Example:
+ with table.staged_insert1 as staged:
+ staged.rec['subject_id'] = 123
+ staged.rec['session_id'] = 45
+
+ # Create object storage directly
+ z = zarr.open(staged.store('raw_data', '.zarr'), mode='w', shape=(1000, 1000))
+ z[:] = data
+
+ # Assign to record
+ staged.rec['raw_data'] = z
+
+ # On successful exit: metadata computed, record inserted
+ # On exception: storage cleaned up, no record inserted
+
+ Yields:
+ StagedInsert: Context for setting record values and getting storage handles
+ """
+ return _staged_insert1(self)
+
+ def insert(
+ self,
+ rows,
+ replace=False,
+ skip_duplicates=False,
+ ignore_extra_fields=False,
+ allow_direct_insert=None,
+ chunk_size=None,
+ ):
+ """
+ Insert a collection of rows.
+
+ Parameters
+ ----------
+ rows : iterable or pathlib.Path
+ Either (a) an iterable where an element is a numpy record, a dict-like
+ object, a pandas.DataFrame, a polars.DataFrame, a pyarrow.Table, a
+ sequence, or a query expression with the same heading as self, or
+ (b) a pathlib.Path object specifying a path relative to the current
+ directory with a CSV file, the contents of which will be inserted.
+ replace : bool, optional
+ If True, replaces the existing tuple.
+ skip_duplicates : bool, optional
+ If True, silently skip rows with duplicate primary key values.
+ On **PostgreSQL**, secondary unique constraint violations still
+ raise an error even when ``skip_duplicates=True``, because the
+ generated ``ON CONFLICT (pk) DO NOTHING`` clause targets only
+ the primary key. On **MySQL**, ``ON DUPLICATE KEY UPDATE``
+ catches all unique-key conflicts, so secondary unique violations
+ are also silently skipped.
+ ignore_extra_fields : bool, optional
+ If False (default), fields that are not in the heading raise error.
+ allow_direct_insert : bool, optional
+ Only applies in auto-populated tables. If False (default), insert may
+ only be called from inside the make callback.
+ chunk_size : int, optional
+ If set, insert rows in batches of this size. Useful for very large
+ inserts to avoid memory issues. Each chunk is a separate transaction.
+
+ Examples
+ --------
+ >>> Table.insert([
+ ... dict(subject_id=7, species="mouse", date_of_birth="2014-09-01"),
+ ... dict(subject_id=8, species="mouse", date_of_birth="2014-09-02")])
+
+ Large insert with chunking:
+
+ >>> Table.insert(large_dataset, chunk_size=10000)
+ """
+ if isinstance(rows, pandas.DataFrame):
+ # drop 'extra' synthetic index for 1-field index case -
+ # frames with more advanced indices should be prepared by user.
+ rows = rows.reset_index(drop=len(rows.index.names) == 1 and not rows.index.names[0]).to_records(index=False)
+
+ # Polars DataFrame -> list of dicts (soft dependency, check by type name)
+ if type(rows).__module__.startswith("polars") and type(rows).__name__ == "DataFrame":
+ rows = rows.to_dicts()
+
+ # PyArrow Table -> list of dicts (soft dependency, check by type name)
+ if type(rows).__module__.startswith("pyarrow") and type(rows).__name__ == "Table":
+ rows = rows.to_pylist()
+
+ if isinstance(rows, Path):
+ with open(rows, newline="") as data_file:
+ rows = list(csv.DictReader(data_file, delimiter=","))
+
+ # prohibit direct inserts into auto-populated tables
+ if not allow_direct_insert and not getattr(self, "_allow_insert", True):
+ raise DataJointError(
+ "Inserts into an auto-populated table can only be done inside "
+ "its make method during a populate call."
+ " To override, set keyword argument allow_direct_insert=True."
+ )
+
+ # Strict-provenance write gate (target check only). No-op outside make()
+ # or when the config flag is off. Deliberately does NOT touch `rows` —
+ # the per-row key-consistency check happens in `_insert_rows` as rows are
+ # materialized, so a one-shot iterable (generator) is not consumed here.
+ # See src/datajoint/provenance.py.
+ from .provenance import assert_write_allowed
+
+ assert_write_allowed(self)
+
+ 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.
+ # Note: this INSERT ... SELECT runs entirely server-side, so under
+ # strict_provenance the per-row key-consistency check does not apply
+ # (row values are never materialized client-side). The target check
+ # in assert_write_allowed above still governs which table is written.
+ 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 = []
+ # Strict-provenance per-row key check runs here, as each row is
+ # materialized — no-op outside make()/when the flag is off. Placing it in
+ # this single materialization point (reached by both the chunked and
+ # single-batch paths) avoids consuming the caller's `rows` iterable early.
+ from .provenance import assert_row_key_allowed
+
+ def _make_row(row):
+ assert_row_key_allowed(row)
+ return self.__make_row_to_insert(row, field_list, ignore_extra_fields)
+
+ rows = list(_make_row(row) 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..8e5397f26
--- /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.3.0"
diff --git a/test_requirements.txt b/test_requirements.txt
deleted file mode 100644
index 12e08e267..000000000
--- a/test_requirements.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-matplotlib
-pygraphviz
diff --git a/tests/__init__.py b/tests/__init__.py
index 00f1f8c09..e69de29bb 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,49 +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
-import datajoint as dj
-
-__author__ = 'Edgar Walker, Fabian Sinz, Dimitri Yatsenko'
-
-# 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'))
-
-# Prefix for all databases used during testing
-PREFIX = environ.get('DJ_TEST_DB_PREFIX', 'djtest')
-
-
-def setup_package():
- """
- Package-level unit test setup
- Turns off safemode
- """
- dj.config['safemode'] = False
-
-
-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')
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/integration/__init__.py b/tests/integration/__init__.py
new file mode 100644
index 000000000..e69de29bb
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..0f7c60b5c
--- /dev/null
+++ b/tests/integration/test_autopopulate.py
@@ -0,0 +1,636 @@
+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
+
+
+# =========================================================================
+# #1424: self.upstream pre-restricted ancestor access in make()
+# =========================================================================
+
+
+def test_upstream_provides_pre_restricted_ancestor(prefix, connection_test):
+ """make() can read self.upstream[Ancestor] and get pre-restricted data."""
+ schema = dj.Schema(f"{prefix}_upstream_basic", connection=connection_test)
+
+ @schema
+ class Subject(dj.Lookup):
+ definition = """
+ subject_id : int32
+ ---
+ name : varchar(64)
+ """
+ contents = [(1, "alice"), (2, "bob")]
+
+ @schema
+ class Greeting(dj.Computed):
+ definition = """
+ -> Subject
+ ---
+ greeting : varchar(128)
+ """
+
+ def make(self, key):
+ # Provenance-safe read: self.upstream pre-restricted to current key
+ name = self.upstream[Subject].fetch1("name")
+ self.insert1({**key, "greeting": f"Hello, {name}!"})
+
+ Greeting.populate()
+ assert (Greeting & {"subject_id": 1}).fetch1("greeting") == "Hello, alice!"
+ assert (Greeting & {"subject_id": 2}).fetch1("greeting") == "Hello, bob!"
+
+
+def test_upstream_rejects_non_ancestor(prefix, connection_test):
+ """self.upstream[T] for a non-ancestor table raises inside make()."""
+ schema = dj.Schema(f"{prefix}_upstream_non_ancestor", connection=connection_test)
+
+ @schema
+ class Subject(dj.Lookup):
+ definition = """
+ subject_id : int32
+ """
+ contents = [(1,)]
+
+ @schema
+ class Unrelated(dj.Lookup):
+ definition = """
+ u_id : int32
+ """
+ contents = [(99,)]
+
+ captured_errors: list[Exception] = []
+
+ @schema
+ class Bad(dj.Computed):
+ definition = """
+ -> Subject
+ ---
+ ok : tinyint
+ """
+
+ def make(self, key):
+ # class-form lookup (Diagram.__getitem__ class branch)
+ try:
+ self.upstream[Unrelated]
+ except DataJointError as exc:
+ captured_errors.append(("class", exc))
+ # string-form lookup (the separate FreeTable/string branch)
+ try:
+ self.upstream[Unrelated.full_table_name]
+ except DataJointError as exc:
+ captured_errors.append(("string", exc))
+ # Insert anyway so populate doesn't fail
+ self.insert1({**key, "ok": 1})
+
+ Bad.populate()
+ # Both the class-form and string-form lookups must reject the non-ancestor.
+ forms = {form for form, _ in captured_errors}
+ assert forms == {"class", "string"}, f"expected both branches to raise, got {forms}"
+ class_err = next(exc for form, exc in captured_errors if form == "class")
+ assert "not in this trace" in str(class_err).lower()
+
+
+def test_upstream_unset_outside_make(prefix, connection_test):
+ """Accessing self.upstream outside of make() raises a clear error."""
+ schema = dj.Schema(f"{prefix}_upstream_outside_make", connection=connection_test)
+
+ @schema
+ class Source(dj.Lookup):
+ definition = """
+ source_id : int32
+ """
+ contents = [(1,)]
+
+ @schema
+ class Derived(dj.Computed):
+ definition = """
+ -> Source
+ ---
+ val : int32
+ """
+
+ def make(self, key):
+ self.insert1({**key, "val": 0})
+
+ with pytest.raises(DataJointError, match="only available inside make"):
+ Derived().upstream
+
+
+def test_upstream_cleared_after_make(prefix, connection_test):
+ """After make() completes, the SAME instance that ran make() has its
+ self.upstream cleared. Capturing the populate instance is what gives this
+ test teeth: it would FAIL if the `finally: self._upstream = None` line were
+ removed (a fresh-instance probe would pass regardless)."""
+ schema = dj.Schema(f"{prefix}_upstream_cleared", connection=connection_test)
+
+ @schema
+ class Source(dj.Lookup):
+ definition = """
+ source_id : int32
+ """
+ contents = [(1,)]
+
+ captured = []
+
+ @schema
+ class Derived(dj.Computed):
+ definition = """
+ -> Source
+ ---
+ val : int32
+ """
+
+ def make(self, key):
+ captured.append(self) # the actual populate instance
+ assert self._upstream is not None # set for the duration of make()
+ self.insert1({**key, "val": 0})
+
+ Derived.populate()
+ assert captured, "make() did not run"
+ inst = captured[0]
+ # The finally block must have cleared _upstream on this very instance.
+ assert inst._upstream is None
+ with pytest.raises(DataJointError, match="only available inside make"):
+ inst.upstream
+
+
+def test_upstream_cleared_after_make_raises(prefix, connection_test):
+ """The reset lives in `finally` specifically so it survives an exception in
+ make(). Force make() to raise and assert the populate instance's
+ self.upstream is still cleared."""
+ schema = dj.Schema(f"{prefix}_upstream_exc", connection=connection_test)
+
+ @schema
+ class Source(dj.Lookup):
+ definition = """
+ source_id : int32
+ """
+ contents = [(1,)]
+
+ captured = []
+
+ @schema
+ class Boom(dj.Computed):
+ definition = """
+ -> Source
+ ---
+ val : int32
+ """
+
+ def make(self, key):
+ captured.append(self)
+ assert self._upstream is not None
+ raise RuntimeError("make failed on purpose")
+
+ with pytest.raises(RuntimeError, match="make failed on purpose"):
+ Boom.populate(suppress_errors=False)
+ assert captured, "make() did not run"
+ inst = captured[0]
+ # Cleared by the finally block even though make() raised.
+ assert inst._upstream is None
+ with pytest.raises(DataJointError, match="only available inside make"):
+ inst.upstream
+
+
+def test_upstream_seen_across_tripartite_make(prefix, connection_test):
+ """The tripartite make() sees the SAME self.upstream object across all three
+ phases (fetch / compute / insert) for a given key — constructed once,
+ shared. Asserted via object identity, not just a correct result."""
+ schema = dj.Schema(f"{prefix}_upstream_tripartite", connection=connection_test)
+
+ @schema
+ class Source(dj.Lookup):
+ definition = """
+ source_id : int32
+ ---
+ value : int32
+ """
+ contents = [(1, 100), (2, 200)]
+
+ seen = [] # (source_id, phase, id(self._upstream))
+
+ @schema
+ class TriComputed(dj.Computed):
+ definition = """
+ -> Source
+ ---
+ result : int32
+ """
+
+ def make_fetch(self, key):
+ seen.append((key["source_id"], "fetch", id(self._upstream)))
+ return (self.upstream[Source].fetch1("value"),)
+
+ def make_compute(self, key, value):
+ seen.append((key["source_id"], "compute", id(self._upstream)))
+ return (value * 2,)
+
+ def make_insert(self, key, doubled):
+ seen.append((key["source_id"], "insert", id(self._upstream)))
+ self.insert1({**key, "result": doubled})
+
+ TriComputed.populate()
+ assert (TriComputed & {"source_id": 1}).fetch1("result") == 200
+ assert (TriComputed & {"source_id": 2}).fetch1("result") == 400
+
+ # Every phase that ran for a given key must have observed one and the same
+ # self.upstream object (not None, not rebuilt per phase).
+ ids_by_key = {}
+ for sid, _phase, uid in seen:
+ ids_by_key.setdefault(sid, set()).add(uid)
+ assert ids_by_key, "tripartite make did not run"
+ for sid, ids in ids_by_key.items():
+ assert len(ids) == 1, f"source_id={sid}: self.upstream differed across phases: {ids}"
+
+
+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..607669124
--- /dev/null
+++ b/tests/integration/test_cascade_delete.py
@@ -0,0 +1,481 @@
+"""
+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
+
+
+# =========================================================================
+# Issue #1429: cascade with part_integrity="cascade" must traverse the FK
+# chain through intermediate Parts (and renamed FKs), not assume that the
+# Part shares PK attribute names with its Master.
+# =========================================================================
+
+
+def test_cascade_part_of_part_no_master_reference(schema_by_backend):
+ """
+ Case 2 from #1429: PartB references PartA directly (no -> Master).
+ Restricting PartB with part_integrity="cascade" must restrict both
+ PartA and Master (PartA via the direct FK, Master via the master-part
+ FK chained through PartA).
+ """
+
+ @schema_by_backend
+ class Master(dj.Manual):
+ definition = """
+ master_id : int32
+ """
+
+ class PartA(dj.Part):
+ definition = """
+ -> master
+ part_a_id : int32
+ """
+
+ class PartB(dj.Part):
+ definition = """
+ -> Master.PartA
+ part_b_id : int32
+ """
+
+ Master.insert([(1,), (2,)])
+ Master.PartA.insert([(1, 10), (1, 11), (2, 20)])
+ Master.PartB.insert([(1, 10, 100), (1, 10, 101), (1, 11, 110), (2, 20, 200)])
+
+ # Cascade preview: deleting one PartB row must propagate up to PartA and Master.
+ counts = dj.Diagram.cascade(
+ Master.PartB & {"master_id": 1, "part_a_id": 10, "part_b_id": 100},
+ part_integrity="cascade",
+ ).counts()
+
+ # Master row (1,) is the originating Part's master — must appear with count 1
+ assert counts.get(Master.full_table_name, 0) == 1, (
+ f"Master restricted by 1 row; got {counts.get(Master.full_table_name)}. "
+ "Indicates the Part→Master upward propagation did not reach the Master "
+ "through the intermediate PartA."
+ )
+ # Master cascades back down to ALL of master_id=1's Parts
+ assert counts.get(Master.PartA.full_table_name, 0) == 2 # rows 10, 11
+ assert counts.get(Master.PartB.full_table_name, 0) == 3 # rows under master_id=1
+
+
+def test_cascade_part_of_part_renamed_fk(schema_by_backend):
+ """
+ Case 1 from #1429: PartB references PartA via a renamed FK (`.proj()`).
+ PartB has no attribute named `master_id` (renamed to `src_master`). The
+ upward propagation must use the FK metadata, not assume shared attribute
+ names.
+ """
+
+ @schema_by_backend
+ class Master(dj.Manual):
+ definition = """
+ master_id : int32
+ """
+
+ class PartA(dj.Part):
+ definition = """
+ -> master
+ part_a_id : int32
+ """
+
+ class PartB(dj.Part):
+ definition = """
+ -> Master.PartA.proj(src_master='master_id', src_part='part_a_id')
+ part_b_id : int32
+ """
+
+ Master.insert([(1,), (2,)])
+ Master.PartA.insert([(1, 10), (2, 20)])
+ Master.PartB.insert([(1, 10, 100), (2, 20, 200)])
+
+ # PartB has columns: src_master, src_part, part_b_id — NOT master_id.
+ counts = dj.Diagram.cascade(
+ Master.PartB & {"src_master": 1, "src_part": 10, "part_b_id": 100},
+ part_integrity="cascade",
+ ).counts()
+
+ assert counts.get(Master.full_table_name, 0) == 1, (
+ f"Master restricted by 1 row; got {counts.get(Master.full_table_name)}. "
+ "Renamed FK was not reversed when propagating up to Master."
+ )
+ assert counts.get(Master.PartA.full_table_name, 0) == 1
+ assert counts.get(Master.PartB.full_table_name, 0) == 1
+
+
+def test_cascade_three_level_part_chain(schema_by_backend):
+ """
+ Three-hop chain (#1429 follow-up review): PartC → PartB → PartA → Master.
+ Verify intermediate Parts (PartA, PartB) are restricted at every hop, not
+ just the first, and the master cascades back down to all siblings.
+ """
+
+ @schema_by_backend
+ class Master(dj.Manual):
+ definition = """
+ master_id : int32
+ """
+
+ class PartA(dj.Part):
+ definition = """
+ -> master
+ part_a_id : int32
+ """
+
+ class PartB(dj.Part):
+ definition = """
+ -> Master.PartA
+ part_b_id : int32
+ """
+
+ class PartC(dj.Part):
+ definition = """
+ -> Master.PartB
+ part_c_id : int32
+ """
+
+ Master.insert([(1,), (2,)])
+ Master.PartA.insert([(1, 10), (1, 11), (2, 20)])
+ Master.PartB.insert([(1, 10, 100), (1, 11, 110), (2, 20, 200)])
+ Master.PartC.insert([(1, 10, 100, 1000), (1, 11, 110, 1100), (2, 20, 200, 2000)])
+
+ counts = dj.Diagram.cascade(
+ Master.PartC & {"master_id": 1, "part_a_id": 10, "part_b_id": 100, "part_c_id": 1000},
+ part_integrity="cascade",
+ ).counts()
+
+ # Master pulled in via the 3-hop upward walk
+ assert counts.get(Master.full_table_name, 0) == 1, (
+ "Master restriction lost across 3-hop chain — the per-edge upward walk " "did not reach Master through PartA + PartB."
+ )
+ # Master forward-cascades back down to all rows under master_id=1
+ assert counts.get(Master.PartA.full_table_name, 0) == 2 # both PartA rows under master 1
+ assert counts.get(Master.PartB.full_table_name, 0) == 2 # both PartB rows under master 1
+ assert counts.get(Master.PartC.full_table_name, 0) == 2 # both PartC rows under master 1
+
+
+def test_cascade_part_of_part_actual_delete(schema_by_backend):
+ """
+ End-to-end: actually run delete() with part_integrity="cascade" through
+ a Part-of-Part chain. Verifies the upward propagation produces SQL that
+ executes (no MySQL 1093 self-reference; correct row removal).
+ """
+
+ @schema_by_backend
+ class Master(dj.Manual):
+ definition = """
+ master_id : int32
+ """
+
+ class PartA(dj.Part):
+ definition = """
+ -> master
+ part_a_id : int32
+ """
+
+ class PartB(dj.Part):
+ definition = """
+ -> Master.PartA
+ part_b_id : int32
+ """
+
+ Master.insert([(1,), (2,)])
+ Master.PartA.insert([(1, 10), (2, 20)])
+ Master.PartB.insert([(1, 10, 100), (2, 20, 200)])
+
+ (Master.PartB & {"master_id": 1}).delete(part_integrity="cascade")
+
+ # master_id=1 chain is entirely gone; master_id=2 chain intact.
+ assert len(Master()) == 1
+ assert Master().fetch1("master_id") == 2
+ assert len(Master.PartA()) == 1
+ assert len(Master.PartB()) == 1
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("