diff --git a/README.md b/README.md index edbf35f..30d3dc2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) -A repository for the Dynamic Foraging task. +A repository for the Dynamic Foraging task and its associated curricula. --- @@ -34,7 +34,7 @@ from the root of the repository. ## ⚙️ Generating settings files -The Dynamic Foraging tasks is instantiated by a set of three settings files that strictly follow a DSL schema. These files are: +The Dynamic Foraging task is instantiated by a set of three settings files that strictly follow a DSL schema. These files are: - `task_logic.json` - `rig.json` @@ -52,6 +52,8 @@ However, for a better experiment management user experience, it is recommended t ## [> ] CLI tools +### Task CLI + The platform exposes a few CLI tools to facilitate various tasks. Tools are available via: ```powershell @@ -66,6 +68,103 @@ uv run dynamic-foraging -h You may need to install optional dependencies depending on the sub-commands you run. +### Curriculum CLI + +Curricula are available via the `curriculum` CLI entry point. For a full list of commands: + +```powershell +uv run curriculum -h +``` + +#### `list` - List Available Curricula + +```bash +uv run curriculum list +``` + +#### `init` - Initialize a Curriculum + +Creates an initial trainer state for enrolling a subject in a curriculum. + +```bash +# Start at the first stage +uv run curriculum init --curriculum coupled_baiting --output initial_state.json + +# Start at a specific stage +uv run curriculum init --curriculum coupled_baiting --stage s_stage_1 --output initial_state.json +``` + +#### `run` - Run a Curriculum + +Evaluates a curriculum based on session data and current trainer state. + +```bash +uv run curriculum run \ + --data-directory /path/to/session/data \ + --input-trainer-state current_state.json \ + --output-suggestion /path/to/output +``` + +Force a specific curriculum: + +```bash +uv run curriculum run \ + --data-directory /path/to/session/data \ + --input-trainer-state current_state.json \ + --curriculum coupled_baiting \ + --output-suggestion /path/to/output +``` + +#### `version` / `dsl-version` - Show Versions + +```bash +uv run curriculum version # Package version +uv run curriculum dsl-version # Underlying DSL library version +``` + +--- + +## Typical curriculum workflow + +1. **List available curricula:** + ```bash + uv run curriculum list + ``` + +2. **Initialize a subject:** + ```bash + uv run curriculum init --curriculum coupled_baiting --output trainer_state.json + ``` + +3. **After a session, evaluate progress:** + ```bash + uv run curriculum run \ + --data-directory /path/to/session/data \ + --input-trainer-state trainer_state.json \ + --output-suggestion /path/to/output + ``` + +4. **Use the suggestion for the next session:** + The `suggestion.json` output can be passed as `--input-trainer-state` for the next session. + +--- + +## Style guide + +To keep things clear, the following naming conventions are recommended: + +- **Policies** should start with `p_` (e.g., `p_identity_policy`) +- **Policy transitions** should start with `pt_` +- **Stages** should start with `s_` (e.g., `s_stage1`) +- **Stage transitions** should start with `st_` and be named after the stages they transition between (e.g., `st_s_stage1_s_stage2`) + +Define the following modules within a curriculum: + +- **metrics**: Defines (or imports) metrics classes and how to calculate them from data +- **stages**: Defines the different stages of the task, including task settings and optionally policies +- **curriculum**: Defines transitions between stages and generates the entry point to the application + +--- ## 🎮 Experiment launcher (temporarily CLABE) diff --git a/pyproject.toml b/pyproject.toml index 361205b..9e725b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,9 @@ dependencies = [ "pydantic-settings", ] +[tool.uv.workspace] +members = ["workspace/*"] + [project.urls] Documentation = "https://allenneuraldynamics.github.io/Aind.Behavior.DynamicForaging/" Repository = "https://github.com/AllenNeuralDynamics/Aind.Behavior.DynamicForaging/" diff --git a/schema/aind_behavior_dynamic_foraging.json b/schema/aind_behavior_dynamic_foraging.json index 7a5bed9..435399c 100644 --- a/schema/aind_behavior_dynamic_foraging.json +++ b/schema/aind_behavior_dynamic_foraging.json @@ -63,7 +63,7 @@ ] }, "harp_lickometer_right": { - "defulat": null, + "default": null, "description": "Harp right lickometer", "oneOf": [ { @@ -121,7 +121,6 @@ "data_directory", "triggered_camera_controller", "harp_behavior", - "harp_lickometer_right", "harp_clock_generator", "harp_sound_card", "manipulator", @@ -1983,7 +1982,7 @@ "type": "object" }, "RewardProbabilityParameters": { - "description": "Defines the reward probability structure for a dynamic foraging task.\n\nReward probabilities are defined as pairs (p_left, p_right) normalized by\nbase_reward_sum. Pairs are drawn from a family representing a difficulty level:\n\n Family 0: [[8, 1], [6, 1], [3, 1], [1, 1]]\n Family 1: [[8, 1], [1, 1]]\n Family 2: [[1.0, 0.0], [0.9, 0.1], [0.8, 0.2], [0.7, 0.3], [0.6, 0.4], [0.5, 0.5]]\n Family 3: [[6, 1], [3, 1], [1, 1]]", + "description": "Defines the reward probability structure for a dynamic foraging task.\n\nReward probabilities are defined as pairs (p_left, p_right) normalized by\nbase_reward_sum. Pairs are drawn from a family representing a difficulty level:\n\n Family 1: [[8, 1], [6, 1], [3, 1], [1, 1]]\n Family 2: [[8, 1], [1, 1]]\n Family 3: [[1.0, 0.0], [0.9, 0.1], [0.8, 0.2], [0.7, 0.3], [0.6, 0.4], [0.5, 0.5]]\n Family 4: [[6, 1], [3, 1], [1, 1]]", "properties": { "base_reward_sum": { "default": 0.8, @@ -3201,16 +3200,12 @@ "block_len": { "$ref": "#/$defs/Distribution", "default": { - "family": "Exponential", + "family": "Scalar", "distribution_parameters": { - "family": "Exponential", - "rate": 1.0 - }, - "truncation_parameters": { - "max": 2.0, - "min": 1.0, - "truncation_mode": "exclude" + "family": "Scalar", + "value": 0.0 }, + "truncation_parameters": null, "scaling_parameters": null }, "description": "Distribution describing block length." diff --git a/scripts/walk_through_session.py b/scripts/walk_through_session.py new file mode 100644 index 0000000..f6288ac --- /dev/null +++ b/scripts/walk_through_session.py @@ -0,0 +1,37 @@ +import logging +import os + +from aind_behavior_dynamic_foraging.data_contract import dataset as df_foraging_dataset +from aind_behavior_dynamic_foraging.task_logic.trial_generators.warmup_trial_generator import WarmupTrialGeneratorSpec +from aind_behavior_dynamic_foraging.task_logic.trial_models import TrialOutcome + +logging.basicConfig( + level=logging.DEBUG, +) +logger = logging.getLogger(__name__) + + +def walk_through_session(data_directory: os.PathLike): + dataset = df_foraging_dataset(data_directory) + software_events = dataset["Behavior"]["SoftwareEvents"] + software_events.load_all() + + trial_outcomes = software_events["TrialOutcome"].data["data"].iloc + warmup_trial_generator = WarmupTrialGeneratorSpec().create_generator() + for i, outcome in enumerate(trial_outcomes): + warmup_trial_generator.update(TrialOutcome.model_validate(outcome)) + trial = warmup_trial_generator.next() + + if not trial: + print(f"Session finished at trial {i}") + return + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Walk through a behavior session.") + parser.add_argument("--data-directory", help="Path to the session directory") + args = parser.parse_args() + + walk_through_session(args.data_directory) diff --git a/src/Extensions/AindBehaviorDynamicForaging.Generated.cs b/src/Extensions/AindBehaviorDynamicForaging.Generated.cs index ef72cbe..3675f30 100644 --- a/src/Extensions/AindBehaviorDynamicForaging.Generated.cs +++ b/src/Extensions/AindBehaviorDynamicForaging.Generated.cs @@ -232,7 +232,7 @@ public HarpLicketySplit HarpLickometerLeft /// Harp right lickometer /// [System.Xml.Serialization.XmlIgnoreAttribute()] - [Newtonsoft.Json.JsonPropertyAttribute("harp_lickometer_right", Required=Newtonsoft.Json.Required.AllowNull)] + [Newtonsoft.Json.JsonPropertyAttribute("harp_lickometer_right")] [System.ComponentModel.DescriptionAttribute("Harp right lickometer")] public HarpLicketySplit HarpLickometerRight { @@ -2939,10 +2939,10 @@ public override string ToString() ///Reward probabilities are defined as pairs (p_left, p_right) normalized by ///base_reward_sum. Pairs are drawn from a family representing a difficulty level: /// - /// Family 0: [[8, 1], [6, 1], [3, 1], [1, 1]] - /// Family 1: [[8, 1], [1, 1]] - /// Family 2: [[1.0, 0.0], [0.9, 0.1], [0.8, 0.2], [0.7, 0.3], [0.6, 0.4], [0.5, 0.5]] - /// Family 3: [[6, 1], [3, 1], [1, 1]] + /// Family 1: [[8, 1], [6, 1], [3, 1], [1, 1]] + /// Family 2: [[8, 1], [1, 1]] + /// Family 3: [[1.0, 0.0], [0.9, 0.1], [0.8, 0.2], [0.7, 0.3], [0.6, 0.4], [0.5, 0.5]] + /// Family 4: [[6, 1], [3, 1], [1, 1]] /// [System.CodeDom.Compiler.GeneratedCodeAttribute("Bonsai.Sgen", "0.9.0.0 (Newtonsoft.Json v13.0.0.0)")] [System.ComponentModel.DescriptionAttribute(@"Defines the reward probability structure for a dynamic foraging task. @@ -2950,10 +2950,10 @@ public override string ToString() Reward probabilities are defined as pairs (p_left, p_right) normalized by base_reward_sum. Pairs are drawn from a family representing a difficulty level: - Family 0: [[8, 1], [6, 1], [3, 1], [1, 1]] - Family 1: [[8, 1], [1, 1]] - Family 2: [[1.0, 0.0], [0.9, 0.1], [0.8, 0.2], [0.7, 0.3], [0.6, 0.4], [0.5, 0.5]] - Family 3: [[6, 1], [3, 1], [1, 1]]")] + Family 1: [[8, 1], [6, 1], [3, 1], [1, 1]] + Family 2: [[8, 1], [1, 1]] + Family 3: [[1.0, 0.0], [0.9, 0.1], [0.8, 0.2], [0.7, 0.3], [0.6, 0.4], [0.5, 0.5]] + Family 4: [[6, 1], [3, 1], [1, 1]]")] [Bonsai.WorkflowElementCategoryAttribute(Bonsai.ElementCategory.Source)] [Bonsai.CombinatorAttribute(MethodName="Generate")] public partial class RewardProbabilityParameters diff --git a/src/aind_behavior_dynamic_foraging/data_contract/_dataset.py b/src/aind_behavior_dynamic_foraging/data_contract/_dataset.py index 69a73de..3f2e238 100644 --- a/src/aind_behavior_dynamic_foraging/data_contract/_dataset.py +++ b/src/aind_behavior_dynamic_foraging/data_contract/_dataset.py @@ -1,5 +1,6 @@ from pathlib import Path +from aind_behavior_curriculum import TrainerState from aind_behavior_services.session import Session from contraqctor.contract import Dataset, DataStreamCollection from contraqctor.contract.camera import Camera @@ -7,7 +8,7 @@ DeviceYmlByFile, HarpDevice, ) -from contraqctor.contract.json import PydanticModel, SoftwareEvents +from contraqctor.contract.json import Json, PydanticModel, SoftwareEvents from contraqctor.contract.mux import MapFromPaths from .. import __semver__ @@ -58,6 +59,19 @@ def make_dataset( name="Behavior", description="Data from the Behavior modality", data_streams=[ + Json( + name="PreviousMetrics", + reader_params=Json.make_params( + path=root_path / "behavior/previous_metrics.json", + ), + ), + PydanticModel( + name="TrainerState", + reader_params=PydanticModel.make_params( + model=TrainerState, + path=root_path / "behavior/trainer_state.json", + ), + ), HarpDevice( name="HarpBehavior", reader_params=HarpDevice.make_params( diff --git a/src/aind_behavior_dynamic_foraging/rig.py b/src/aind_behavior_dynamic_foraging/rig.py index dbd964d..c1a94f0 100644 --- a/src/aind_behavior_dynamic_foraging/rig.py +++ b/src/aind_behavior_dynamic_foraging/rig.py @@ -72,7 +72,7 @@ class AindDynamicForagingRig(rig.Rig): ) harp_behavior: harp.HarpBehavior = Field(description="Harp behavior") harp_lickometer_left: Optional[harp.HarpLicketySplit] = Field(default=None, description="Harp left lickometer") - harp_lickometer_right: Optional[harp.HarpLicketySplit] = Field(defulat=None, description="Harp right lickometer") + harp_lickometer_right: Optional[harp.HarpLicketySplit] = Field(default=None, description="Harp right lickometer") harp_clock_generator: harp.HarpWhiteRabbit = Field(description="Harp clock generator") harp_sound_card: DynamicForagingSoundCard = Field(description="Harp sound card") harp_sniff_detector: Optional[harp.HarpSniffDetector] = Field(default=None, description="Harp sniff detector") diff --git a/src/aind_behavior_dynamic_foraging/task_logic/trial_generators/block_based_trial_generator.py b/src/aind_behavior_dynamic_foraging/task_logic/trial_generators/block_based_trial_generator.py index 9788a0b..0112c95 100644 --- a/src/aind_behavior_dynamic_foraging/task_logic/trial_generators/block_based_trial_generator.py +++ b/src/aind_behavior_dynamic_foraging/task_logic/trial_generators/block_based_trial_generator.py @@ -26,10 +26,10 @@ class RewardProbabilityParameters(BaseModel): Reward probabilities are defined as pairs (p_left, p_right) normalized by base_reward_sum. Pairs are drawn from a family representing a difficulty level: - Family 0: [[8, 1], [6, 1], [3, 1], [1, 1]] - Family 1: [[8, 1], [1, 1]] - Family 2: [[1.0, 0.0], [0.9, 0.1], [0.8, 0.2], [0.7, 0.3], [0.6, 0.4], [0.5, 0.5]] - Family 3: [[6, 1], [3, 1], [1, 1]] + Family 1: [[8, 1], [6, 1], [3, 1], [1, 1]] + Family 2: [[8, 1], [1, 1]] + Family 3: [[1.0, 0.0], [0.9, 0.1], [0.8, 0.2], [0.7, 0.3], [0.6, 0.4], [0.5, 0.5]] + Family 4: [[6, 1], [3, 1], [1, 1]] """ diff --git a/src/aind_behavior_dynamic_foraging/task_logic/trial_generators/warmup_trial_generator.py b/src/aind_behavior_dynamic_foraging/task_logic/trial_generators/warmup_trial_generator.py index e560b19..9f115cc 100644 --- a/src/aind_behavior_dynamic_foraging/task_logic/trial_generators/warmup_trial_generator.py +++ b/src/aind_behavior_dynamic_foraging/task_logic/trial_generators/warmup_trial_generator.py @@ -1,12 +1,7 @@ import logging from typing import Literal -from aind_behavior_services.task.distributions import ( - Distribution, - ExponentialDistribution, - ExponentialDistributionParameters, - TruncationParameters, -) +from aind_behavior_services.task.distributions import Distribution, Scalar from pydantic import BaseModel, Field from ..trial_models import TrialOutcome @@ -41,10 +36,7 @@ class WarmupTrialGeneratorSpec(BlockBasedTrialGeneratorSpec): type: Literal["WarmupTrialGenerator"] = "WarmupTrialGenerator" block_len: Distribution = Field( - default=ExponentialDistribution( - distribution_parameters=ExponentialDistributionParameters(rate=1), - truncation_parameters=TruncationParameters(min=1, max=2), - ), + default=Scalar(value=1), description="Distribution describing block length.", ) @@ -69,7 +61,8 @@ def _are_end_conditions_met(self) -> bool: """ end_conditions = self.spec.trial_generation_end_parameters - choice_history = self.is_right_choice_history + win = end_conditions.evaluation_window + choice_history = self.is_right_choice_history[-win:] if win > 0 else self.is_right_choice_history choice_len = len(choice_history) left_choices = choice_history.count(False) @@ -78,20 +71,25 @@ def _are_end_conditions_met(self) -> bool: finish_ratio = 0 if choice_len == 0 else (unignored) / choice_len choice_ratio = 0 if unignored == 0 else right_choices / (unignored) - if ( - choice_len >= end_conditions.min_trial + len(self.is_right_choice_history) >= end_conditions.min_trial and finish_ratio >= end_conditions.min_response_rate and abs(choice_ratio - 0.5) <= end_conditions.max_choice_bias ): logger.debug( "Warmup trial generation end conditions met: " - f"total trials={choice_len}, " + f"total trials={len(self.is_right_choice_history)}, " f"finish ratio={finish_ratio}, " f"choice bias={abs(choice_ratio - 0.5)}" ) return True + logger.debug( + "Warmup trial generation end conditions are not met: " + f"total trials={len(self.is_right_choice_history)}, " + f"finish ratio={finish_ratio}, " + f"choice bias={abs(choice_ratio - 0.5)}" + ) return False def update(self, outcome: TrialOutcome | str) -> None: diff --git a/uv.lock b/uv.lock index 17e112d..65f6e74 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,12 @@ resolution-markers = [ "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] +[manifest] +members = [ + "aind-behavior-dynamic-foraging", + "aind-behavior-dynamic-foraging-curricula", +] + [[package]] name = "accessible-pygments" version = "0.0.5" @@ -27,16 +33,16 @@ wheels = [ [[package]] name = "aind-behavior-curriculum" -version = "0.0.37" +version = "0.0.38" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, { name = "pydantic" }, { name = "semver" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f0/ac/cb5073f94c6b41b88c6e91d8bf41f7c140d659a0eb1191bc0a91cace60db/aind_behavior_curriculum-0.0.37.tar.gz", hash = "sha256:a6d8fd58b4d172655bc445eefefe8ba6a7966f2b4303afe138dce3c07ec45a13", size = 139105, upload-time = "2025-12-05T22:51:07.253Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/ad/812f8cd33857366d7b1da81443ed60777992e591438c7104ad9fe0d4ac9f/aind_behavior_curriculum-0.0.38.tar.gz", hash = "sha256:11cce6a455ee3a2c0464e5f0ff170c44f1f9caf50cd3f1bf9bd9df7a41248e0d", size = 139193, upload-time = "2026-03-09T10:56:32.906Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/83/c51f680b86136dca811816746568de0feadbbe03d27a3213500a744ff047/aind_behavior_curriculum-0.0.37-py3-none-any.whl", hash = "sha256:349e973d52a450523b03a4b73b8371f72c80e9d43702eb62005f250e51663ca7", size = 48014, upload-time = "2025-12-05T22:51:06.351Z" }, + { url = "https://files.pythonhosted.org/packages/74/36/53daf76d7a3a27245fdee89e7900bbb13b9db0327af22d5f3e10c3273720/aind_behavior_curriculum-0.0.38-py3-none-any.whl", hash = "sha256:ec77803fc0cad1c9f430bad3f1d2404dd324ce24ee7970b8d8337de2d6ad2d3c", size = 48048, upload-time = "2026-03-09T10:56:31.299Z" }, ] [[package]] @@ -94,6 +100,55 @@ docs = [ { name = "sphinx-jsonschema" }, ] +[[package]] +name = "aind-behavior-dynamic-foraging-curricula" +version = "0.2.1" +source = { editable = "workspace/aind_behavior_dynamic_foraging_curricula" } +dependencies = [ + { name = "aind-behavior-curriculum" }, + { name = "aind-behavior-dynamic-foraging" }, + { name = "numpy" }, + { name = "pydantic-settings" }, +] + +[package.dev-dependencies] +dev = [ + { name = "codespell" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, + { name = "pymdown-extensions" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aind-behavior-curriculum", specifier = ">=0.0.38" }, + { name = "aind-behavior-dynamic-foraging", editable = "." }, + { name = "numpy", specifier = ">=2.4.2" }, + { name = "pydantic-settings" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codespell" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extras = ["python"] }, + { name = "pymdown-extensions" }, + { name = "ruff" }, +] + [[package]] name = "aind-behavior-services" version = "0.13.5" @@ -298,6 +353,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "backrefs" +version = "6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/a6/e325ec73b638d3ede4421b5445d4a0b8b219481826cc079d510100af356c/backrefs-6.2.tar.gz", hash = "sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49", size = 7012303, upload-time = "2026-02-16T19:10:15.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/39/3765df263e08a4df37f4f43cb5aa3c6c17a4bdd42ecfe841e04c26037171/backrefs-6.2-py310-none-any.whl", hash = "sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8", size = 381075, upload-time = "2026-02-16T19:10:04.322Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f0/35240571e1b67ffb19dafb29ab34150b6f59f93f717b041082cdb1bfceb1/backrefs-6.2-py311-none-any.whl", hash = "sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be", size = 392874, upload-time = "2026-02-16T19:10:06.314Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/77e8c9745b4d227cce9f5e0a6f68041278c5f9b18588b35905f5f19c1beb/backrefs-6.2-py312-none-any.whl", hash = "sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90", size = 398787, upload-time = "2026-02-16T19:10:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/c5/71/c754b1737ad99102e03fa3235acb6cb6d3ac9d6f596cbc3e5f236705abd8/backrefs-6.2-py313-none-any.whl", hash = "sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b", size = 400747, upload-time = "2026-02-16T19:10:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/af/75/be12ba31a6eb20dccef2320cd8ccb3f7d9013b68ba4c70156259fee9e409/backrefs-6.2-py314-none-any.whl", hash = "sha256:e5f805ae09819caa1aa0623b4a83790e7028604aa2b8c73ba602c4454e665de7", size = 412602, upload-time = "2026-02-16T19:10:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077, upload-time = "2026-02-16T19:10:13.74Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -624,6 +693,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/9a/94fa67d2aecf7703a68be18b97b8d1af09ec9c26c73b2c21d5e881e46353/contraqctor-0.5.3-py3-none-any.whl", hash = "sha256:9306521efd6165007409319be39d4a8d9e286028693237584dbfc9f87e974292", size = 67638, upload-time = "2025-11-10T17:31:18.482Z" }, ] +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cryptography" version = "46.0.5" @@ -807,6 +980,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + [[package]] name = "gitdb" version = "4.0.12" @@ -831,6 +1016,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, ] +[[package]] +name = "griffelib" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/d7/2b805e89cdc609e5b304361d80586b272ef00f6287ee63de1e571b1f71ec/griffelib-2.0.1.tar.gz", hash = "sha256:59f39eabb4c777483a3823e39e8f9e03e69df271a7e49aee64e91a8cfa91bdf5", size = 166383, upload-time = "2026-03-23T21:05:25.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/4c/cc8c68196db727cfc1432f2ad5de50aa6707e630d44b2e6361dc06d8f134/griffelib-2.0.1-py3-none-any.whl", hash = "sha256:b769eed581c0e857d362fc8fcd8e57ecd2330c124b6104ac8b4c1c86d76970aa", size = 142377, upload-time = "2026-03-23T21:04:01.116Z" }, +] + [[package]] name = "harp-python" version = "0.4.1" @@ -871,6 +1065,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -1112,6 +1315,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, ] +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1283,6 +1495,134 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/62/0dfc5719514115bf1781f44b1d7f2a0923fcc01e9c5d7990e48a05c9ae5d/mkdocstrings-1.0.3.tar.gz", hash = "sha256:ab670f55040722b49bb45865b2e93b824450fb4aef638b00d7acb493a9020434", size = 100946, upload-time = "2026-02-07T14:31:40.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/41/1cf02e3df279d2dd846a1bf235a928254eba9006dd22b4a14caa71aed0f7/mkdocstrings-1.0.3-py3-none-any.whl", hash = "sha256:0d66d18430c2201dc7fe85134277382baaa15e6b30979f3f3bdbabd6dbdb6046", size = 35523, upload-time = "2026-02-07T14:31:39.27Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, +] + [[package]] name = "ms-active-directory" version = "1.14.1" @@ -1302,9 +1642,9 @@ name = "msal" version = "1.35.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform == 'win32'" }, - { name = "pyjwt", extra = ["crypto"], marker = "sys_platform == 'win32'" }, - { name = "requests", marker = "sys_platform == 'win32'" }, + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3c/aa/5a646093ac218e4a329391d5a31e5092a89db7d2ef1637a90b82cd0b6f94/msal-1.35.1.tar.gz", hash = "sha256:70cac18ab80a053bff86219ba64cfe3da1f307c74b009e2da57ef040eb1b5656", size = 165658, upload-time = "2026-03-04T23:38:51.812Z" } wheels = [ @@ -1435,6 +1775,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + [[package]] name = "pandas" version = "3.0.1" @@ -1495,6 +1844,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/b0/34937815889fa982613775e4b97fddd13250f11012d769949c5465af2150/pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", size = 9452085, upload-time = "2026-02-17T22:20:14.331Z" }, ] +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + [[package]] name = "pillow" version = "12.1.1" @@ -1582,6 +1940,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -1819,7 +2195,7 @@ wheels = [ [package.optional-dependencies] crypto = [ - { name = "cryptography", marker = "sys_platform == 'win32'" }, + { name = "cryptography" }, ] [[package]] @@ -1838,6 +2214,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/04/6cf0687780c68e7fb0525e7210ec5477987c0481904f600c2e5d81bbb7dd/pykeepass-4.1.1.post1-py3-none-any.whl", hash = "sha256:4cfd54f376cb1f58dd8f11fbe7923282bc7dd97ffdf1bb622004a6e718bfe379", size = 55584, upload-time = "2025-03-06T00:41:57.201Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/63/06673d1eb6d8f83c0ea1f677d770e12565fb516928b4109c9e2055656a9e/pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5", size = 853363, upload-time = "2026-02-15T20:44:06.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" }, +] + [[package]] name = "pyotp" version = "2.9.0" @@ -1856,6 +2245,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1941,6 +2360,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + [[package]] name = "questionary" version = "2.1.1" @@ -2386,6 +2817,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + [[package]] name = "typenames" version = "2.1.0" @@ -2449,6 +2934,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "wcwidth" version = "0.6.0" diff --git a/workspace/aind_behavior_dynamic_foraging_curricula/README.md b/workspace/aind_behavior_dynamic_foraging_curricula/README.md new file mode 100644 index 0000000..5744d03 --- /dev/null +++ b/workspace/aind_behavior_dynamic_foraging_curricula/README.md @@ -0,0 +1,223 @@ +# Aind.Behavior.DynamicForaging.Curricula + +![CI](https://github.com/AllenNeuralDynamics/Aind.Behavior.DynamicForaging.Curricula/actions/workflows/dynamic-foraging-curricula.yml/badge.svg) +[![License](https://img.shields.io/badge/license-MIT-brightgreen)](LICENSE) +[![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) + + +A repository of curricula for [Dynamic foraging task](https://github.com/AllenNeuralDynamics/Aind.Behavior.DynamicForaging). + +## CLI Reference + +Curricula are modules of the main package: `aind_behavior_dynamic_foraging_curricula.`. + +All curricula are available via the `curriculum` CLI entry point. The following commands are available: + +### Getting Help + +Display all available commands: + +```bash +uv run curriculum -h +``` + +Get help for a specific command: + +```bash +uv run curriculum -h +``` + +### `list` - List Available Curricula + +Lists all curricula available in this repository. + +**Usage:** + +```bash +uv run curriculum list +``` + +**Example output:** +``` +Available curricula: + coupled_baiting +``` + +### `init` - Initialize a Curriculum + +Creates an initial trainer state for enrolling a subject in a curriculum. This generates the starting point for curriculum execution. + +**Required Arguments:** +- `--curriculum `: The curriculum to enroll in (required) + +**Optional Arguments:** +- `--output `: Path to save the enrollment trainer state as a JSON file +- `--stage `: If provided, enroll at a specific stage instead of the first stage + +**Examples:** + +Initialize the depletion curriculum (starts at first stage): + +```bash +uv run curriculum init --curriculum coupled_baiting --output initial_state.json +``` + +Initialize at a specific stage: + +```bash +uv run curriculum init --curriculum coupled_baiting --stage s_stage_1 --output initial_state.json +``` + +Print to stdout without saving: + +```bash +uv run curriculum init --curriculum coupled_baiting +``` + +### `run` - Run a Curriculum + +Evaluates a curriculum based on session data and the current trainer state, producing a suggestion for the next stage. + +**Required Arguments:** +- `--data-directory `: Path to the session data directory for calculating metrics +- `--input-trainer-state `: Path to the current trainer state JSON file + +**Optional Arguments:** +- `--curriculum `: Forces the use of a specific curriculum, bypassing automatic detection +- `--output-suggestion `: Directory path to save the suggestion as `suggestion.json` +- `--mute-suggestion`: Disables printing the suggestion to stdout (useful when only saving to file) + +**Examples:** + +Run curriculum with automatic detection: + +```bash +uv run curriculum run \ + --data-directory /path/to/session/data \ + --input-trainer-state current_state.json \ + --output-suggestion /path/to/output +``` + +Force a specific curriculum: + +```bash +uv run curriculum run \ + --data-directory /path/to/session/data \ + --input-trainer-state current_state.json \ + --curriculum depletion \ + --output-suggestion /path/to/output +``` + +Run without saving (print to stdout only): + +```bash +uv run curriculum run \ + --data-directory /path/to/session/data \ + --input-trainer-state current_state.json +``` + +Run and save without printing: + +```bash +uv run curriculum run \ + --data-directory /path/to/session/data \ + --input-trainer-state current_state.json \ + --output-suggestion /path/to/output \ + --mute-suggestion +``` + +Quick demo with template curriculum: + +```bash +uv run curriculum run \ + --data-directory "demo" \ + --input-trainer-state "foo.json" \ + --curriculum "template" +``` + +### `version` - Show Package Version + +Displays the version of this package. + +**Usage:** + +```bash +uv run curriculum version +``` + +**Example output:** +``` +0.2.0 +``` + +### `dsl-version` - Show DSL Version + +Displays the version of the underlying `aind-behavior-curriculum` DSL library. + +**Usage:** + +```bash +uv run curriculum dsl-version +``` + +**Example output:** +``` +0.0.37 +``` + +## Typical Workflow + +1. **List available curricula:** + ```bash + uv run curriculum list + ``` + +2. **Initialize a subject in a curriculum:** + ```bash + uv run curriculum init --curriculum depletion --output trainer_state.json + ``` + +3. **After a training session, evaluate progress:** + ```bash + uv run curriculum run \ + --data-directory /path/to/session/data \ + --input-trainer-state trainer_state.json \ + --output-suggestion /path/to/output + ``` + +4. **Use the suggestion output for the next session:** + The `suggestion.json` file contains the updated trainer state and can be used as `--input-trainer-state` for the next session. + + +## Style guide + +To keep things clear, I suggest the following naming convention: + +* **Policies** should start with `p_` (e.g., `p_identity_policy`) +* **Policy transitions** should start with `pt_` +* **Stages** should start with `s_` (e.g., `s_stage1`) +* **Stage transitions** should start with `st_` and should be named after the stages they transition between (e.g., `st_s_stage1_s_stage2`) + +Define the following modules: + +* **metrics**: Defines (or imports) metrics classes and how to calculate them from data +* **stages**: Defines the different stages of the Dynamic foraging task. This includes task settings and, optionally, policies +* **curriculum**: Defines the transitions between the stages and generate entry point to the application + +## Contributors + +Contributions to this repository are welcome! However, please ensure that your code adheres to the recommended DevOps practices below: + +### Linting + +We use [ruff](https://docs.astral.sh/ruff/) as our primary linting tool. + +### Testing + +Attempt to add tests when new features are added. +To run the currently available tests, run `uv run pytest` from the root of the repository. + +### Lock files + +We use [uv](https://docs.astral.sh/uv/) to manage our lock files and therefore encourage everyone to use uv as a package manager as well. \ No newline at end of file diff --git a/workspace/aind_behavior_dynamic_foraging_curricula/examples/coupled_baiting_curriculum.py b/workspace/aind_behavior_dynamic_foraging_curricula/examples/coupled_baiting_curriculum.py new file mode 100644 index 0000000..9cf547b --- /dev/null +++ b/workspace/aind_behavior_dynamic_foraging_curricula/examples/coupled_baiting_curriculum.py @@ -0,0 +1,35 @@ +from pathlib import Path + +from aind_behavior_dynamic_foraging_curricula.coupled_baiting import TRAINER +from aind_behavior_dynamic_foraging_curricula.metrics import DynamicForagingMetrics + + +def main(): + trainer_state = TRAINER.create_enrollment() + + # starts at stage_1_warmup + current_stage = trainer_state.stage + print(f"Current stage: {current_stage.name}") # stage_1_warmup + + metrics = DynamicForagingMetrics( + total_sessions=1, + consecutive_sessions_at_current_stage=1, + unignored_trials_per_session=[250], + foraging_efficiency_per_session=[0.65], + stage_name="stage_1_warmup", + ) + + # evaluate + new_trainer_state = TRAINER.evaluate(trainer_state, metrics) + print(f"Next stage: {new_trainer_state.stage.name}") # stage_2, since finished_trials >= 200 and efficiency >= 0.6 + + # save models + trainer_state_path = Path(r".\local\trainer_state.json") + trainer_state_path.write_text(new_trainer_state.model_dump_json(indent=2)) + + metrics_path = Path(r".\local\metrics.json") + metrics_path.write_text(metrics.model_dump_json(indent=2)) + + +if __name__ == "__main__": + main() diff --git a/workspace/aind_behavior_dynamic_foraging_curricula/pyproject.toml b/workspace/aind_behavior_dynamic_foraging_curricula/pyproject.toml new file mode 100644 index 0000000..386167e --- /dev/null +++ b/workspace/aind_behavior_dynamic_foraging_curricula/pyproject.toml @@ -0,0 +1,75 @@ +[build-system] +requires = ["uv_build>=0.8.22"] +build-backend = "uv_build" + +[project] +name = "aind-behavior-dynamic-foraging-curricula" +description = "A library of curricula for the Dynamic Foraging task." +authors = [ + {name = "Bruno Cruz", email = "bruno.cruz@alleninstitute.org"}, + {name = "Micah Woodard", email = "micah.woodard@alleninstitute.org"} + ] +license = "MIT" +version = "0.2.1" +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: Microsoft :: Windows", +] +readme = {file = "README.md", content-type = "text/markdown"} + +dependencies = [ + "aind-behavior-curriculum >= 0.0.38", + "numpy>=2.4.2", + "pydantic-settings", + "aind-behavior-dynamic-foraging==0.0.2rc24" +] + +[tool.uv.sources] +aind-behavior-dynamic-foraging = { workspace = true } + +[dependency-groups] + +dev = [ + 'ruff', + 'pytest', + 'pytest-cov', + 'codespell', +] + +docs = [ + 'mkdocs', + 'mkdocs-material', + 'mkdocstrings[python]', + 'pymdown-extensions', + 'ruff', +] + +[tool.uv] +default-groups = ['dev'] + +[tool.ruff] +line-length = 120 +target-version = 'py311' + +[tool.ruff.lint] +extend-select = ['Q', 'RUF100', 'C90', 'I'] +extend-ignore = [] +mccabe = { max-complexity = 14 } +pydocstyle = { convention = 'google' } + +[tool.codespell] +skip = '.git,*.pdf,*.svg,uv.lock' +ignore-words-list = 'nd' + +[tool.pytest.ini_options] +addopts = "--strict-markers --tb=short --cov=src --cov-report=term-missing --cov-fail-under=70" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + +[project.scripts] +curriculum = "aind_behavior_dynamic_foraging_curricula.cli:main" diff --git a/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/__init__.py b/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/__init__.py new file mode 100644 index 0000000..98cffdf --- /dev/null +++ b/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/__init__.py @@ -0,0 +1,30 @@ +import re +from importlib.metadata import PackageNotFoundError, version + + +def pep440_to_semver(ver: str) -> str: + """ + Convert a PEP 440 version to a SemVer-compatible string. + + Examples: + 1.2.3rc2 -> 1.2.3-rc2 + 1.2.3a1 -> 1.2.3-a1 + 1.2.3b1 -> 1.2.3-b1 + 1.2.3.dev4 -> 1.2.3-dev4 + 1.2.3.post1 -> 1.2.3+post1 + """ + # pre-release: a, b, rc -> -aN, -bN, -rcN + ver = re.sub(r"(?<=\d)(a|b|rc)(\d+)", r"-\1\2", ver) + # dev release: .devN -> -devN + ver = re.sub(r"\.dev(\d+)", r"-dev\1", ver) + # post release: .postN -> +postN + ver = re.sub(r"\.post(\d+)", r"+post\1", ver) + return ver + + +try: + __version__ = version(__name__) +except PackageNotFoundError: + __version__ = "0.0.0" + +__semver__ = pep440_to_semver(__version__) diff --git a/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/cli.py b/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/cli.py new file mode 100644 index 0000000..62fa7b7 --- /dev/null +++ b/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/cli.py @@ -0,0 +1,168 @@ +import importlib +import logging +import os +import typing as t +from pathlib import Path + +import aind_behavior_curriculum +from pydantic import BaseModel, Field, RootModel, SerializeAsAny +from pydantic_settings import BaseSettings, CliApp, CliImplicitFlag, CliSubCommand + +from . import __version__ +from .utils import model_from_json_file + +TModel = t.TypeVar("TModel", bound=BaseModel) +TTrainerState = t.TypeVar("TTrainerState", bound=aind_behavior_curriculum.TrainerState) +TMetrics = t.TypeVar("TMetrics", bound=aind_behavior_curriculum.Metrics) +TCurriculum = t.TypeVar("TCurriculum", bound=aind_behavior_curriculum.Curriculum) + +curricula_logger = logging.Logger(__name__) + + +class Version(RootModel): + root: t.Any + + def cli_cmd(self) -> None: + print(__version__) + + +class DslVersion(RootModel): + root: t.Any + + def cli_cmd(self) -> None: + print(aind_behavior_curriculum.__version__) + + +class ListKnownCurricula(RootModel): + root: t.Any + + def cli_cmd(self) -> None: + print("Available curricula:") + for curriculum in _KNOWN_CURRICULA: + print(f" - {curriculum}") + + +class CurriculumCliArgs(BaseSettings): + data_directory: os.PathLike = Field(description="Path to the session data directory.") + input_trainer_state: os.PathLike = Field(description="Path to a serialized trainer state.") + mute_suggestion: CliImplicitFlag[bool] = Field(default=False, description="Disables the suggestion output") + output_suggestion: t.Optional[os.PathLike] = Field( + default=None, + description="A path to save the suggestion. If not provided, the suggestion will not be serialized to a file.", + ) + curriculum: t.Optional[str] = Field( + default=None, description="Forces the use of a specific curriculum, bypassing any automatic detection." + ) + + def cli_cmd(self) -> None: + try: + if self.curriculum: + curriculum_name = self.curriculum + else: + annonymous_trainer_state = model_from_json_file( + self.input_trainer_state, + aind_behavior_curriculum.TrainerState[aind_behavior_curriculum.Curriculum[t.Any]], + ) + if (curriculum := annonymous_trainer_state.curriculum) is None: + curricula_logger.error("Trainer state does not have a curriculum.") + raise ValueError("Trainer state does not have a curriculum.") + curriculum_name = curriculum.pkg_location + + curriculum_name = curriculum_name.replace(str(__package__) + ".", "") + if curriculum_name not in _KNOWN_CURRICULA: + curricula_logger.error(f"Unknown curriculum: {curriculum_name}. Available: {list(_KNOWN_CURRICULA)}") + raise ValueError(f"Unknown curriculum: {curriculum_name}. Available: {list(_KNOWN_CURRICULA)}") + + else: + module = importlib.import_module(f"{__package__}.{curriculum_name}") + runner: t.Callable[[CurriculumCliArgs], CurriculumSuggestion] = getattr(module, "run_curriculum") + + suggestion = runner(self) + suggestion.dsl_version = aind_behavior_curriculum.__version__ + + if not self.mute_suggestion: + print(suggestion.model_dump_json()) + + if self.output_suggestion is not None: + with open(Path(self.output_suggestion) / "suggestion.json", "w", encoding="utf-8") as file: + file.write(suggestion.model_dump_json(indent=2)) + + except Exception as e: + curricula_logger.error(f"Error occurred while running curriculum: {e}") + raise e + + +class CurriculumInitCliArgs(BaseSettings): + curriculum: str = Field(description="The curriculum to enroll the model in.") + output: t.Optional[os.PathLike] = Field( + default=None, + description="Path to save the enrollment curriculum. If not provided, the curriculum will not be serialized to a file.", + ) + stage: t.Optional[str] = Field( + default=None, + description="If provided, the enrollment will be for a specific stage in the curriculum.", + ) + + def cli_cmd(self) -> None: + if self.curriculum not in _KNOWN_CURRICULA: + curricula_logger.error(f"Unknown curriculum: {self.curriculum}. Available: {list(_KNOWN_CURRICULA)}") + raise ValueError(f"Unknown curriculum: {self.curriculum}. Available: {list(_KNOWN_CURRICULA)}") + + module = importlib.import_module(f"{__package__}.{self.curriculum}") + trainer: aind_behavior_curriculum.Trainer = getattr(module, "TRAINER") + if self.stage is None: + init_state = trainer.create_enrollment() + else: + try: + stages = trainer.curriculum.see_stages() + stage = [s for s in stages if s.name == self.stage][0] + except IndexError: + curricula_logger.error(f"Unknown stage: {self.stage}") + curricula_logger.error(self._print_available_stages(trainer.curriculum)) + raise ValueError(f"Unknown stage: {self.stage}. Available: {[s.name for s in stages]}") + else: + init_state = trainer.create_trainer_state( + stage=stage, is_on_curriculum=True, active_policies=stage.start_policies + ) + + if self.output is not None: + with open(Path(self.output), "w", encoding="utf-8") as file: + file.write(init_state.model_dump_json(indent=2)) + + print(init_state.model_dump_json()) + + def _print_available_stages(self, curriculum: aind_behavior_curriculum.Curriculum) -> None: + print("Available stages:") + for stage in curriculum.see_stages(): + print(f" - {stage.name}") + + +class CurriculumAppCliArgs(BaseSettings, cli_prog_name="curriculum", cli_kebab_case=True): + run: CliSubCommand[CurriculumCliArgs] + init: CliSubCommand[CurriculumInitCliArgs] + version: CliSubCommand[Version] + dsl_version: CliSubCommand[DslVersion] + list: CliSubCommand[ListKnownCurricula] + + def cli_cmd(self) -> None: + CliApp.run_subcommand(self) + + +class CurriculumSuggestion(BaseModel, t.Generic[TTrainerState, TMetrics]): + trainer_state: SerializeAsAny[TTrainerState] = Field(description="The TrainerState suggestion.") + metrics: SerializeAsAny[TMetrics] = Field(description="The calculated metrics.") + version: str = Field(default=__version__, description="The version of the curriculum.") + dsl_version: str = Field( + default=aind_behavior_curriculum.__version__, description="The version of the curriculum library." + ) + + +_KNOWN_CURRICULA = [p.stem for p in Path(__file__).parent.iterdir() if p.is_dir() and not p.name.startswith("_")] + + +def main(): + CliApp.run(CurriculumAppCliArgs) + + +if __name__ == "__main__": + main() diff --git a/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/coupled_baiting/__init__.py b/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/coupled_baiting/__init__.py new file mode 100644 index 0000000..7e54700 --- /dev/null +++ b/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/coupled_baiting/__init__.py @@ -0,0 +1,3 @@ +from .curriculum import CURRICULUM, CURRICULUM_NAME, PKG_LOCATION, TRAINER, run_curriculum + +__all__ = ["CURRICULUM_NAME", "CURRICULUM", "TRAINER", "PKG_LOCATION", "run_curriculum"] diff --git a/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/coupled_baiting/curriculum.py b/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/coupled_baiting/curriculum.py new file mode 100644 index 0000000..f8db8b3 --- /dev/null +++ b/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/coupled_baiting/curriculum.py @@ -0,0 +1,133 @@ +from typing import Any, Type, TypeVar + +import numpy as np +import pydantic +from aind_behavior_curriculum import Curriculum, Metrics, StageTransition, Trainer, TrainerState, create_curriculum +from aind_behavior_dynamic_foraging.task_logic import ( + AindDynamicForagingTaskLogic, +) + +from .. import __semver__ +from ..cli import CurriculumCliArgs, CurriculumSuggestion +from ..metrics import DynamicForagingMetrics +from ..utils import metrics_from_dataset_path, trainer_state_from_file +from .stages import ( + make_s_stage_1, + make_s_stage_1_warmup, + make_s_stage_2, + make_s_stage_3, + make_s_stage_final, + make_s_stage_graduated, +) + +CURRICULUM_NAME = "CoupledBaiting" +PKG_LOCATION = ".".join(__name__.split(".")[:-1]) + +TModel = TypeVar("TModel", bound=pydantic.BaseModel) + + +# --- STAGE TRANSITIONS --- + + +# warmup +@StageTransition +def st_stage_1_warmup_to_stage_1(metrics: DynamicForagingMetrics) -> bool: + return bool(metrics.consecutive_sessions_at_current_stage >= 1) + + +@StageTransition +def st_stage_1_warmup_to_stage_2(metrics: DynamicForagingMetrics) -> bool: + return bool(metrics.unignored_trials_per_session[-1] >= 200 and metrics.foraging_efficiency_per_session[-1] >= 0.6) + + +# stage 1 +@StageTransition +def st_stage_1_to_stage_2(metrics: DynamicForagingMetrics) -> bool: + return bool(metrics.foraging_efficiency_per_session[-1] >= 0.6 and metrics.unignored_trials_per_session[-1] >= 200) + + +# stage 2 +@StageTransition +def st_stage_2_to_stage_3(metrics: DynamicForagingMetrics) -> bool: + return bool(metrics.foraging_efficiency_per_session[-1] >= 0.65 and metrics.unignored_trials_per_session[-1] >= 300) + + +@StageTransition +def st_stage_2_to_stage_1(metrics: DynamicForagingMetrics) -> bool: + return bool(metrics.foraging_efficiency_per_session[-1] < 0.55 or metrics.unignored_trials_per_session[-1] < 200) + + +# stage 3 +@StageTransition +def st_stage_3_to_final(metrics: DynamicForagingMetrics) -> bool: + return bool(metrics.foraging_efficiency_per_session[-1] >= 0.7 and metrics.unignored_trials_per_session[-1] >= 400) + + +@StageTransition +def st_stage_3_to_stage_2(metrics: DynamicForagingMetrics) -> bool: + return bool(metrics.foraging_efficiency_per_session[-1] < 0.65 or metrics.unignored_trials_per_session[-1] < 300) + + +# stage final +@StageTransition +def st_final_to_graduated(metrics: DynamicForagingMetrics) -> bool: + return bool( + metrics.total_sessions >= 10 + and metrics.consecutive_sessions_at_current_stage >= 5 + and np.mean(metrics.unignored_trials_per_session[-5:]) >= 450 + and np.mean(metrics.foraging_efficiency_per_session[-5:]) >= 0.7 + ) + + +@StageTransition +def st_final_to_stage_3(metrics: DynamicForagingMetrics) -> bool: + return bool( + np.mean(metrics.foraging_efficiency_per_session[-5:]) < 0.60 + or np.mean(metrics.unignored_trials_per_session[-5:]) < 300 + ) + + +# --- CURRICULUM --- +curriculum_class: Type[Curriculum[AindDynamicForagingTaskLogic]] = create_curriculum( + CURRICULUM_NAME, __semver__, (AindDynamicForagingTaskLogic,), pkg_location=PKG_LOCATION +) +CURRICULUM = curriculum_class() + +# add stages +CURRICULUM.add_stage(make_s_stage_1_warmup()) +CURRICULUM.add_stage(make_s_stage_1()) +CURRICULUM.add_stage(make_s_stage_2()) +CURRICULUM.add_stage(make_s_stage_3()) +CURRICULUM.add_stage(make_s_stage_final()) +CURRICULUM.add_stage(make_s_stage_graduated()) + +# add stage transitions +# warmup +CURRICULUM.add_stage_transition( + make_s_stage_1_warmup(), make_s_stage_2(), st_stage_1_warmup_to_stage_2 +) # add 2 first to take priority + +CURRICULUM.add_stage_transition(make_s_stage_1_warmup(), make_s_stage_1(), st_stage_1_warmup_to_stage_1) +# stage 1 +CURRICULUM.add_stage_transition(make_s_stage_1(), make_s_stage_2(), st_stage_1_to_stage_2) +# stage 2 +CURRICULUM.add_stage_transition(make_s_stage_2(), make_s_stage_3(), st_stage_2_to_stage_3) +CURRICULUM.add_stage_transition(make_s_stage_2(), make_s_stage_1(), st_stage_2_to_stage_1) +# stage 3 +CURRICULUM.add_stage_transition(make_s_stage_3(), make_s_stage_final(), st_stage_3_to_final) +CURRICULUM.add_stage_transition(make_s_stage_3(), make_s_stage_2(), st_stage_3_to_stage_2) +# final +CURRICULUM.add_stage_transition(make_s_stage_final(), make_s_stage_graduated(), st_final_to_graduated) +CURRICULUM.add_stage_transition(make_s_stage_final(), make_s_stage_3(), st_final_to_stage_3) + +TRAINER = Trainer(CURRICULUM) + + +def run_curriculum(args: CurriculumCliArgs) -> CurriculumSuggestion[TrainerState[Any], Any]: + trainer_state = trainer_state_from_file(args.input_trainer_state, TRAINER) + metrics: Metrics = metrics_from_dataset_path( + dataset_path=args.data_directory, + trainer_state=trainer_state, + ) + trainer_state = TRAINER.evaluate(trainer_state, metrics) + return CurriculumSuggestion(trainer_state=trainer_state, metrics=metrics, version=__semver__) diff --git a/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/coupled_baiting/stages.py b/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/coupled_baiting/stages.py new file mode 100644 index 0000000..66ee583 --- /dev/null +++ b/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/coupled_baiting/stages.py @@ -0,0 +1,320 @@ +from aind_behavior_curriculum import MetricsProvider, Stage +from aind_behavior_dynamic_foraging.task_logic import ( + AindDynamicForagingTaskLogic, + AindDynamicForagingTaskParameters, + RewardSize, +) +from aind_behavior_dynamic_foraging.task_logic.trial_generators import ( + CoupledTrialGeneratorSpec, + TrialGeneratorCompositeSpec, + WarmupTrialGeneratorSpec, +) +from aind_behavior_dynamic_foraging.task_logic.trial_generators.block_based_trial_generator import ( + RewardProbabilityParameters, +) +from aind_behavior_dynamic_foraging.task_logic.trial_generators.coupled_trial_generator import ( + BehaviorStabilityParameters, + CoupledTrialGenerationEndConditions, +) +from aind_behavior_dynamic_foraging.task_logic.trial_generators.warmup_trial_generator import ( + WarmupTrialGenerationEndConditions, +) +from aind_behavior_services.task.distributions import ( + ExponentialDistribution, + ExponentialDistributionParameters, + Scalar, + TruncationParameters, +) + +from ..metrics import metrics_from_dataset + +# --- STAGES --- +# adapted from https://github.com/AllenNeuralDynamics/aind-foraging-behavior-bonsai-automatic-training/blob/main/code/aind_auto_train/curriculums/coupled_baiting_2p3.py + + +def make_s_stage_1_warmup(): + return Stage( + name="stage_1_warmup", + task=AindDynamicForagingTaskLogic( + task_parameters=AindDynamicForagingTaskParameters( + reward_size=RewardSize(right_value_volume=4.0, left_value_volume=4.0), + trial_generator=TrialGeneratorCompositeSpec( + generators=[ + WarmupTrialGeneratorSpec( + trial_generation_end_parameters=WarmupTrialGenerationEndConditions( + min_trial=50, + max_choice_bias=0.1, + min_response_rate=0.8, + evaluation_window=20, + ), + reward_probability_parameters=RewardProbabilityParameters( + base_reward_sum=1, reward_pairs=[[1.0, 0.0]] + ), + block_len=Scalar(value=1), + inter_trial_interval_duration=ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=1.0 / 3), + truncation_parameters=TruncationParameters(min=1, max=7), + ), + quiescent_duration=Scalar(value=0.1), + min_block_reward=1, + is_baiting=True, + response_duration=5.0, + reward_consumption_duration=1.0, + kernel_size=2, + extend_block_on_no_response=True, + ), + CoupledTrialGeneratorSpec( + trial_generation_end_parameters=CoupledTrialGenerationEndConditions( + max_trial=1000, + max_time=75, + min_time=30, + ignore_win=20000, + ignore_ratio_threshold=1, + ), + behavior_stability_parameters=BehaviorStabilityParameters( + behavior_evaluation_mode="end", + behavior_stability_fraction=0.5, + min_consecutive_stable_trials=5, + ), + reward_probability_parameters=RewardProbabilityParameters( + base_reward_sum=0.8, reward_pairs=[[1.0, 0.0]] + ), + block_len=ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=0.2), + truncation_parameters=TruncationParameters(min=10, max=20), + ), + inter_trial_interval_duration=ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=1.0 / 3), + truncation_parameters=TruncationParameters(min=1, max=7), + ), + quiescent_duration=Scalar(value=0.1), + min_block_reward=0, + is_baiting=True, + extend_block_on_no_response=True, + response_duration=5.0, + reward_consumption_duration=1.0, + kernel_size=2, + ), + ] + ), + ) + ), + metrics_provider=MetricsProvider(metrics_from_dataset), + ) + + +def make_s_stage_1(): + return Stage( + name="stage_1", + task=AindDynamicForagingTaskLogic( + task_parameters=AindDynamicForagingTaskParameters( + reward_size=RewardSize(right_value_volume=2.0, left_value_volume=2.0), + lick_spout_retraction=False, + trial_generator=CoupledTrialGeneratorSpec( + trial_generation_end_parameters=CoupledTrialGenerationEndConditions( + max_trial=1000, + max_time=75, + min_time=30, + ignore_win=20000, + ignore_ratio_threshold=1, + ), + behavior_stability_parameters=BehaviorStabilityParameters( + behavior_evaluation_mode="end", + behavior_stability_fraction=0.5, + min_consecutive_stable_trials=5, + ), + reward_probability_parameters=RewardProbabilityParameters( + base_reward_sum=0.8, reward_pairs=[[1.0, 0.0]] + ), + block_len=ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=0.2), + truncation_parameters=TruncationParameters(min=10, max=20), + ), + inter_trial_interval_duration=ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=1.0 / 3), + truncation_parameters=TruncationParameters(min=1, max=7), + ), + quiescent_duration=Scalar(value=0.1), + min_block_reward=0, + is_baiting=False, + extend_block_on_no_response=True, + response_duration=5.0, + reward_consumption_duration=1.0, + kernel_size=2, + ), + ) + ), + metrics_provider=MetricsProvider(metrics_from_dataset), + ) + + +def make_s_stage_2(): + return Stage( + name="stage_2", + task=AindDynamicForagingTaskLogic( + task_parameters=AindDynamicForagingTaskParameters( + reward_size=RewardSize(right_value_volume=2.0, left_value_volume=2.0), + lick_spout_retraction=False, + trial_generator=CoupledTrialGeneratorSpec( + trial_generation_end_parameters=CoupledTrialGenerationEndConditions( + max_trial=1000, + max_time=75, + min_time=30, + ignore_win=30, + ignore_ratio_threshold=0.83, + ), + behavior_stability_parameters=BehaviorStabilityParameters( + behavior_evaluation_mode="end", + behavior_stability_fraction=0.6, + min_consecutive_stable_trials=5, + ), + reward_probability_parameters=RewardProbabilityParameters( + base_reward_sum=0.6, reward_pairs=[[8, 1]] + ), + block_len=ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=0.1), + truncation_parameters=TruncationParameters(min=10, max=40), + ), + inter_trial_interval_duration=ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=0.2), + truncation_parameters=TruncationParameters(min=1, max=10), + ), + quiescent_duration=Scalar(value=0.3), + min_block_reward=0, + is_baiting=True, + extend_block_on_no_response=True, + response_duration=3.0, + reward_consumption_duration=1.0, + kernel_size=2, + ), + ) + ), + metrics_provider=MetricsProvider(metrics_from_dataset), + ) + + +def make_s_stage_3(): + return Stage( + name="stage_3", + task=AindDynamicForagingTaskLogic( + task_parameters=AindDynamicForagingTaskParameters( + reward_size=RewardSize(right_value_volume=2.0, left_value_volume=2.0), + lick_spout_retraction=False, + trial_generator=CoupledTrialGeneratorSpec( + trial_generation_end_parameters=CoupledTrialGenerationEndConditions( + max_trial=1000, + max_time=75, + min_time=30, + ignore_win=30, + ignore_ratio_threshold=0.83, + ), + behavior_stability_parameters=BehaviorStabilityParameters( + behavior_evaluation_mode="end", + behavior_stability_fraction=0.6, + min_consecutive_stable_trials=5, + ), + reward_probability_parameters=RewardProbabilityParameters( + base_reward_sum=0.45, reward_pairs=[[8, 1]] + ), + block_len=ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=0.05), + truncation_parameters=TruncationParameters(min=20, max=60), + ), + inter_trial_interval_duration=ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=1.0 / 3), + truncation_parameters=TruncationParameters(min=1, max=15), + ), + quiescent_duration=Scalar(value=0.5), + min_block_reward=0, + is_baiting=True, + extend_block_on_no_response=True, + response_duration=2.0, + reward_consumption_duration=1.0, + kernel_size=2, + ), + ) + ), + metrics_provider=MetricsProvider(metrics_from_dataset), + ) + + +def make_s_stage_final(): + return Stage( + name="final", + task=AindDynamicForagingTaskLogic( + task_parameters=AindDynamicForagingTaskParameters( + reward_size=RewardSize(right_value_volume=2.0, left_value_volume=2.0), + lick_spout_retraction=False, + trial_generator=CoupledTrialGeneratorSpec( + trial_generation_end_parameters=CoupledTrialGenerationEndConditions( + max_trial=1000, + max_time=75, + min_time=30, + ignore_win=30, + ignore_ratio_threshold=0.83, + ), + behavior_stability_parameters=None, + reward_probability_parameters=RewardProbabilityParameters( + base_reward_sum=0.45, reward_pairs=[[8, 1], [6, 1], [3, 1], [1, 1]] + ), + block_len=ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=0.05), + truncation_parameters=TruncationParameters(min=20, max=60), + ), + inter_trial_interval_duration=ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=1.0 / 3), + truncation_parameters=TruncationParameters(min=1, max=30), + ), + quiescent_duration=Scalar(value=1), + min_block_reward=0, + is_baiting=True, + extend_block_on_no_response=True, + response_duration=1.0, + reward_consumption_duration=3.0, + kernel_size=2, + ), + ) + ), + metrics_provider=MetricsProvider(metrics_from_dataset), + ) + + +def make_s_stage_graduated(): + return Stage( + name="graduated", + task=AindDynamicForagingTaskLogic( + task_parameters=AindDynamicForagingTaskParameters( + reward_size=RewardSize(right_value_volume=2.0, left_value_volume=2.0), + lick_spout_retraction=False, + trial_generator=CoupledTrialGeneratorSpec( + trial_generation_end_parameters=CoupledTrialGenerationEndConditions( + max_trial=1000, + max_time=75, + min_time=30, + ignore_win=30, + ignore_ratio_threshold=0.83, + ), + behavior_stability_parameters=None, + reward_probability_parameters=RewardProbabilityParameters( + base_reward_sum=0.45, reward_pairs=[[8, 1], [6, 1], [3, 1], [1, 1]] + ), + block_len=ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=0.05), + truncation_parameters=TruncationParameters(min=20, max=60), + ), + inter_trial_interval_duration=ExponentialDistribution( + distribution_parameters=ExponentialDistributionParameters(rate=1.0 / 3), + truncation_parameters=TruncationParameters(min=1, max=30), + ), + quiescent_duration=Scalar(value=1), + min_block_reward=0, + is_baiting=True, + extend_block_on_no_response=True, + response_duration=1.0, + reward_consumption_duration=3.0, + kernel_size=2, + ), + ) + ), + metrics_provider=MetricsProvider(metrics_from_dataset), + ) diff --git a/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/metrics.py b/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/metrics.py new file mode 100644 index 0000000..76f0922 --- /dev/null +++ b/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/metrics.py @@ -0,0 +1,158 @@ +import logging +import os +from typing import List, Literal + +import numpy as np +from aind_behavior_curriculum import Metrics +from aind_behavior_dynamic_foraging.data_contract import dataset as df_foraging_dataset +from pydantic import Field + +STAGE_NAMES = Literal["stage_1_warmup", "stage_1", "stage_2", "stage_3", "final", "graduated"] + +logger = logging.getLogger(__name__) + + +class DynamicForagingMetrics(Metrics): + """Metrics for dynamic foraging""" + + foraging_efficiency_per_session: List[float] = Field( + min_length=1, description="Full history of foraging efficiency per session" + ) + unignored_trials_per_session: List[int] = Field( + min_length=1, description="Full history of trials finished per session" + ) + total_sessions: int = Field(ge=0, description="Total sessions completed.") + consecutive_sessions_at_current_stage: int = Field(ge=0, description="Last consecutive sessions at current stage.") + stage_name: STAGE_NAMES = Field(description="Stage name of session.") + + +def metrics_from_dataset( + data_directory: os.PathLike, +) -> DynamicForagingMetrics: + """ + Create metrics for completed session. + + Args: + data_directory (os.PathLike): + Path to the directory containing the dataset to analyze. This + directory is expected to include all required behavioral data files. Also includes metrics and trainer state + + Returns: + DynamicForagingMetrics: + Metrics for session + + Raises: + FileNotFoundError: + If the specified data directory or required files do not exist. + + ValueError: + If the dataset is malformed or missing required fields for + computing metrics. + """ + + dataset = df_foraging_dataset(data_directory) + software_events = dataset["Behavior"]["SoftwareEvents"] + software_events.load_all() + + trial_generator_spec = software_events["TrialGeneratorSpec"].data["data"].iloc[-1] + is_baiting = trial_generator_spec.get("trial_generator_spec", False) + trial_outcomes = software_events["TrialOutcome"].data["data"].iloc + # exclude auto response and ignored trials + filtered = [ + t for t in trial_outcomes if t["is_right_choice"] is not None and not t["trial"]["is_auto_response_right"] + ] + is_right_choice = [to["is_right_choice"] for to in filtered] + is_rewarded = [to["is_rewarded"] for to in filtered] + p_right_reward = [to["trial"]["p_reward_right"] for to in filtered] + p_left_reward = [to["trial"]["p_reward_left"] for to in filtered] + foraging_efficiency = compute_foraging_efficiency( + is_baiting=is_baiting, is_rewarded=is_rewarded, p_left_reward=p_left_reward, p_right_reward=p_right_reward + ) + logger.debug(f"Calculated foraging efficiency as {foraging_efficiency}") + + try: + prev_metrics = DynamicForagingMetrics(**dataset["Behavior"]["PreviousMetrics"].data) + prev_stage = prev_metrics.stage_name + except FileNotFoundError: + logger.info("No previous metrics found.") + prev_metrics = None + prev_stage = None + + foraging_efficiency_per_session = [] if not prev_metrics else prev_metrics.foraging_efficiency_per_session + unignored_trials_per_session = [] if not prev_metrics else prev_metrics.unignored_trials_per_session + total_sessions = 0 if not prev_metrics else prev_metrics.total_sessions + stage_name = dataset["Behavior"]["TrainerState"].data.stage.name + consecutive_sessions_at_current_stage = ( + 0 if not prev_metrics or stage_name != prev_stage else prev_metrics.consecutive_sessions_at_current_stage + ) + + return DynamicForagingMetrics( + foraging_efficiency_per_session=foraging_efficiency_per_session + [foraging_efficiency], + unignored_trials_per_session=unignored_trials_per_session + [sum(x is not None for x in is_right_choice)], + total_sessions=total_sessions + 1, + consecutive_sessions_at_current_stage=consecutive_sessions_at_current_stage + 1, + stage_name=stage_name, + ) + + +def compute_foraging_efficiency( + is_baiting: bool, is_rewarded: list[bool], p_right_reward: list[float], p_left_reward: list[float] +) -> float: + """ + Compute foraging efficiency for a two-arm bandit task. + + This function calculates the ratio of actual rewards obtained to the + optimal expected rewards for a session. The implementation is adapted from the Allen Institute dynamic foraging + analysis codebase. + + Args: + is_baiting (bool): + Whether the task uses a baiting schedule. If True, rewards can + accumulate on unchosen options; if False, rewards are independent + per trial. + + is_rewarded (list[bool | None]): + List indicating whether each trial resulted in a reward. `True` + indicates a rewarded trial, `False` indicates no reward. + + p_right_reward (list[float]): + Probability of reward for the right option on each trial. + + p_left_reward (list[float]): + Probability of reward for the left option on each trial. + + Returns: + float: + Foraging efficiency, defined as the ratio of the number of + rewarded trials to the optimal expected number of rewards for + the session. + + Raises: + ValueError: + If input lists have mismatched lengths. + + Notes: + Adapted from: + https://github.com/AllenNeuralDynamics/aind-dynamic-foraging-basic-analysis/blob/main/src/aind_dynamic_foraging_basic_analysis/metrics/foraging_efficiency.py + """ + + if not is_baiting: + logger.debug("Calculated non baiting foraging efficiency.") + optimal_rewards_per_session = np.nanmean(np.max([p_right_reward], axis=0)) * len(p_left_reward) + else: + logger.debug("Calculated baiting foraging efficiency.") + p_max = np.maximum(p_left_reward, p_right_reward) + p_min = np.minimum(p_left_reward, p_right_reward) + + with np.errstate(divide="ignore", invalid="ignore"): + optimal_visit_ratio = np.floor(np.log(1 - p_max) / np.log(1 - p_min)) + optimal_general_reward_rates = p_max + (1 - (1 - p_min) ** (optimal_visit_ratio + 1) - p_max**2) / ( + optimal_visit_ratio + 1 + ) + + simple_case = (p_min == 0) | (p_max >= 1) + optimal_reward_per_trial = np.where(simple_case, p_max, optimal_general_reward_rates) + + optimal_rewards_per_session = np.nanmean(optimal_reward_per_trial) * len(p_left_reward) + + return float(is_rewarded.count(True) / optimal_rewards_per_session) diff --git a/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/utils.py b/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/utils.py new file mode 100644 index 0000000..a6fa4d9 --- /dev/null +++ b/workspace/aind_behavior_dynamic_foraging_curricula/src/aind_behavior_dynamic_foraging_curricula/utils.py @@ -0,0 +1,31 @@ +import os +from pathlib import Path +from typing import Any, TypeVar + +import pydantic +from aind_behavior_curriculum import Curriculum, Metrics, Trainer, TrainerState + +TModel = TypeVar("TModel", bound=pydantic.BaseModel) +TCurriculum = TypeVar("TCurriculum", bound=Curriculum) + + +def model_from_json_file(json_path: os.PathLike | str, model: type[TModel]) -> TModel: + with open(Path(json_path), "r", encoding="utf-8") as file: + return model.model_validate_json(file.read()) + + +def trainer_state_from_file(path: str | os.PathLike, trainer: Trainer[TCurriculum]) -> TrainerState[TCurriculum]: + return model_from_json_file(path, trainer.trainer_state_model) + + +def metrics_from_dataset_path( + dataset_path: str | os.PathLike, + trainer_state: TrainerState[Any], +) -> Metrics: + stage = trainer_state.stage + if stage is None: + raise ValueError("Trainer state does not have a stage") + if stage.metrics_provider is None: + raise ValueError("Stage does not have a metrics provider") + metrics_provider = stage.metrics_provider + return metrics_provider.callable(dataset_path) diff --git a/workspace/aind_behavior_dynamic_foraging_curricula/tests/test_coupled_baiting.py b/workspace/aind_behavior_dynamic_foraging_curricula/tests/test_coupled_baiting.py new file mode 100644 index 0000000..2a80908 --- /dev/null +++ b/workspace/aind_behavior_dynamic_foraging_curricula/tests/test_coupled_baiting.py @@ -0,0 +1,205 @@ +import unittest + +from aind_behavior_dynamic_foraging_curricula.coupled_baiting import CURRICULUM, TRAINER +from aind_behavior_dynamic_foraging_curricula.coupled_baiting.stages import ( + make_s_stage_1, + make_s_stage_1_warmup, + make_s_stage_2, + make_s_stage_3, + make_s_stage_final, + make_s_stage_graduated, +) +from aind_behavior_dynamic_foraging_curricula.metrics import DynamicForagingMetrics + + +def make_metrics( + foraging_efficiency_per_session: list[float] = None, + unignored_trials_per_session: list[int] = None, + total_sessions: int = 1, + consecutive_sessions_at_current_stage: int = 1, + stage_name: str = "stage_1_warmup", +) -> DynamicForagingMetrics: + return DynamicForagingMetrics( + foraging_efficiency_per_session=foraging_efficiency_per_session or [0.0], + unignored_trials_per_session=unignored_trials_per_session or [0], + total_sessions=total_sessions, + consecutive_sessions_at_current_stage=consecutive_sessions_at_current_stage, + stage_name=stage_name, + ) + + +class TestCurriculumStructure(unittest.TestCase): + def test_all_stages_in_curriculum(self): + stages = CURRICULUM.see_stages() + stage_names = [s.name for s in stages] + self.assertIn("stage_1_warmup", stage_names) + self.assertIn("stage_1", stage_names) + self.assertIn("stage_2", stage_names) + self.assertIn("stage_3", stage_names) + self.assertIn("final", stage_names) + self.assertIn("graduated", stage_names) + + def test_enrollment_starts_at_stage_1_warmup(self): + trainer_state = TRAINER.create_enrollment() + self.assertEqual(trainer_state.stage.name, "stage_1_warmup") + + +class TestWarmupTransitions(unittest.TestCase): + def setUp(self): + self.trainer_state = TRAINER.create_trainer_state(stage=make_s_stage_1_warmup()) + + def test_warmup_to_stage_2_on_good_performance(self): + metrics = make_metrics( + unignored_trials_per_session=[250], foraging_efficiency_per_session=[0.65], stage_name="stage_1_warmup" + ) + updated = TRAINER.evaluate(self.trainer_state, metrics) + self.assertEqual(updated.stage.name, "stage_2") + + def test_warmup_to_stage_1_after_first_session(self): + metrics = make_metrics( + unignored_trials_per_session=[100], + foraging_efficiency_per_session=[0.4], + consecutive_sessions_at_current_stage=1, + stage_name="stage_1_warmup", + ) + updated = TRAINER.evaluate(self.trainer_state, metrics) + self.assertEqual(updated.stage.name, "stage_1") + + +class TestStage1Transitions(unittest.TestCase): + def setUp(self): + self.trainer_state = TRAINER.create_trainer_state(stage=make_s_stage_1()) + + def test_stage_1_to_stage_2_on_good_performance(self): + metrics = make_metrics( + unignored_trials_per_session=[200], foraging_efficiency_per_session=[0.6], stage_name="stage_1" + ) + updated = TRAINER.evaluate(self.trainer_state, metrics) + self.assertEqual(updated.stage.name, "stage_2") + + def test_stage_1_no_transition_on_poor_performance(self): + metrics = make_metrics( + unignored_trials_per_session=[100], foraging_efficiency_per_session=[0.4], stage_name="stage_1" + ) + updated = TRAINER.evaluate(self.trainer_state, metrics) + self.assertEqual(updated.stage.name, "stage_1") + + +class TestStage2Transitions(unittest.TestCase): + def setUp(self): + self.trainer_state = TRAINER.create_trainer_state(stage=make_s_stage_2()) + + def test_stage_2_to_stage_3_on_good_performance(self): + metrics = make_metrics( + unignored_trials_per_session=[300], foraging_efficiency_per_session=[0.65], stage_name="stage_2" + ) + updated = TRAINER.evaluate(self.trainer_state, metrics) + self.assertEqual(updated.stage.name, "stage_3") + + def test_stage_2_rollback_to_stage_1_on_poor_trials(self): + metrics = make_metrics( + unignored_trials_per_session=[150], foraging_efficiency_per_session=[0.6], stage_name="stage_2" + ) + updated = TRAINER.evaluate(self.trainer_state, metrics) + self.assertEqual(updated.stage.name, "stage_1") + + def test_stage_2_rollback_to_stage_1_on_poor_efficiency(self): + metrics = make_metrics( + unignored_trials_per_session=[199], foraging_efficiency_per_session=[0.5], stage_name="stage_2" + ) + updated = TRAINER.evaluate(self.trainer_state, metrics) + self.assertEqual(updated.stage.name, "stage_1") + + def test_stage_2_no_transition_on_middle_performance(self): + metrics = make_metrics( + unignored_trials_per_session=[250], foraging_efficiency_per_session=[0.6], stage_name="stage_2" + ) + updated = TRAINER.evaluate(self.trainer_state, metrics) + self.assertEqual(updated.stage.name, "stage_2") + + +class TestStage3Transitions(unittest.TestCase): + def setUp(self): + self.trainer_state = TRAINER.create_trainer_state(stage=make_s_stage_3()) + + def test_stage_3_to_final_on_good_performance(self): + metrics = make_metrics( + unignored_trials_per_session=[400], foraging_efficiency_per_session=[0.7], stage_name="stage_3" + ) + updated = TRAINER.evaluate(self.trainer_state, metrics) + self.assertEqual(updated.stage.name, "final") + + def test_stage_3_rollback_to_stage_2_on_poor_trials(self): + metrics = make_metrics( + unignored_trials_per_session=[250], foraging_efficiency_per_session=[0.7], stage_name="stage_3" + ) + updated = TRAINER.evaluate(self.trainer_state, metrics) + self.assertEqual(updated.stage.name, "stage_2") + + def test_stage_3_rollback_to_stage_2_on_poor_efficiency(self): + metrics = make_metrics( + unignored_trials_per_session=[299], foraging_efficiency_per_session=[0.6], stage_name="stage_3" + ) + updated = TRAINER.evaluate(self.trainer_state, metrics) + self.assertEqual(updated.stage.name, "stage_2") + + def test_stage_3_no_transition_on_middle_performance(self): + metrics = make_metrics( + unignored_trials_per_session=[350], foraging_efficiency_per_session=[0.67], stage_name="stage_3" + ) + updated = TRAINER.evaluate(self.trainer_state, metrics) + self.assertEqual(updated.stage.name, "stage_3") + + +class TestFinalTransitions(unittest.TestCase): + def setUp(self): + self.trainer_state = TRAINER.create_trainer_state(stage=make_s_stage_final()) + + def test_final_to_graduated_on_excellent_performance(self): + metrics = make_metrics( + unignored_trials_per_session=[450] * 5, + foraging_efficiency_per_session=[0.70] * 5, + total_sessions=10, + consecutive_sessions_at_current_stage=5, + stage_name="final", + ) + updated = TRAINER.evaluate(self.trainer_state, metrics) + self.assertEqual(updated.stage.name, "graduated") + + def test_final_rollback_to_stage_3_on_poor_performance(self): + metrics = make_metrics( + unignored_trials_per_session=[250] * 5, + foraging_efficiency_per_session=[0.55] * 5, + total_sessions=10, + consecutive_sessions_at_current_stage=5, + stage_name="final", + ) + updated = TRAINER.evaluate(self.trainer_state, metrics) + self.assertEqual(updated.stage.name, "stage_3") + + def test_final_no_graduation_without_enough_sessions(self): + metrics = make_metrics( + unignored_trials_per_session=[450] * 5, + foraging_efficiency_per_session=[0.70] * 5, + total_sessions=5, + consecutive_sessions_at_current_stage=3, + stage_name="final", + ) + updated = TRAINER.evaluate(self.trainer_state, metrics) + self.assertNotEqual(updated.stage.name, "graduated") + + def test_graduated_is_absorbing(self): + trainer_state = TRAINER.create_trainer_state(stage=make_s_stage_graduated()) + metrics = make_metrics( + unignored_trials_per_session=[500] * 5, + foraging_efficiency_per_session=[0.9] * 5, + total_sessions=20, + consecutive_sessions_at_current_stage=10, + stage_name="final", + ) + updated = TRAINER.evaluate(trainer_state, metrics) + self.assertEqual(updated.stage.name, "graduated") + + +if __name__ == "__main__": + unittest.main() diff --git a/workspace/aind_behavior_dynamic_foraging_curricula/tests/test_metrics.py b/workspace/aind_behavior_dynamic_foraging_curricula/tests/test_metrics.py new file mode 100644 index 0000000..43933fd --- /dev/null +++ b/workspace/aind_behavior_dynamic_foraging_curricula/tests/test_metrics.py @@ -0,0 +1,127 @@ +import unittest +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, PropertyMock, patch + +import numpy as np + +from aind_behavior_dynamic_foraging_curricula.metrics import ( + metrics_from_dataset, +) + + +def _make_trial( + is_right_choice: Optional[bool], + is_rewarded: bool, + p_reward_right: float, + p_reward_left: float, + is_auto_response_right: Optional[bool] = False, +) -> dict: + return { + "is_right_choice": is_right_choice, + "is_rewarded": is_rewarded, + "trial": { + "p_reward_right": p_reward_right, + "p_reward_left": p_reward_left, + "is_auto_response_right": is_auto_response_right, + }, + } + + +def _patch_dataset( + trials: list[dict], is_baiting: bool = True, prev_metrics: Optional[dict] = None, stage_name: str = "stage_1" +): + """Patch df_foraging_dataset with a mock matching the access pattern in metrics_from_dataset.""" + + # software events + mock_trial_spec = MagicMock() + mock_trial_spec.data = {"data": MagicMock()} + mock_trial_spec.data["data"].iloc.__getitem__ = MagicMock(return_value={"is_baiting": is_baiting}) + mock_trial_spec.data["data"].iloc[-1] = {"is_baiting": is_baiting} + + # trial outcomes + mock_trial_outcome = MagicMock() + mock_trial_outcome.data = {"data": MagicMock(iloc=trials)} + + # trial generator + mock_software_events = MagicMock() + mock_software_events.__getitem__ = MagicMock( + side_effect=lambda key: mock_trial_spec if key == "TrialGeneratorSpec" else mock_trial_outcome + ) + + # trainer state + mock_trainer_state = MagicMock() + mock_trainer_state.data = {"stage": {"name": stage_name}} + + # previous metrics + mock_previous_metrics = MagicMock() + if prev_metrics is None: + type(mock_previous_metrics).data = PropertyMock(side_effect=FileNotFoundError) + else: + mock_previous_metrics.data = prev_metrics + + mock_behavior = MagicMock() + mock_behavior.__getitem__ = MagicMock( + side_effect=lambda key: { + "SoftwareEvents": mock_software_events, + "TrainerState": mock_trainer_state, + "PreviousMetrics": mock_previous_metrics, + }[key] + ) + + mock_dataset = MagicMock() + mock_dataset.__getitem__ = MagicMock(return_value=mock_behavior) + return patch( + "aind_behavior_dynamic_foraging_curricula.metrics.df_foraging_dataset", + return_value=mock_dataset, + ) + + +class TestMetricsFromDataset(unittest.TestCase): + def setUp(self): + import tempfile + + self.tmp_dir = tempfile.mkdtemp() + self.tmp_path = Path(self.tmp_dir) + + def test_no_previous_metrics_total_sessions_is_one(self): + trials = [_make_trial(True, True, 0.7, 0.3)] + with _patch_dataset(trials): + result = metrics_from_dataset(self.tmp_path) + self.assertEqual(result.total_sessions, 1) + + def test_no_previous_metrics_consecutive_sessions_is_one(self): + trials = [_make_trial(True, True, 0.7, 0.3)] + with _patch_dataset(trials): + result = metrics_from_dataset(self.tmp_path) + self.assertEqual(result.consecutive_sessions_at_current_stage, 1) + + def test_previous_metrics_accumulate(self): + trials = [_make_trial(True, True, 0.7, 0.3)] + metrics = { + "foraging_efficiency_per_session": [0.5], + "unignored_trials_per_session": [10], + "total_sessions": 1, + "consecutive_sessions_at_current_stage": 1, + "stage_name": "stage_1_warmup", + } + with _patch_dataset(trials, prev_metrics=metrics): + result = metrics_from_dataset(self.tmp_path) + self.assertEqual(result.total_sessions, 2) + self.assertEqual(len(result.foraging_efficiency_per_session), 2) + self.assertEqual(len(result.unignored_trials_per_session), 2) + + def test_foraging_efficiency_is_finite_and_positive(self): + trials = [ + _make_trial(True, True, 0.7, 0.3), + _make_trial(True, False, 0.7, 0.3), + _make_trial(False, True, 0.7, 0.3), + ] + with _patch_dataset(trials): + result = metrics_from_dataset(self.tmp_path) + self.assertGreater(result.foraging_efficiency_per_session[-1], 0) + self.assertTrue(np.isfinite(result.foraging_efficiency_per_session[-1])) + + +if __name__ == "__main__": + unittest.main()