diff --git a/containers/test-template/Dockerfile b/containers/test-template/Dockerfile index 52372877..b5099456 100644 --- a/containers/test-template/Dockerfile +++ b/containers/test-template/Dockerfile @@ -2,8 +2,9 @@ ARG PYTHON_VERSION=3.11 FROM python:${PYTHON_VERSION}-slim # Install and uninstall snakebids to cache it and it's dependences -RUN apt-get update && apt-get install -y gcc && \ +RUN apt-get update && apt-get install -y gcc git && \ rm -rf /var/lib/apt/lists/* && \ + git config --global --add safe.directory /src && \ python -m pip install pipx && \ pipx install poetry && \ pipx install hatch && \ diff --git a/containers/test-template/test-template.sh b/containers/test-template/test-template.sh index 14d44733..f2b2a84c 100755 --- a/containers/test-template/test-template.sh +++ b/containers/test-template/test-template.sh @@ -11,6 +11,9 @@ case "$method" in "setuptools" ) python -m venv .venv .venv/bin/python -m pip install . + if [ -d /src ]; then + .venv/bin/python -m pip install /src + fi PATH=".venv/bin:$PATH" eval "$script" ;; "poetry" ) diff --git a/docs/bids_app/config.md b/docs/bids_app/config.md index fe25c8fa..8e2c45a5 100644 --- a/docs/bids_app/config.md +++ b/docs/bids_app/config.md @@ -1,16 +1,16 @@ {#bids-app-config} -Configuration -============= +# Configuration Snakebids is configured with a YAML (or JSON) file that extends the standard [snakemake config file](https://snakemake.readthedocs.io/en/stable/snakefiles/configuration.html#standard-configuration) with variables that snakebids uses to parse an input BIDS dataset and expose the snakebids workflow to the command line. -Config Variables ----------------- +## Config Variables ### `pybids_inputs` A dictionary that describes each type of input you want to grab from an input BIDS dataset. Snakebids will parse your dataset with {func}`generate_inputs() `, converting each input type into a {class}`BidsComponent `. The value of each item should be a dictionary with keys `filters` and `wildcards`. +#### Filters + The value of `filters` should be a dictionary where each key corresponds to a BIDS entity, and the value specifies which values of that entity should be grabbed. The dictionary for each input is sent to the [PyBIDS' `get()` function ](#bids.layout.BIDSLayout). `filters` can be set according to a few different formats: * [`string`](#str): specifies an exact value for the entity. In the following example: @@ -26,10 +26,10 @@ The value of `filters` should be a dictionary where each key corresponds to a BI the bold component would match any paths under the `func/` datatype folder, with the suffix `bold` and the extension `.nii.gz`. ``` - sub-xxx/.../func/ent1-xxx_ent2-xxx_..._bold.nii.gz + sub-xxx/.../func/sub-xxx_ses-xxx_..._bold.nii.gz ``` -* [`boolean`](#bool): constrains presence or absence of the entity without restricting its value. `False` requires that the entity be **absent**, while `True` requires that the entity be **present**, regardless of value. +* [`boolean`](#bool): constrains presence or absence of the entity without restricting its value. `False` requires that the entity be **absent**, while `True` requires the entity to be **present**, regardless of value. ```yaml pybids_inputs: derivs: @@ -38,7 +38,74 @@ The value of `filters` should be a dictionary where each key corresponds to a BI desc: True acquisition: False ``` - The above example maps all paths in the `func/` datatype folder that have a `_desc-` entity but do not have the `_acq-` entity. + The above example selects all paths in the `func/` datatype folder that have a `_desc-` entity but do not have the `_acq-` entity. + +* [`list`](#list): Specify multiple string or boolean filters. Any path matching any one of the filters will be selected. Using `False` as one of the filters allows the entity to optionally be absent in addition to matching one of the string filters. Using `True` along with text is redundant, as `True` will cause any value to be selected. Using `True` with `False` is equivalent to not providing the filter at all. + + These filters: + + ```yaml + pybids_inputs: + derivs: + filters: + acquisition: + - False + - MPRAGE + - MP2RAGE + ``` + + would select all of the following paths: + + ``` + sub-001/ses-1/anat/sub-001_ses-001_acq-MPRAGE_run-1_T1w.nii.gz + sub-001/ses-1/anat/sub-001_ses-001_acq-MP2RAGE_run-1_T1w.nii.gz + sub-001/ses-1/anat/sub-001_ses-001_run-1_T1w.nii.gz + ``` + + +* To use regex for filtering, use an additional subkey set either to [`match`](#re.match) or [`search`](#re.search), depending on which regex method you wish to use. This key may be set to any one of the above items (`str`, `bool`, or `list`). Only one such key may be used. + + These filters: + + ```yaml + pybids_inputs: + derivs: + filters: + suffix: + search: '[Tt]1' + acquisition: + match: MP2?RAGE + ``` + + would select all of the following paths: + + ``` + sub-001/ses-1/anat/sub-001_ses-001_acq-MPRAGE_run-1_T1.nii.gz + sub-001/ses-1/anat/sub-001_ses-001_acq-MP2RAGE_run-1_t1w.nii.gz + sub-001/ses-1/anat/sub-001_ses-001_acq-MPRAGE_run-1_qT1w.nii.gz + ``` + +````{note} +`match` and `search` are both _filtering methods_. In addition to these, `get` is also a valid filtering method and may be used as the subkey for a filter. However, this is equivalent to directly providing the desired filter without a subkey: + +```yaml +pybids_inputs: + derivs: + filters: + suffix: + get: T1w + +# is the same as +pybids_inputs: + derivs: + filters: + suffix: T1w +``` + +In other words, `get` is the default filtering method. +```` + +#### Wildcards The value of `wildcards` should be a list of BIDS entities. Snakebids collects the values of any entities specified and saves them in the {attr}`entities ` and {attr}`~snakebids.BidsComponent.zip_lists` entries of the corresponding {class}`BidsComponent `. In other words, these are the entities to be preserved in output paths derived from the input being described. Placing an entity in `wildcards` does not require the entity be present. If an entity is not found, it will be left out of {attr}`entities `. To require the presence of an entity, place it under `filters` set to `true`. diff --git a/poetry.lock b/poetry.lock index 4ff6366c..873560ba 100644 --- a/poetry.lock +++ b/poetry.lock @@ -354,63 +354,63 @@ typing-extensions = {version = ">=3.7.4,<5.0.0", markers = "python_version < \"3 [[package]] name = "coverage" -version = "7.4.0" +version = "7.4.1" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a"}, - {file = "coverage-7.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43"}, - {file = "coverage-7.4.0-cp310-cp310-win32.whl", hash = "sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451"}, - {file = "coverage-7.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137"}, - {file = "coverage-7.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca"}, - {file = "coverage-7.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26"}, - {file = "coverage-7.4.0-cp311-cp311-win32.whl", hash = "sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614"}, - {file = "coverage-7.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590"}, - {file = "coverage-7.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143"}, - {file = "coverage-7.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa"}, - {file = "coverage-7.4.0-cp312-cp312-win32.whl", hash = "sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450"}, - {file = "coverage-7.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0"}, - {file = "coverage-7.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9d1bf53c4c8de58d22e0e956a79a5b37f754ed1ffdbf1a260d9dcfa2d8a325e"}, - {file = "coverage-7.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:109f5985182b6b81fe33323ab4707011875198c41964f014579cf82cebf2bb85"}, - {file = "coverage-7.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc9d4bc55de8003663ec94c2f215d12d42ceea128da8f0f4036235a119c88ac"}, - {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc6d65b21c219ec2072c1293c505cf36e4e913a3f936d80028993dd73c7906b1"}, - {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a10a4920def78bbfff4eff8a05c51be03e42f1c3735be42d851f199144897ba"}, - {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b8e99f06160602bc64da35158bb76c73522a4010f0649be44a4e167ff8555952"}, - {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7d360587e64d006402b7116623cebf9d48893329ef035278969fa3bbf75b697e"}, - {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:29f3abe810930311c0b5d1a7140f6395369c3db1be68345638c33eec07535105"}, - {file = "coverage-7.4.0-cp38-cp38-win32.whl", hash = "sha256:5040148f4ec43644702e7b16ca864c5314ccb8ee0751ef617d49aa0e2d6bf4f2"}, - {file = "coverage-7.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9864463c1c2f9cb3b5db2cf1ff475eed2f0b4285c2aaf4d357b69959941aa555"}, - {file = "coverage-7.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:936d38794044b26c99d3dd004d8af0035ac535b92090f7f2bb5aa9c8e2f5cd42"}, - {file = "coverage-7.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:799c8f873794a08cdf216aa5d0531c6a3747793b70c53f70e98259720a6fe2d7"}, - {file = "coverage-7.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7defbb9737274023e2d7af02cac77043c86ce88a907c58f42b580a97d5bcca9"}, - {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1526d265743fb49363974b7aa8d5899ff64ee07df47dd8d3e37dcc0818f09ed"}, - {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf635a52fc1ea401baf88843ae8708591aa4adff875e5c23220de43b1ccf575c"}, - {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:756ded44f47f330666843b5781be126ab57bb57c22adbb07d83f6b519783b870"}, - {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0eb3c2f32dabe3a4aaf6441dde94f35687224dfd7eb2a7f47f3fd9428e421058"}, - {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfd5db349d15c08311702611f3dccbef4b4e2ec148fcc636cf8739519b4a5c0f"}, - {file = "coverage-7.4.0-cp39-cp39-win32.whl", hash = "sha256:53d7d9158ee03956e0eadac38dfa1ec8068431ef8058fe6447043db1fb40d932"}, - {file = "coverage-7.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:cfd2a8b6b0d8e66e944d47cdec2f47c48fef2ba2f2dff5a9a75757f64172857e"}, - {file = "coverage-7.4.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6"}, - {file = "coverage-7.4.0.tar.gz", hash = "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e"}, + {file = "coverage-7.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7"}, + {file = "coverage-7.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61"}, + {file = "coverage-7.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee"}, + {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25"}, + {file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19"}, + {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630"}, + {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c"}, + {file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b"}, + {file = "coverage-7.4.1-cp310-cp310-win32.whl", hash = "sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016"}, + {file = "coverage-7.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018"}, + {file = "coverage-7.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295"}, + {file = "coverage-7.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c"}, + {file = "coverage-7.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676"}, + {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd"}, + {file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011"}, + {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74"}, + {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1"}, + {file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6"}, + {file = "coverage-7.4.1-cp311-cp311-win32.whl", hash = "sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5"}, + {file = "coverage-7.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968"}, + {file = "coverage-7.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581"}, + {file = "coverage-7.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6"}, + {file = "coverage-7.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66"}, + {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156"}, + {file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3"}, + {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1"}, + {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1"}, + {file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc"}, + {file = "coverage-7.4.1-cp312-cp312-win32.whl", hash = "sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74"}, + {file = "coverage-7.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448"}, + {file = "coverage-7.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218"}, + {file = "coverage-7.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45"}, + {file = "coverage-7.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d"}, + {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06"}, + {file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766"}, + {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75"}, + {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60"}, + {file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad"}, + {file = "coverage-7.4.1-cp38-cp38-win32.whl", hash = "sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042"}, + {file = "coverage-7.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d"}, + {file = "coverage-7.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54"}, + {file = "coverage-7.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70"}, + {file = "coverage-7.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628"}, + {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950"}, + {file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1"}, + {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7"}, + {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756"}, + {file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35"}, + {file = "coverage-7.4.1-cp39-cp39-win32.whl", hash = "sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c"}, + {file = "coverage-7.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a"}, + {file = "coverage-7.4.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166"}, + {file = "coverage-7.4.1.tar.gz", hash = "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04"}, ] [package.dependencies] @@ -772,13 +772,13 @@ pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_ve [[package]] name = "hypothesis" -version = "6.96.4" +version = "6.97.4" description = "A library for property-based testing" optional = false python-versions = ">=3.8" files = [ - {file = "hypothesis-6.96.4-py3-none-any.whl", hash = "sha256:2beb7a148e95a2067563bcca017d71cc286805c792e43ec5cb155ed6d0a1990d"}, - {file = "hypothesis-6.96.4.tar.gz", hash = "sha256:3b0d080bfd3b303e91388507ac7edebd7039ffcc96ac2cfcdc3c45806352c09f"}, + {file = "hypothesis-6.97.4-py3-none-any.whl", hash = "sha256:9069fe3fb18d9b7dd218bd69ab50bbc66426819dfac7cc7168ba85034d98a4df"}, + {file = "hypothesis-6.97.4.tar.gz", hash = "sha256:28ff724fa81ccc55f64f0f1eb06e4a75db6a195fe0857e9b3184cf4ff613a103"}, ] [package.dependencies] @@ -1589,28 +1589,28 @@ files = [ [[package]] name = "platformdirs" -version = "4.1.0" +version = "4.2.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"}, - {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"}, + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, ] [package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] [[package]] name = "pluggy" -version = "1.3.0" +version = "1.4.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" files = [ - {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, - {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, ] [package.extras] @@ -1786,18 +1786,18 @@ tutorial = ["ipykernel", "jinja2", "jupyter-client", "markupsafe", "nbconvert"] [[package]] name = "pydantic" -version = "2.5.3" +version = "2.6.0" description = "Data validation using Python type hints" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pydantic-2.5.3-py3-none-any.whl", hash = "sha256:d0caf5954bee831b6bfe7e338c32b9e30c85dfe080c843680783ac2b631673b4"}, - {file = "pydantic-2.5.3.tar.gz", hash = "sha256:b3ef57c62535b0941697cce638c08900d87fcb67e29cfa99e8a68f747f393f7a"}, + {file = "pydantic-2.6.0-py3-none-any.whl", hash = "sha256:1440966574e1b5b99cf75a13bec7b20e3512e8a61b894ae252f56275e2c465ae"}, + {file = "pydantic-2.6.0.tar.gz", hash = "sha256:ae887bd94eb404b09d86e4d12f93893bdca79d766e738528c6fa1c849f3c6bcf"}, ] [package.dependencies] annotated-types = ">=0.4.0" -pydantic-core = "2.14.6" +pydantic-core = "2.16.1" typing-extensions = ">=4.6.1" [package.extras] @@ -1805,116 +1805,90 @@ email = ["email-validator (>=2.0.0)"] [[package]] name = "pydantic-core" -version = "2.14.6" +version = "2.16.1" description = "" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.14.6-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:72f9a942d739f09cd42fffe5dc759928217649f070056f03c70df14f5770acf9"}, - {file = "pydantic_core-2.14.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6a31d98c0d69776c2576dda4b77b8e0c69ad08e8b539c25c7d0ca0dc19a50d6c"}, - {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5aa90562bc079c6c290f0512b21768967f9968e4cfea84ea4ff5af5d917016e4"}, - {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:370ffecb5316ed23b667d99ce4debe53ea664b99cc37bfa2af47bc769056d534"}, - {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f85f3843bdb1fe80e8c206fe6eed7a1caeae897e496542cee499c374a85c6e08"}, - {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9862bf828112e19685b76ca499b379338fd4c5c269d897e218b2ae8fcb80139d"}, - {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:036137b5ad0cb0004c75b579445a1efccd072387a36c7f217bb8efd1afbe5245"}, - {file = "pydantic_core-2.14.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:92879bce89f91f4b2416eba4429c7b5ca22c45ef4a499c39f0c5c69257522c7c"}, - {file = "pydantic_core-2.14.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0c08de15d50fa190d577e8591f0329a643eeaed696d7771760295998aca6bc66"}, - {file = "pydantic_core-2.14.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:36099c69f6b14fc2c49d7996cbf4f87ec4f0e66d1c74aa05228583225a07b590"}, - {file = "pydantic_core-2.14.6-cp310-none-win32.whl", hash = "sha256:7be719e4d2ae6c314f72844ba9d69e38dff342bc360379f7c8537c48e23034b7"}, - {file = "pydantic_core-2.14.6-cp310-none-win_amd64.whl", hash = "sha256:36fa402dcdc8ea7f1b0ddcf0df4254cc6b2e08f8cd80e7010d4c4ae6e86b2a87"}, - {file = "pydantic_core-2.14.6-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:dea7fcd62915fb150cdc373212141a30037e11b761fbced340e9db3379b892d4"}, - {file = "pydantic_core-2.14.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ffff855100bc066ff2cd3aa4a60bc9534661816b110f0243e59503ec2df38421"}, - {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b027c86c66b8627eb90e57aee1f526df77dc6d8b354ec498be9a757d513b92b"}, - {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00b1087dabcee0b0ffd104f9f53d7d3eaddfaa314cdd6726143af6bc713aa27e"}, - {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75ec284328b60a4e91010c1acade0c30584f28a1f345bc8f72fe8b9e46ec6a96"}, - {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e1f4744eea1501404b20b0ac059ff7e3f96a97d3e3f48ce27a139e053bb370b"}, - {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2602177668f89b38b9f84b7b3435d0a72511ddef45dc14446811759b82235a1"}, - {file = "pydantic_core-2.14.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c8edaea3089bf908dd27da8f5d9e395c5b4dc092dbcce9b65e7156099b4b937"}, - {file = "pydantic_core-2.14.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:478e9e7b360dfec451daafe286998d4a1eeaecf6d69c427b834ae771cad4b622"}, - {file = "pydantic_core-2.14.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b6ca36c12a5120bad343eef193cc0122928c5c7466121da7c20f41160ba00ba2"}, - {file = "pydantic_core-2.14.6-cp311-none-win32.whl", hash = "sha256:2b8719037e570639e6b665a4050add43134d80b687288ba3ade18b22bbb29dd2"}, - {file = "pydantic_core-2.14.6-cp311-none-win_amd64.whl", hash = "sha256:78ee52ecc088c61cce32b2d30a826f929e1708f7b9247dc3b921aec367dc1b23"}, - {file = "pydantic_core-2.14.6-cp311-none-win_arm64.whl", hash = "sha256:a19b794f8fe6569472ff77602437ec4430f9b2b9ec7a1105cfd2232f9ba355e6"}, - {file = "pydantic_core-2.14.6-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:667aa2eac9cd0700af1ddb38b7b1ef246d8cf94c85637cbb03d7757ca4c3fdec"}, - {file = "pydantic_core-2.14.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdee837710ef6b56ebd20245b83799fce40b265b3b406e51e8ccc5b85b9099b7"}, - {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c5bcf3414367e29f83fd66f7de64509a8fd2368b1edf4351e862910727d3e51"}, - {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a92ae76f75d1915806b77cf459811e772d8f71fd1e4339c99750f0e7f6324f"}, - {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a983cca5ed1dd9a35e9e42ebf9f278d344603bfcb174ff99a5815f953925140a"}, - {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb92f9061657287eded380d7dc455bbf115430b3aa4741bdc662d02977e7d0af"}, - {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4ace1e220b078c8e48e82c081e35002038657e4b37d403ce940fa679e57113b"}, - {file = "pydantic_core-2.14.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef633add81832f4b56d3b4c9408b43d530dfca29e68fb1b797dcb861a2c734cd"}, - {file = "pydantic_core-2.14.6-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7e90d6cc4aad2cc1f5e16ed56e46cebf4877c62403a311af20459c15da76fd91"}, - {file = "pydantic_core-2.14.6-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e8a5ac97ea521d7bde7621d86c30e86b798cdecd985723c4ed737a2aa9e77d0c"}, - {file = "pydantic_core-2.14.6-cp312-none-win32.whl", hash = "sha256:f27207e8ca3e5e021e2402ba942e5b4c629718e665c81b8b306f3c8b1ddbb786"}, - {file = "pydantic_core-2.14.6-cp312-none-win_amd64.whl", hash = "sha256:b3e5fe4538001bb82e2295b8d2a39356a84694c97cb73a566dc36328b9f83b40"}, - {file = "pydantic_core-2.14.6-cp312-none-win_arm64.whl", hash = "sha256:64634ccf9d671c6be242a664a33c4acf12882670b09b3f163cd00a24cffbd74e"}, - {file = "pydantic_core-2.14.6-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:24368e31be2c88bd69340fbfe741b405302993242ccb476c5c3ff48aeee1afe0"}, - {file = "pydantic_core-2.14.6-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:e33b0834f1cf779aa839975f9d8755a7c2420510c0fa1e9fa0497de77cd35d2c"}, - {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6af4b3f52cc65f8a0bc8b1cd9676f8c21ef3e9132f21fed250f6958bd7223bed"}, - {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15687d7d7f40333bd8266f3814c591c2e2cd263fa2116e314f60d82086e353a"}, - {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:095b707bb287bfd534044166ab767bec70a9bba3175dcdc3371782175c14e43c"}, - {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94fc0e6621e07d1e91c44e016cc0b189b48db053061cc22d6298a611de8071bb"}, - {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ce830e480f6774608dedfd4a90c42aac4a7af0a711f1b52f807130c2e434c06"}, - {file = "pydantic_core-2.14.6-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a306cdd2ad3a7d795d8e617a58c3a2ed0f76c8496fb7621b6cd514eb1532cae8"}, - {file = "pydantic_core-2.14.6-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:2f5fa187bde8524b1e37ba894db13aadd64faa884657473b03a019f625cee9a8"}, - {file = "pydantic_core-2.14.6-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:438027a975cc213a47c5d70672e0d29776082155cfae540c4e225716586be75e"}, - {file = "pydantic_core-2.14.6-cp37-none-win32.whl", hash = "sha256:f96ae96a060a8072ceff4cfde89d261837b4294a4f28b84a28765470d502ccc6"}, - {file = "pydantic_core-2.14.6-cp37-none-win_amd64.whl", hash = "sha256:e646c0e282e960345314f42f2cea5e0b5f56938c093541ea6dbf11aec2862391"}, - {file = "pydantic_core-2.14.6-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:db453f2da3f59a348f514cfbfeb042393b68720787bbef2b4c6068ea362c8149"}, - {file = "pydantic_core-2.14.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3860c62057acd95cc84044e758e47b18dcd8871a328ebc8ccdefd18b0d26a21b"}, - {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36026d8f99c58d7044413e1b819a67ca0e0b8ebe0f25e775e6c3d1fabb3c38fb"}, - {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ed1af8692bd8d2a29d702f1a2e6065416d76897d726e45a1775b1444f5928a7"}, - {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:314ccc4264ce7d854941231cf71b592e30d8d368a71e50197c905874feacc8a8"}, - {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:982487f8931067a32e72d40ab6b47b1628a9c5d344be7f1a4e668fb462d2da42"}, - {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dbe357bc4ddda078f79d2a36fc1dd0494a7f2fad83a0a684465b6f24b46fe80"}, - {file = "pydantic_core-2.14.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f6ffc6701a0eb28648c845f4945a194dc7ab3c651f535b81793251e1185ac3d"}, - {file = "pydantic_core-2.14.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7f5025db12fc6de7bc1104d826d5aee1d172f9ba6ca936bf6474c2148ac336c1"}, - {file = "pydantic_core-2.14.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:dab03ed811ed1c71d700ed08bde8431cf429bbe59e423394f0f4055f1ca0ea60"}, - {file = "pydantic_core-2.14.6-cp38-none-win32.whl", hash = "sha256:dfcbebdb3c4b6f739a91769aea5ed615023f3c88cb70df812849aef634c25fbe"}, - {file = "pydantic_core-2.14.6-cp38-none-win_amd64.whl", hash = "sha256:99b14dbea2fdb563d8b5a57c9badfcd72083f6006caf8e126b491519c7d64ca8"}, - {file = "pydantic_core-2.14.6-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:4ce8299b481bcb68e5c82002b96e411796b844d72b3e92a3fbedfe8e19813eab"}, - {file = "pydantic_core-2.14.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b9a9d92f10772d2a181b5ca339dee066ab7d1c9a34ae2421b2a52556e719756f"}, - {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd9e98b408384989ea4ab60206b8e100d8687da18b5c813c11e92fd8212a98e0"}, - {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f86f1f318e56f5cbb282fe61eb84767aee743ebe32c7c0834690ebea50c0a6b"}, - {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86ce5fcfc3accf3a07a729779d0b86c5d0309a4764c897d86c11089be61da160"}, - {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dcf1978be02153c6a31692d4fbcc2a3f1db9da36039ead23173bc256ee3b91b"}, - {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eedf97be7bc3dbc8addcef4142f4b4164066df0c6f36397ae4aaed3eb187d8ab"}, - {file = "pydantic_core-2.14.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d5f916acf8afbcab6bacbb376ba7dc61f845367901ecd5e328fc4d4aef2fcab0"}, - {file = "pydantic_core-2.14.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8a14c192c1d724c3acbfb3f10a958c55a2638391319ce8078cb36c02283959b9"}, - {file = "pydantic_core-2.14.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0348b1dc6b76041516e8a854ff95b21c55f5a411c3297d2ca52f5528e49d8411"}, - {file = "pydantic_core-2.14.6-cp39-none-win32.whl", hash = "sha256:de2a0645a923ba57c5527497daf8ec5df69c6eadf869e9cd46e86349146e5975"}, - {file = "pydantic_core-2.14.6-cp39-none-win_amd64.whl", hash = "sha256:aca48506a9c20f68ee61c87f2008f81f8ee99f8d7f0104bff3c47e2d148f89d9"}, - {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:d5c28525c19f5bb1e09511669bb57353d22b94cf8b65f3a8d141c389a55dec95"}, - {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:78d0768ee59baa3de0f4adac9e3748b4b1fffc52143caebddfd5ea2961595277"}, - {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b93785eadaef932e4fe9c6e12ba67beb1b3f1e5495631419c784ab87e975670"}, - {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a874f21f87c485310944b2b2734cd6d318765bcbb7515eead33af9641816506e"}, - {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89f4477d915ea43b4ceea6756f63f0288941b6443a2b28c69004fe07fde0d0d"}, - {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:172de779e2a153d36ee690dbc49c6db568d7b33b18dc56b69a7514aecbcf380d"}, - {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:dfcebb950aa7e667ec226a442722134539e77c575f6cfaa423f24371bb8d2e94"}, - {file = "pydantic_core-2.14.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:55a23dcd98c858c0db44fc5c04fc7ed81c4b4d33c653a7c45ddaebf6563a2f66"}, - {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:4241204e4b36ab5ae466ecec5c4c16527a054c69f99bba20f6f75232a6a534e2"}, - {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e574de99d735b3fc8364cba9912c2bec2da78775eba95cbb225ef7dda6acea24"}, - {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1302a54f87b5cd8528e4d6d1bf2133b6aa7c6122ff8e9dc5220fbc1e07bffebd"}, - {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8e81e4b55930e5ffab4a68db1af431629cf2e4066dbdbfef65348b8ab804ea8"}, - {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c99462ffc538717b3e60151dfaf91125f637e801f5ab008f81c402f1dff0cd0f"}, - {file = "pydantic_core-2.14.6-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e4cf2d5829f6963a5483ec01578ee76d329eb5caf330ecd05b3edd697e7d768a"}, - {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:cf10b7d58ae4a1f07fccbf4a0a956d705356fea05fb4c70608bb6fa81d103cda"}, - {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:399ac0891c284fa8eb998bcfa323f2234858f5d2efca3950ae58c8f88830f145"}, - {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c6a5c79b28003543db3ba67d1df336f253a87d3112dac3a51b94f7d48e4c0e1"}, - {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:599c87d79cab2a6a2a9df4aefe0455e61e7d2aeede2f8577c1b7c0aec643ee8e"}, - {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43e166ad47ba900f2542a80d83f9fc65fe99eb63ceec4debec160ae729824052"}, - {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a0b5db001b98e1c649dd55afa928e75aa4087e587b9524a4992316fa23c9fba"}, - {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:747265448cb57a9f37572a488a57d873fd96bf51e5bb7edb52cfb37124516da4"}, - {file = "pydantic_core-2.14.6-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:7ebe3416785f65c28f4f9441e916bfc8a54179c8dea73c23023f7086fa601c5d"}, - {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:86c963186ca5e50d5c8287b1d1c9d3f8f024cbe343d048c5bd282aec2d8641f2"}, - {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e0641b506486f0b4cd1500a2a65740243e8670a2549bb02bc4556a83af84ae03"}, - {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71d72ca5eaaa8d38c8df16b7deb1a2da4f650c41b58bb142f3fb75d5ad4a611f"}, - {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27e524624eace5c59af499cd97dc18bb201dc6a7a2da24bfc66ef151c69a5f2a"}, - {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3dde6cac75e0b0902778978d3b1646ca9f438654395a362cb21d9ad34b24acf"}, - {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:00646784f6cd993b1e1c0e7b0fdcbccc375d539db95555477771c27555e3c556"}, - {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:23598acb8ccaa3d1d875ef3b35cb6376535095e9405d91a3d57a8c7db5d29341"}, - {file = "pydantic_core-2.14.6-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7f41533d7e3cf9520065f610b41ac1c76bc2161415955fbcead4981b22c7611e"}, - {file = "pydantic_core-2.14.6.tar.gz", hash = "sha256:1fd0c1d395372843fba13a51c28e3bb9d59bd7aebfeb17358ffaaa1e4dbbe948"}, + {file = "pydantic_core-2.16.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:300616102fb71241ff477a2cbbc847321dbec49428434a2f17f37528721c4948"}, + {file = "pydantic_core-2.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5511f962dd1b9b553e9534c3b9c6a4b0c9ded3d8c2be96e61d56f933feef9e1f"}, + {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98f0edee7ee9cc7f9221af2e1b95bd02810e1c7a6d115cfd82698803d385b28f"}, + {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9795f56aa6b2296f05ac79d8a424e94056730c0b860a62b0fdcfe6340b658cc8"}, + {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c45f62e4107ebd05166717ac58f6feb44471ed450d07fecd90e5f69d9bf03c48"}, + {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:462d599299c5971f03c676e2b63aa80fec5ebc572d89ce766cd11ca8bcb56f3f"}, + {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21ebaa4bf6386a3b22eec518da7d679c8363fb7fb70cf6972161e5542f470798"}, + {file = "pydantic_core-2.16.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:99f9a50b56713a598d33bc23a9912224fc5d7f9f292444e6664236ae471ddf17"}, + {file = "pydantic_core-2.16.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8ec364e280db4235389b5e1e6ee924723c693cbc98e9d28dc1767041ff9bc388"}, + {file = "pydantic_core-2.16.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:653a5dfd00f601a0ed6654a8b877b18d65ac32c9d9997456e0ab240807be6cf7"}, + {file = "pydantic_core-2.16.1-cp310-none-win32.whl", hash = "sha256:1661c668c1bb67b7cec96914329d9ab66755911d093bb9063c4c8914188af6d4"}, + {file = "pydantic_core-2.16.1-cp310-none-win_amd64.whl", hash = "sha256:561be4e3e952c2f9056fba5267b99be4ec2afadc27261505d4992c50b33c513c"}, + {file = "pydantic_core-2.16.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:102569d371fadc40d8f8598a59379c37ec60164315884467052830b28cc4e9da"}, + {file = "pydantic_core-2.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:735dceec50fa907a3c314b84ed609dec54b76a814aa14eb90da31d1d36873a5e"}, + {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e83ebbf020be727d6e0991c1b192a5c2e7113eb66e3def0cd0c62f9f266247e4"}, + {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:30a8259569fbeec49cfac7fda3ec8123486ef1b729225222f0d41d5f840b476f"}, + {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:920c4897e55e2881db6a6da151198e5001552c3777cd42b8a4c2f72eedc2ee91"}, + {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5247a3d74355f8b1d780d0f3b32a23dd9f6d3ff43ef2037c6dcd249f35ecf4c"}, + {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d5bea8012df5bb6dda1e67d0563ac50b7f64a5d5858348b5c8cb5043811c19d"}, + {file = "pydantic_core-2.16.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ed3025a8a7e5a59817b7494686d449ebfbe301f3e757b852c8d0d1961d6be864"}, + {file = "pydantic_core-2.16.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:06f0d5a1d9e1b7932477c172cc720b3b23c18762ed7a8efa8398298a59d177c7"}, + {file = "pydantic_core-2.16.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:150ba5c86f502c040b822777e2e519b5625b47813bd05f9273a8ed169c97d9ae"}, + {file = "pydantic_core-2.16.1-cp311-none-win32.whl", hash = "sha256:d6cbdf12ef967a6aa401cf5cdf47850559e59eedad10e781471c960583f25aa1"}, + {file = "pydantic_core-2.16.1-cp311-none-win_amd64.whl", hash = "sha256:afa01d25769af33a8dac0d905d5c7bb2d73c7c3d5161b2dd6f8b5b5eea6a3c4c"}, + {file = "pydantic_core-2.16.1-cp311-none-win_arm64.whl", hash = "sha256:1a2fe7b00a49b51047334d84aafd7e39f80b7675cad0083678c58983662da89b"}, + {file = "pydantic_core-2.16.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f478ec204772a5c8218e30eb813ca43e34005dff2eafa03931b3d8caef87d51"}, + {file = "pydantic_core-2.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f1936ef138bed2165dd8573aa65e3095ef7c2b6247faccd0e15186aabdda7f66"}, + {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99d3a433ef5dc3021c9534a58a3686c88363c591974c16c54a01af7efd741f13"}, + {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd88f40f2294440d3f3c6308e50d96a0d3d0973d6f1a5732875d10f569acef49"}, + {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fac641bbfa43d5a1bed99d28aa1fded1984d31c670a95aac1bf1d36ac6ce137"}, + {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:72bf9308a82b75039b8c8edd2be2924c352eda5da14a920551a8b65d5ee89253"}, + {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb4363e6c9fc87365c2bc777a1f585a22f2f56642501885ffc7942138499bf54"}, + {file = "pydantic_core-2.16.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:20f724a023042588d0f4396bbbcf4cffd0ddd0ad3ed4f0d8e6d4ac4264bae81e"}, + {file = "pydantic_core-2.16.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fb4370b15111905bf8b5ba2129b926af9470f014cb0493a67d23e9d7a48348e8"}, + {file = "pydantic_core-2.16.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23632132f1fd608034f1a56cc3e484be00854db845b3a4a508834be5a6435a6f"}, + {file = "pydantic_core-2.16.1-cp312-none-win32.whl", hash = "sha256:b9f3e0bffad6e238f7acc20c393c1ed8fab4371e3b3bc311020dfa6020d99212"}, + {file = "pydantic_core-2.16.1-cp312-none-win_amd64.whl", hash = "sha256:a0b4cfe408cd84c53bab7d83e4209458de676a6ec5e9c623ae914ce1cb79b96f"}, + {file = "pydantic_core-2.16.1-cp312-none-win_arm64.whl", hash = "sha256:d195add190abccefc70ad0f9a0141ad7da53e16183048380e688b466702195dd"}, + {file = "pydantic_core-2.16.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:502c062a18d84452858f8aea1e520e12a4d5228fc3621ea5061409d666ea1706"}, + {file = "pydantic_core-2.16.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d8c032ccee90b37b44e05948b449a2d6baed7e614df3d3f47fe432c952c21b60"}, + {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:920f4633bee43d7a2818e1a1a788906df5a17b7ab6fe411220ed92b42940f818"}, + {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f5d37ff01edcbace53a402e80793640c25798fb7208f105d87a25e6fcc9ea06"}, + {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:399166f24c33a0c5759ecc4801f040dbc87d412c1a6d6292b2349b4c505effc9"}, + {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac89ccc39cd1d556cc72d6752f252dc869dde41c7c936e86beac5eb555041b66"}, + {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73802194f10c394c2bedce7a135ba1d8ba6cff23adf4217612bfc5cf060de34c"}, + {file = "pydantic_core-2.16.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8fa00fa24ffd8c31fac081bf7be7eb495be6d248db127f8776575a746fa55c95"}, + {file = "pydantic_core-2.16.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:601d3e42452cd4f2891c13fa8c70366d71851c1593ed42f57bf37f40f7dca3c8"}, + {file = "pydantic_core-2.16.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07982b82d121ed3fc1c51faf6e8f57ff09b1325d2efccaa257dd8c0dd937acca"}, + {file = "pydantic_core-2.16.1-cp38-none-win32.whl", hash = "sha256:d0bf6f93a55d3fa7a079d811b29100b019784e2ee6bc06b0bb839538272a5610"}, + {file = "pydantic_core-2.16.1-cp38-none-win_amd64.whl", hash = "sha256:fbec2af0ebafa57eb82c18c304b37c86a8abddf7022955d1742b3d5471a6339e"}, + {file = "pydantic_core-2.16.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a497be217818c318d93f07e14502ef93d44e6a20c72b04c530611e45e54c2196"}, + {file = "pydantic_core-2.16.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:694a5e9f1f2c124a17ff2d0be613fd53ba0c26de588eb4bdab8bca855e550d95"}, + {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d4dfc66abea3ec6d9f83e837a8f8a7d9d3a76d25c9911735c76d6745950e62c"}, + {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8655f55fe68c4685673265a650ef71beb2d31871c049c8b80262026f23605ee3"}, + {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21e3298486c4ea4e4d5cc6fb69e06fb02a4e22089304308817035ac006a7f506"}, + {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71b4a48a7427f14679f0015b13c712863d28bb1ab700bd11776a5368135c7d60"}, + {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10dca874e35bb60ce4f9f6665bfbfad050dd7573596608aeb9e098621ac331dc"}, + {file = "pydantic_core-2.16.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fa496cd45cda0165d597e9d6f01e36c33c9508f75cf03c0a650018c5048f578e"}, + {file = "pydantic_core-2.16.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5317c04349472e683803da262c781c42c5628a9be73f4750ac7d13040efb5d2d"}, + {file = "pydantic_core-2.16.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:42c29d54ed4501a30cd71015bf982fa95e4a60117b44e1a200290ce687d3e640"}, + {file = "pydantic_core-2.16.1-cp39-none-win32.whl", hash = "sha256:ba07646f35e4e49376c9831130039d1b478fbfa1215ae62ad62d2ee63cf9c18f"}, + {file = "pydantic_core-2.16.1-cp39-none-win_amd64.whl", hash = "sha256:2133b0e412a47868a358713287ff9f9a328879da547dc88be67481cdac529118"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d25ef0c33f22649b7a088035fd65ac1ce6464fa2876578df1adad9472f918a76"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:99c095457eea8550c9fa9a7a992e842aeae1429dab6b6b378710f62bfb70b394"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b49c604ace7a7aa8af31196abbf8f2193be605db6739ed905ecaf62af31ccae0"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c56da23034fe66221f2208c813d8aa509eea34d97328ce2add56e219c3a9f41c"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cebf8d56fee3b08ad40d332a807ecccd4153d3f1ba8231e111d9759f02edfd05"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:1ae8048cba95f382dba56766525abca438328455e35c283bb202964f41a780b0"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:780daad9e35b18d10d7219d24bfb30148ca2afc309928e1d4d53de86822593dc"}, + {file = "pydantic_core-2.16.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c94b5537bf6ce66e4d7830c6993152940a188600f6ae044435287753044a8fe2"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:adf28099d061a25fbcc6531febb7a091e027605385de9fe14dd6a97319d614cf"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:644904600c15816a1f9a1bafa6aab0d21db2788abcdf4e2a77951280473f33e1"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87bce04f09f0552b66fca0c4e10da78d17cb0e71c205864bab4e9595122cb9d9"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:877045a7969ace04d59516d5d6a7dee13106822f99a5d8df5e6822941f7bedc8"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9c46e556ee266ed3fb7b7a882b53df3c76b45e872fdab8d9cf49ae5e91147fd7"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4eebbd049008eb800f519578e944b8dc8e0f7d59a5abb5924cc2d4ed3a1834ff"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:c0be58529d43d38ae849a91932391eb93275a06b93b79a8ab828b012e916a206"}, + {file = "pydantic_core-2.16.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b1fc07896fc1851558f532dffc8987e526b682ec73140886c831d773cef44b76"}, + {file = "pydantic_core-2.16.1.tar.gz", hash = "sha256:daff04257b49ab7f4b3f73f98283d3dbb1a65bf3500d55c7beac3c66c310fe34"}, ] [package.dependencies] @@ -1922,13 +1896,13 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pyfakefs" -version = "5.3.4" +version = "5.3.5" description = "pyfakefs implements a fake file system that mocks the Python file system modules." optional = false python-versions = ">=3.7" files = [ - {file = "pyfakefs-5.3.4-py3-none-any.whl", hash = "sha256:fc375229f5417f197f0892a7d6dc49a411e67e10eb8142b19d80e60a9d52a13d"}, - {file = "pyfakefs-5.3.4.tar.gz", hash = "sha256:dadac1653195a4bfe4c26e9dfa7cc0c0286b1cd8e18706442c2464cae5542a17"}, + {file = "pyfakefs-5.3.5-py3-none-any.whl", hash = "sha256:751015c1de94e1390128c82b48cdedc3f088bbdbe4bc713c79d02a27f0f61e69"}, + {file = "pyfakefs-5.3.5.tar.gz", hash = "sha256:7cdc500b35a214cb7a614e1940543acc6650e69a94ac76e30f33c9373bd9cf90"}, ] [[package]] @@ -1973,13 +1947,13 @@ files = [ [[package]] name = "pyright" -version = "1.1.348" +version = "1.1.349" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.348-py3-none-any.whl", hash = "sha256:e7d4df504c4c082b5c3725a8c15fc3fda62da5d09fc77994baa77f359a1b62f2"}, - {file = "pyright-1.1.348.tar.gz", hash = "sha256:1c6994546f7ab130b9da8c357f8b2a99bef268b6d8ae2eae292bde66923aa7af"}, + {file = "pyright-1.1.349-py3-none-any.whl", hash = "sha256:8f9189ddb62222a35b3525666225f1d8f24244cbff5893c42b3f001d8ebafa1a"}, + {file = "pyright-1.1.349.tar.gz", hash = "sha256:af4ab7f103a0b2a92e5fbf248bf734e9a98247991350ac989ead34e97148f91c"}, ] [package.dependencies] @@ -2068,17 +2042,17 @@ dev = ["pre-commit", "pytest-asyncio", "tox"] [[package]] name = "pytest-split" -version = "0.8.1" +version = "0.8.2" description = "Pytest plugin which splits the test suite to equally sized sub suites based on test execution time." optional = false python-versions = ">=3.7.1,<4.0" files = [ - {file = "pytest_split-0.8.1-py3-none-any.whl", hash = "sha256:74b110ea091bd147cc1c5f9665a59506e5cedfa66f96a89fb03e4ab447c2c168"}, - {file = "pytest_split-0.8.1.tar.gz", hash = "sha256:2d88bd3dc528689a7a3f58fc12ea165c3aa62e90795e420dfad920afe5612d6d"}, + {file = "pytest_split-0.8.2-py3-none-any.whl", hash = "sha256:b7fa704659cb224b9f7f5c24536bc04eff351f42d852bf0312e03774fd9c0972"}, + {file = "pytest_split-0.8.2.tar.gz", hash = "sha256:446f330e3607572027f3861058c27d9b3eaa80d83dc86675abe2978bbf50c02f"}, ] [package.dependencies] -pytest = ">=5,<8" +pytest = ">=5,<9" [[package]] name = "pytest-xdist" @@ -2116,13 +2090,13 @@ six = ">=1.5" [[package]] name = "pytz" -version = "2023.3.post1" +version = "2023.4" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" files = [ - {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, - {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, + {file = "pytz-2023.4-py2.py3-none-any.whl", hash = "sha256:f90ef520d95e7c46951105338d918664ebfd6f1d995bd7d153127ce90efafa6a"}, + {file = "pytz-2023.4.tar.gz", hash = "sha256:31d4583c4ed539cd037956140d695e42c033a19e984bfce9964a3f7d59bc2b40"}, ] [[package]] @@ -2240,13 +2214,13 @@ prompt_toolkit = ">=2.0,<=3.0.36" [[package]] name = "referencing" -version = "0.32.1" +version = "0.33.0" description = "JSON Referencing + Python" optional = false python-versions = ">=3.8" files = [ - {file = "referencing-0.32.1-py3-none-any.whl", hash = "sha256:7e4dc12271d8e15612bfe35792f5ea1c40970dadf8624602e33db2758f7ee554"}, - {file = "referencing-0.32.1.tar.gz", hash = "sha256:3c57da0513e9563eb7e203ebe9bb3a1b509b042016433bd1e45a2853466c3dd3"}, + {file = "referencing-0.33.0-py3-none-any.whl", hash = "sha256:39240f2ecc770258f28b642dd47fd74bc8b02484de54e1882b74b35ebd779bd5"}, + {file = "referencing-0.33.0.tar.gz", hash = "sha256:c775fedf74bc0f9189c2a3be1c12fd03e8c23f4d371dce795df44e06c5b412f7"}, ] [package.dependencies] @@ -2491,28 +2465,28 @@ files = [ [[package]] name = "ruff" -version = "0.1.14" +version = "0.1.15" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.1.14-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:96f76536df9b26622755c12ed8680f159817be2f725c17ed9305b472a757cdbb"}, - {file = "ruff-0.1.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab3f71f64498c7241123bb5a768544cf42821d2a537f894b22457a543d3ca7a9"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7060156ecc572b8f984fd20fd8b0fcb692dd5d837b7606e968334ab7ff0090ab"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a53d8e35313d7b67eb3db15a66c08434809107659226a90dcd7acb2afa55faea"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bea9be712b8f5b4ebed40e1949379cfb2a7d907f42921cf9ab3aae07e6fba9eb"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2270504d629a0b064247983cbc495bed277f372fb9eaba41e5cf51f7ba705a6a"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80258bb3b8909b1700610dfabef7876423eed1bc930fe177c71c414921898efa"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:653230dd00aaf449eb5ff25d10a6e03bc3006813e2cb99799e568f55482e5cae"}, - {file = "ruff-0.1.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b3acc6c4e6928459ba9eb7459dd4f0c4bf266a053c863d72a44c33246bfdbf"}, - {file = "ruff-0.1.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b3dadc9522d0eccc060699a9816e8127b27addbb4697fc0c08611e4e6aeb8b5"}, - {file = "ruff-0.1.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1c8eca1a47b4150dc0fbec7fe68fc91c695aed798532a18dbb1424e61e9b721f"}, - {file = "ruff-0.1.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:62ce2ae46303ee896fc6811f63d6dabf8d9c389da0f3e3f2bce8bc7f15ef5488"}, - {file = "ruff-0.1.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b2027dde79d217b211d725fc833e8965dc90a16d0d3213f1298f97465956661b"}, - {file = "ruff-0.1.14-py3-none-win32.whl", hash = "sha256:722bafc299145575a63bbd6b5069cb643eaa62546a5b6398f82b3e4403329cab"}, - {file = "ruff-0.1.14-py3-none-win_amd64.whl", hash = "sha256:e3d241aa61f92b0805a7082bd89a9990826448e4d0398f0e2bc8f05c75c63d99"}, - {file = "ruff-0.1.14-py3-none-win_arm64.whl", hash = "sha256:269302b31ade4cde6cf6f9dd58ea593773a37ed3f7b97e793c8594b262466b67"}, - {file = "ruff-0.1.14.tar.gz", hash = "sha256:ad3f8088b2dfd884820289a06ab718cde7d38b94972212cc4ba90d5fbc9955f3"}, + {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5fe8d54df166ecc24106db7dd6a68d44852d14eb0729ea4672bb4d96c320b7df"}, + {file = "ruff-0.1.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f0bfbb53c4b4de117ac4d6ddfd33aa5fc31beeaa21d23c45c6dd249faf9126f"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0d432aec35bfc0d800d4f70eba26e23a352386be3a6cf157083d18f6f5881c8"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9405fa9ac0e97f35aaddf185a1be194a589424b8713e3b97b762336ec79ff807"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c66ec24fe36841636e814b8f90f572a8c0cb0e54d8b5c2d0e300d28a0d7bffec"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6f8ad828f01e8dd32cc58bc28375150171d198491fc901f6f98d2a39ba8e3ff5"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86811954eec63e9ea162af0ffa9f8d09088bab51b7438e8b6488b9401863c25e"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd4025ac5e87d9b80e1f300207eb2fd099ff8200fa2320d7dc066a3f4622dc6b"}, + {file = "ruff-0.1.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b17b93c02cdb6aeb696effecea1095ac93f3884a49a554a9afa76bb125c114c1"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ddb87643be40f034e97e97f5bc2ef7ce39de20e34608f3f829db727a93fb82c5"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:abf4822129ed3a5ce54383d5f0e964e7fef74a41e48eb1dfad404151efc130a2"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6c629cf64bacfd136c07c78ac10a54578ec9d1bd2a9d395efbee0935868bf852"}, + {file = "ruff-0.1.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1bab866aafb53da39c2cadfb8e1c4550ac5340bb40300083eb8967ba25481447"}, + {file = "ruff-0.1.15-py3-none-win32.whl", hash = "sha256:2417e1cb6e2068389b07e6fa74c306b2810fe3ee3476d5b8a96616633f40d14f"}, + {file = "ruff-0.1.15-py3-none-win_amd64.whl", hash = "sha256:3837ac73d869efc4182d9036b1405ef4c73d9b1f88da2413875e34e0d6919587"}, + {file = "ruff-0.1.15-py3-none-win_arm64.whl", hash = "sha256:9a933dfb1c14ec7a33cceb1e49ec4a16b51ce3c20fd42663198746efc0427360"}, + {file = "ruff-0.1.15.tar.gz", hash = "sha256:f6dfa8c1b21c913c326919056c390966648b680966febcb796cc9d1aaab8564e"}, ] [[package]] @@ -3337,17 +3311,18 @@ types-strict = ["autoflake (==1.4)", "mypy", "yapf (==0.32.0)"] [[package]] name = "urllib3" -version = "2.1.0" +version = "2.2.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, - {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, + {file = "urllib3-2.2.0-py3-none-any.whl", hash = "sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224"}, + {file = "urllib3-2.2.0.tar.gz", hash = "sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20"}, ] [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] diff --git a/pyproject.toml b/pyproject.toml index 3ed2f685..ce81f15a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -180,6 +180,7 @@ exclude_lines = [ 'class [a-zA-Z0-9_]+\([^)]*Protocol.*\)', 'if TYPE_CHECKING', "def __dir__", + '@(abc\.)abstractmethod' ] [tool.isort] diff --git a/snakebids/core/_querying.py b/snakebids/core/_querying.py new file mode 100644 index 00000000..0bc84c99 --- /dev/null +++ b/snakebids/core/_querying.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import abc +import functools as ft +import re +from typing import TYPE_CHECKING, Any, Final, Iterable, Mapping, Sequence, cast + +import attrs +import more_itertools as itx +from bids.layout import BIDSLayout, Query +from bids.layout.models import BIDSFile +from typing_extensions import Self, TypeAlias, override + +from snakebids.exceptions import ConfigError, PybidsError +from snakebids.types import FilterMap, FilterValue, InputConfig + +CompiledFilter: TypeAlias = "Mapping[str, Sequence[str | Query]]" + + +class PostFilter: + """Filters to apply after indexing, typically derived from the CLI. + + Currently used for supporting ``--[exclude-]participant-label`` + """ + + def __init__(self): + self.inclusions: dict[str, Sequence[str] | str] = {} + self.exclusions: dict[str, Sequence[str] | str] = {} + + def add_filter( + self, + key: str, + inclusions: Iterable[str] | str | None, + exclusions: Iterable[str] | str | None, + ): + """Add entity filter based on inclusion or exclusion criteria. + + Converts a list of values to include or exclude into Pybids compatible filters. + Exclusion filters are appropriately formatted as regex. Raises an exception if + both include and exclude are stipulated. + + PostFilter is modified in-place. + + Parameters + ---------- + key + Name of entity to be filtered + inclusions + Values to include, values not found in this list will be excluded, by + default ``None`` + exclusions + Values to exclude, only values not found in this list will be included, by + default ``None`` + + Raises + ------ + ValueError + Raised if both include and exclude values are stipulated. + """ + if inclusions is not None and exclusions is not None: + msg = ( + "Cannot define both participant_label and exclude_participant_label at " + "the same time" + ) + raise ValueError(msg) + if inclusions is not None: + self.inclusions[key] = list(itx.always_iterable(inclusions)) + if exclusions is not None: + self.exclusions[key] = self._format_exclusions(exclusions) + + def _format_exclusions(self, exclusions: Iterable[str] | str): + # if multiple items to exclude, combine with with item1|item2|... + exclude_string = "|".join( + re.escape(label) for label in itx.always_iterable(exclusions) + ) + # regex to exclude subjects + return [f"^((?!({exclude_string})$).*)$"] + + +@attrs.define(slots=False) +class UnifiedFilter: + """Manages component level and post filters.""" + + component: InputConfig + """The Component configuration defining the filters""" + + postfilters: PostFilter + """Filters to be applied after collecting and parsing the data + + Currently only used to implement --[exclude-]participant-label, but in the future, + may implement other such CLI args. Unlike configuration-defined filters, these + filters apply after the dataset is indexed and queried. Thus, if a filter is set + to an empty list, a complete, albeit empty, component may be found. This is akin to + calling ``BidsComponent.filter`` after running ``generate_inputs``. + + For performance purposes, non-empty post-filters are applied via ``pybids.get()`` + """ + + @classmethod + def from_filter_dict( + cls, + filters: Mapping[str, str | bool | Sequence[str | bool]], + postfilter: PostFilter | None = None, + ) -> Self: + """Patch together a UnifiedFilter based on a basic filter dict. + + Intended primarily for use in testing + """ + wildcards: list[str] = [] + if postfilter is not None: + wildcards.extend(postfilter.inclusions) + wildcards.extend(postfilter.exclusions) + return cls( + {"filters": filters, "wildcards": wildcards}, postfilter or PostFilter() + ) + + def _has_empty_list(self, items: Iterable[Any]): + """Check if any of the lists within iterable are empty.""" + return any( + itx.ilen(itx.always_iterable(item, base_type=(str, dict))) # type: ignore + == 0 + for item in items + ) + + def _has_overlap(self, key: str): + """Check if filter key is a wildcard and not already a prefilter.""" + return key not in self.prefilters and key in self.component.get("wildcards", []) + + @ft.cached_property + def prefilters(self) -> FilterMap: + """Filters defined in the component configuration and applied via pybids. + + Unlike postfilters, a prefilter set to an empty list will result in no valid + paths found, resulting in a blank (missing) component. + """ + filters = dict(self.component.get("filters", {})) + # Silently remove "regex_search". This value has been blocked by a bug for the + # since version 0.6, and even before, never fully worked properly (e.g. would + # break if combined with --exclude-participant-label) + if "regex_search" in filters: + del filters["regex_search"] + return filters + + @ft.cached_property + def get(self) -> CompiledFilter: + """The combination of pre- and post- filters for indexing pybids via ``.get()``. + + Includes pre-filters not annotated for regex querying and all inclusion + post-filters. Empty post-filters are replaced with Query.ANY. This allows valid + paths to be found and processed later. Post-filters are not applied when an + equivalent prefilter is present + + Raises + ------ + FilterSpecError + When filter configuration is invalidly specified. + """ + result = dict(_compile_filters(self.prefilters, with_regex=False)) + postfilters = self.postfilters.inclusions + for key in self.postfilters.inclusions: + if self._has_overlap(key): + # if empty list filter, ensure the entity filtered is present + result[key] = ( + postfilters[key] + if itx.ilen(itx.always_iterable(postfilters[key])) + else [Query.ANY] + ) + return result + + @ft.cached_property + def search(self) -> CompiledFilter: + """Pre-filters for indexing pybids via ``.get(regex_search=True)``. + + As with :prop:`UnifiedFilter.get`, but only prefilters labelled for regex + matching using ``search:`` or ``match:``. + + Raises + ------ + FilterSpecError + When filter configuration is invalidly specified. + """ + return dict(_compile_filters(self.prefilters, with_regex=True)) + + @property + def post_exclusions(self) -> dict[str, Sequence[str] | str]: + """Dictionary of all post-exclusion filters.""" + return { + key: val + for key, val in self.postfilters.exclusions.items() + if self._has_overlap(key) + } + + @property + def without_bools(self) -> Mapping[str, str | Sequence[str]]: + """Check and typeguard to ensure filters do not contain booleans.""" + for key, val in self.get.items(): + if any(isinstance(v, Query) for v in itx.always_iterable(val)): + msg = ( + "Boolean filters in items with custom paths are not supported; in " + f"component='{key}'" + ) + raise ValueError(msg) + return cast("Mapping[str, str | Sequence[str]]", self.get) + + @property + def has_empty_prefilter(self) -> bool: + """Returns True if even one prefilter is empty.""" + return self._has_empty_list(self.prefilters.values()) + + @property + def has_empty_postfilter(self) -> bool: + """Returns True if even one postfilter is empty.""" + return self._has_empty_list( + filt + for name, filt in self.postfilters.inclusions.items() + if self._has_overlap(name) + ) + + +def get_matching_files( + bids_layout: BIDSLayout, + filters: UnifiedFilter, +) -> Iterable[BIDSFile]: + """Query pybids layout based on provided filters. + + Supports a combination of regular and regex querying. + + Raises + ------ + FilterSpecError + When filter configuration is invalidly specified. + PybidsError + When pybids raises an error within BIDSLayout.get() + """ + if filters.has_empty_prefilter: + return [] + try: + get = bids_layout.get( + regex_search=False, + **filters.get, + ) + search = ( + set(bids_layout.get(regex_search=True, **filters.search)) + if filters.search + else None + ) + except AttributeError as err: + msg = ( + "Pybids has encountered a problem that Snakebids cannot handle. This " + "may indicate a missing or invalid dataset_description.json for this " + "dataset." + ) + raise PybidsError(msg) from err + + if search is not None: + return [p for p in get if p in search] + return get + + +@attrs.define +class FilterSpecError(Exception, abc.ABC): + entity: str + value: FilterValue + + requirement: Final[str] = attrs.field( + default="Must have exactly one of {'get', 'match', search'}.", init=False + ) + + @abc.abstractmethod + def get_config_error(self, component_name: str) -> ConfigError: + """Return ConfigError with class-specific message.""" + ... + + +@attrs.define +class _TooFewKeysError(FilterSpecError): + """Exception raised when filter specified as dictionary without keys.""" + + @override + def get_config_error(self, component_name: str) -> ConfigError: + msg = ( + f"Filter '{self.entity}' for component '{component_name}' was specified as " + f"a dict but was not given any keys. {self.requirement} Got: {self.value}" + ) + return ConfigError(msg) + + +@attrs.define +class _TooManyKeysError(FilterSpecError): + """Exception raised when filter specified as dictionary with more than one key.""" + + @override + def get_config_error(self, component_name: str) -> ConfigError: + msg = ( + f"Filter '{self.entity}' for component '{component_name}' may not have " + f"more than one key. {self.requirement} Got: {self.value}" + ) + return ConfigError(msg) + + +@attrs.define +class _InvalidKeyError(FilterSpecError): + """Exception raised when filter specified as dictionary with invalid key.""" + + @override + def get_config_error(self, component_name: str) -> ConfigError: + msg = ( + f"Invalid query method specified for filter '{self.entity}' in component " + f"'{component_name}'. {self.requirement} Got {self.value}" + ) + return ConfigError(msg) + + +def _compile_filters(filters: FilterMap, *, with_regex: bool) -> CompiledFilter: + """Convert filter configuration into dict consumable by pybids get(). + + Raises + ------ + FilterSpecError + When filter configuration is invalidly specified. + """ + + def _compile_filt(filt: Iterable[str | bool]): + return [ + Query.ANY if f is True else Query.NONE if f is False else f for f in filt + ] + + result: CompiledFilter = {} + for key, filt in filters.items(): + try: + filt_type = itx.one(filt.keys(), too_short=TypeError) # type: ignore + except ValueError as err: + raise _TooManyKeysError(key, filt) from err + except TypeError as err: + raise _TooFewKeysError(key, filt) from err + except AttributeError: + if TYPE_CHECKING: + assert not isinstance(filt, dict) + f = filt + filt_type = "get" + else: + if filt_type not in {"match", "search", "get"}: + raise _InvalidKeyError(key, filt) + if TYPE_CHECKING: + assert isinstance(filt, dict) + f = cast("str | bool | Sequence[str | bool]", filt[filt_type]) + + # these two must both be true or both be false + if with_regex ^ (filt_type != "get"): + continue + + result[key] = _compile_filt(itx.always_iterable(f)) + + if filt_type == "match": + # pybids only does search, so surround string filters with position anchors + # to simulate match + result[key] = [ + f"^(?:{filt})$" if isinstance(filt, str) else filt + for filt in result[key] + ] + + return result diff --git a/snakebids/core/input_generation.py b/snakebids/core/input_generation.py index 9b94ec25..372e9665 100644 --- a/snakebids/core/input_generation.py +++ b/snakebids/core/input_generation.py @@ -1,7 +1,6 @@ """Utilities for converting Snakemake apps to BIDS apps.""" from __future__ import annotations -import functools as ft import json import logging import os @@ -9,21 +8,28 @@ import warnings from collections import defaultdict from pathlib import Path -from typing import Any, Generator, Iterable, Literal, Mapping, Sequence, cast, overload +from typing import ( + Any, + Iterable, + Literal, + overload, +) -import attrs import more_itertools as itx from bids import BIDSLayout, BIDSLayoutIndexer -from bids.layout import BIDSFile, Query from snakemake.script import Snakemake -from typing_extensions import Self, TypeAlias +from snakebids.core._querying import ( + FilterSpecError, + PostFilter, + UnifiedFilter, + get_matching_files, +) from snakebids.core.datasets import BidsComponent, BidsDataset, BidsDatasetDict from snakebids.core.filtering import filter_list from snakebids.exceptions import ( ConfigError, DuplicateComponentError, - PybidsError, RunError, ) from snakebids.types import InputConfig, InputsConfig, ZipList @@ -37,9 +43,6 @@ _logger = logging.getLogger(__name__) -FilterType: TypeAlias = "Mapping[str, str | bool | Sequence[str | bool]]" -CompiledFilter: TypeAlias = "Mapping[str, str | Query | Sequence[str | Query]]" - @overload def generate_inputs( @@ -266,7 +269,7 @@ def generate_inputs( ), }) """ - postfilters = _Postfilter() + postfilters = PostFilter() postfilters.add_filter("subject", participant_label, exclude_participant_label) pybidsdb_dir, pybidsdb_reset = _normalize_database_args( @@ -288,9 +291,9 @@ def generate_inputs( else None ) - bids_inputs = _get_lists_from_bids( + bids_inputs = _get_components( bids_layout=layout, - pybids_inputs=pybids_inputs, + inputs_config=pybids_inputs, limit_to=limit_to, postfilters=postfilters, ) @@ -478,195 +481,180 @@ def write_derivative_json(snakemake: Snakemake, **kwargs: dict[str, Any]) -> Non json.dump(sidecar, outfile, indent=4) -class _Postfilter: - """Filters to apply after indexing, typically derived from the CLI. +def _get_components( + *, + bids_layout: BIDSLayout | None, + inputs_config: InputsConfig, + postfilters: PostFilter, + limit_to: Iterable[str] | None = None, +): + """Generate components based on components config and a bids layout. - Currently used for supporting ``--[exclude-]participant-label`` - """ + Parameters + ---------- + bids_layout : BIDSLayout + Layout from pybids for accessing the BIDS dataset to grab paths. - def __init__(self): - self.inclusions: dict[str, Sequence[str] | str] = {} - self.exclusions: dict[str, Sequence[str] | str] = {} + inputs_config + Dictionary indexed by modality name, specifying the filters and + wildcards for each pybids input. - def add_filter( - self, - key: str, - inclusions: Iterable[str] | str | None, - exclusions: Iterable[str] | str | None, - ): - """Add entity filter based on inclusion or exclusion criteria. - - Converts a list of values to include or exclude into Pybids compatible filters. - Exclusion filters are appropriately formatted as regex. Raises an exception if - both include and exclude are stipulated - - _Postfilter is modified in-place - - Parameters - ---------- - key - Name of entity to be filtered - inclusions - Values to include, values not found in this list will be excluded, by - default None - exclusions - Values to exclude, only values not found in this list will be included, by - default None - - Raises - ------ - ValueError - Raised if both include and exclude values are stipulated. - """ - if inclusions is not None and exclusions is not None: - msg = ( - "Cannot define both participant_label and exclude_participant_label at " - "the same time" - ) - raise ValueError(msg) - if inclusions is not None: - self.inclusions[key] = list(itx.always_iterable(inclusions)) - if exclusions is not None: - self.exclusions[key] = self._format_exclusions(exclusions) - - def _format_exclusions(self, exclusions: Iterable[str] | str): - # if multiple items to exclude, combine with with item1|item2|... - exclude_string = "|".join( - re.escape(label) for label in itx.always_iterable(exclusions) - ) - # regex to exclude subjects - return [f"^((?!({exclude_string})$).*)$"] + limit_to + List of inputs to skip, this used by snakebids to exclude modalities based on + cmd-line args. + postfilters + Filters to all components after delineation. -def _compile_filters(filters: FilterType) -> CompiledFilter: - return { - key: [ - Query.ANY if f is True else Query.NONE if f is False else f - for f in itx.always_iterable(filts) - ] - for key, filts in filters.items() - } + Yields + ------ + BidsComponent: + One BidsComponent is yielded for each modality described by ``pybids_inputs``. + Raises + ------ + ConfigError + In response to invalid configuration, missing components, or parsing errors. + """ + for name in limit_to or inputs_config: + comp = _get_component( + bids_layout=bids_layout, + component=inputs_config[name], + input_name=name, + postfilters=postfilters, + ) + if comp is not None: + yield comp -@attrs.define(slots=False) -class _UnifiedFilter: - """Manages component level and post filters.""" - component: InputConfig - """The Component configuration defining the filters""" +def _get_component( + bids_layout: BIDSLayout | None, + component: InputConfig, + *, + input_name: str, + postfilters: PostFilter, +) -> BidsComponent | None: + """Create component based on provided config. - postfilters: _Postfilter - """Filters to be applied after collecting and parsing the data + Parameters + ---------- + bids_layout + Layout from pybids for accessing the BIDS dataset to grab paths - Currently only used to implement --[exclude-]participant-label, but in the future, - may implement other such CLI args. Unlike configuration-defined filters, these - filters apply after the dataset is indexed and queried. Thus, if a filter is set - to an empty list, a complete, albeit empty, component may be found. This is akin to - calling ``BidsComponent.filter`` after running ``generate_inputs``. + component + Dictionary indexed by modality name, specifying the filters and + wildcards for each pybids input. - For performance purposes, non-empty post-filters are applied via ``pybids.get()`` + input_name + Name of the component. + + postfilters + Filters to component after delineation + + Raises + ------ + ConfigError + In response to invalid configuration, missing components, or parsing errors. """ + _logger.debug("Grabbing inputs for %s...", input_name) - @classmethod - def from_filter_dict( - cls, - filters: Mapping[str, str | bool | Sequence[str | bool]], - postfilter: _Postfilter | None = None, - ) -> Self: - """Patch together a UnifiedFilter based on a basic filter dict. - - Intended primarily for use in testing - """ - wildcards: list[str] = [] - if postfilter is not None: - wildcards.extend(postfilter.inclusions) - wildcards.extend(postfilter.exclusions) - return cls( - {"filters": filters, "wildcards": wildcards}, postfilter or _Postfilter() + filters = UnifiedFilter(component, postfilters or {}) + + if "custom_path" in component: + path = component["custom_path"] + zip_lists = _parse_custom_path(path, filters=filters) + return BidsComponent(name=input_name, path=path, zip_lists=zip_lists) + + if bids_layout is None: + msg = ( + f"No valid bids dir given, but {input_name} does not have a " + "custom_path specified." ) + raise RunError(msg) - def _has_empty_list(self, items: Iterable[Any]): - """Check if any of the lists within iterable are empty.""" - return any(itx.ilen(itx.always_iterable(item)) == 0 for item in items) - - def _has_overlap(self, key: str): - """Check if filter key is a wildcard and not already a prefilter.""" - return key not in self.prefilters and key in self.component.get("wildcards", []) - - @ft.cached_property - def prefilters(self) -> FilterType: - """Filters defined in the component configuration and applied via pybids. - - Unlike postfilters, a prefilter set to an empty list will result in no valid - paths found, resulting in a blank (missing) component. - """ - filters = dict(self.component.get("filters", {})) - # Silently remove "regex_search". This value has been blocked by a bug for the - # since version 0.6, and even before, never fully worked properly (e.g. would - # break if combined with --exclude-participant-label) - if "regex_search" in filters: - del filters["regex_search"] - return filters - - @ft.cached_property - def filters(self) -> CompiledFilter: - """The combination pre- and post- filters to be applied to pybids indexing. - - Includes all pre-filters, and all inclusion post-filters. Empty post-filters - are replaced with Query.ANY. This allows valid paths to be found and processed - later. Post-filters are not applied when an equivalent prefilter is present - """ - result = dict(_compile_filters(self.prefilters)) - postfilters = self.postfilters.inclusions - for key in self.postfilters.inclusions: - if self._has_overlap(key): - # if empty list filter, ensure the entity filtered is present - result[key] = ( - postfilters[key] - if itx.ilen(itx.always_iterable(postfilters[key])) - else Query.ANY - ) - return result + zip_lists: dict[str, list[str]] = defaultdict(list) + paths: set[str] = set() + try: + matching_files = get_matching_files(bids_layout, filters) + except FilterSpecError as err: + raise err.get_config_error(input_name) from err + + for img in matching_files: + wildcards: list[str] = [ + wildcard + for wildcard in component.get("wildcards", []) + if wildcard in img.entities + ] + _logger.debug("Wildcards %s found entities for %s", wildcards, img.path) - @property - def post_exclusions(self) -> dict[str, Sequence[str] | str]: - """Dictionary of all post-exclusion filters.""" - return { - key: val - for key, val in self.postfilters.exclusions.items() - if self._has_overlap(key) - } + try: + path, parsed_wildcards = _parse_bids_path(img.path, wildcards) + except BidsParseError as err: + msg = ( + "Parsing failed:\n" + f" Entity: {err.entity.entity}\n" + f" Pattern: {err.entity.regex}\n" + f" Path: {img.path}\n" + "\n" + "Pybids parsed this path using the pattern: " + f"{bids_layout.entities[err.entity.entity].regex}\n" + "\n" + "Snakebids is not currently able to handle this entity. If it is a " + "custom entity, its `tag-` must be configured to be the same as " + "its name. Its entry in your pybids config file should look like:\n" + f'{{\n\t"name": "{err.entity.entity}",\n' + f'\t"pattern":"{err.entity.entity}-()"\n}}\n' + f"If {err.entity.entity} is an official pybids entity, please " + "ensure you are using the latest version of snakebids" + ) + raise ConfigError(msg) from err + + for wildcard_name, value in parsed_wildcards.items(): + zip_lists[wildcard_name].append(value) + + paths.add(path) - @property - def without_bools(self) -> Mapping[str, str | Sequence[str]]: - """Check and typeguard to ensure filters do not contain booleans.""" - for key, val in self.filters.items(): - if any(isinstance(v, Query) for v in itx.always_iterable(val)): - msg = ( - "Boolean filters in items with custom paths are not supported; in " - f"component='{key}'" - ) - raise ValueError(msg) - return cast("Mapping[str, str | Sequence[str]]", self.filters) - - @property - def has_empty_prefilter(self) -> bool: - """Returns True if even one prefilter is empty.""" - return self._has_empty_list(self.prefilters.values()) - - @property - def has_empty_postfilter(self) -> bool: - """Returns True if even one postfilter is empty.""" - return self._has_empty_list( - filt - for name, filt in self.postfilters.inclusions.items() - if self._has_overlap(name) + # now, check to see if unique + if len(paths) == 0: + _logger.warning( + "No input files found for snakebids component %s:\n" + " filters:\n%s\n" + " wildcards:\n%s", + input_name, + "\n".join( + [ + f" {key}: {val}" + for key, val in component.get("filters", {}).items() + ] + ), + "\n".join( + [f" {wildcard}" for wildcard in component.get("wildcards", [])] + ), ) + return None + try: + path = itx.one(paths) + except ValueError as err: + msg = ( + f"More than one snakemake filename for {input_name}, taking the " + f"first. To correct this, use the --filter_{input_name} option to " + f"narrow the search. Found filenames: {paths}" + ) + raise ConfigError(msg) from err + + if filters.has_empty_postfilter: + return BidsComponent( + name=input_name, path=path, zip_lists={key: [] for key in zip_lists} + ) + + return BidsComponent(name=input_name, path=path, zip_lists=zip_lists).filter( + regex_search=True, **filters.post_exclusions + ) def _parse_custom_path( input_path: Path | str, - filters: _UnifiedFilter, + filters: UnifiedFilter, ) -> ZipList: """Glob wildcards from a custom path and apply filters. @@ -772,162 +760,6 @@ def _parse_bids_path(path: str, entities: Iterable[str]) -> tuple[str, dict[str, return "".join(new_path), wildcard_values -def _get_matching_files( - bids_layout: BIDSLayout, - filters: _UnifiedFilter, -) -> Iterable[BIDSFile]: - if filters.has_empty_prefilter: - return [] - try: - return bids_layout.get( - regex_search=False, - **filters.filters, - ) - except AttributeError as err: - msg = ( - "Pybids has encountered a problem that Snakebids cannot handle. This " - "may indicate a missing or invalid dataset_description.json for this " - "dataset." - ) - raise PybidsError(msg) from err - - -def _get_lists_from_bids( - bids_layout: BIDSLayout | None, - pybids_inputs: InputsConfig, - *, - limit_to: Iterable[str] | None = None, - postfilters: _Postfilter, -) -> Generator[BidsComponent, None, None]: - """Grabs files using pybids and creates snakemake-friendly lists. - - Parameters - ---------- - bids_layout : BIDSLayout - Layout from pybids for accessing the BIDS dataset to grab paths - - pybids_inputs : dict - Dictionary indexed by modality name, specifying the filters and - wildcards for each pybids input. - - limit_to : list, optional - List of inputs to skip, this used by snakebids to exclude modalities - based on cmd-line args - - filters : dict of str -> str or list of str, optional - Pybids filters to apply globally to all inputs. - - Yields - ------ - BidsComponent: - One BidsComponent is yielded for each modality described by ``pybids_inputs``. - """ - for input_name in limit_to or list(pybids_inputs): - _logger.debug("Grabbing inputs for %s...", input_name) - component = pybids_inputs[input_name] - - filters = _UnifiedFilter(component, postfilters or {}) - - if "custom_path" in component: - # a custom path was specified for this input, skip pybids: - # get input_wildcards by parsing path for {} entries (using a set - # to get unique only) - # get zip_lists by using glob_wildcards (but need to modify - # to deal with multiple wildcards - - path = component["custom_path"] - zip_lists = _parse_custom_path(path, filters=filters) - yield BidsComponent(name=input_name, path=path, zip_lists=zip_lists) - continue - - if bids_layout is None: - msg = ( - f"No valid bids dir given, but {input_name} does not have a " - "custom_path specified." - ) - raise RunError(msg) - - zip_lists: dict[str, list[str]] = defaultdict(list) - paths: set[str] = set() - matching_files = _get_matching_files(bids_layout, filters) - - for img in matching_files: - wildcards: list[str] = [ - wildcard - for wildcard in component.get("wildcards", []) - if wildcard in img.entities - ] - _logger.debug("Wildcards %s found entities for %s", wildcards, img.path) - - try: - path, parsed_wildcards = _parse_bids_path(img.path, wildcards) - except BidsParseError as err: - msg = ( - "Parsing failed:\n" - f" Entity: {err.entity.entity}\n" - f" Pattern: {err.entity.regex}\n" - f" Path: {img.path}\n" - "\n" - "Pybids parsed this path using the pattern: " - f"{bids_layout.entities[err.entity.entity].regex}\n" - "\n" - "Snakebids is not currently able to handle this entity. If it is a " - "custom entity, its `tag-` must be configured to be the same as " - "its name. Its entry in your pybids config file should look like:\n" - f'{{\n\t"name": "{err.entity.entity}",\n' - f'\t"pattern":"{err.entity.entity}-()"\n}}\n' - f"If {err.entity.entity} is an official pybids entity, please " - "ensure you are using the latest version of snakebids" - ) - raise ConfigError(msg) from err - - for wildcard_name, value in parsed_wildcards.items(): - zip_lists[wildcard_name].append(value) - - paths.add(path) - - # now, check to see if unique - if len(paths) == 0: - _logger.warning( - "No input files found for snakebids component %s:\n" - " filters:\n%s\n" - " wildcards:\n%s", - input_name, - "\n".join( - [ - f" {key}: {val}" - for key, val in component.get("filters", {}).items() - ] - ), - "\n".join( - [ - f" {wildcard}" - for wildcard in component.get("wildcards", []) - ] - ), - ) - continue - try: - path = itx.one(paths) - except ValueError as err: - msg = ( - f"More than one snakemake filename for {input_name}, taking the " - f"first. To correct this, use the --filter_{input_name} option to " - f"narrow the search. Found filenames: {paths}" - ) - raise ConfigError(msg) from err - - if filters.has_empty_postfilter: - yield BidsComponent( - name=input_name, path=path, zip_lists={key: [] for key in zip_lists} - ) - continue - - yield BidsComponent(name=input_name, path=path, zip_lists=zip_lists).filter( - regex_search=True, **filters.post_exclusions - ) - - def get_wildcard_constraints(image_types: InputsConfig) -> dict[str, str]: """Return a wildcard_constraints dict for use in snakemake. diff --git a/snakebids/tests/test_generate_inputs.py b/snakebids/tests/test_generate_inputs.py index 01fce054..d2383ecb 100644 --- a/snakebids/tests/test_generate_inputs.py +++ b/snakebids/tests/test_generate_inputs.py @@ -13,7 +13,7 @@ import warnings from collections import defaultdict from pathlib import Path -from typing import Iterable, Literal, NamedTuple, TypedDict, TypeVar, cast +from typing import Any, Iterable, Literal, NamedTuple, TypedDict, TypeVar, cast import attrs import more_itertools as itx @@ -25,19 +25,18 @@ from pytest_mock import MockerFixture from snakemake.io import expand as sb_expand +from snakebids.core._querying import PostFilter, UnifiedFilter, get_matching_files from snakebids.core.datasets import BidsComponent, BidsDataset from snakebids.core.input_generation import ( _all_custom_paths, _gen_bids_layout, - _get_lists_from_bids, + _get_components, _normalize_database_args, _parse_bids_path, _parse_custom_path, - _Postfilter, - _UnifiedFilter, generate_inputs, ) -from snakebids.exceptions import ConfigError, RunError +from snakebids.exceptions import ConfigError, PybidsError, RunError from snakebids.paths._presets import bids from snakebids.tests import strategies as sb_st from snakebids.tests.helpers import ( @@ -49,6 +48,7 @@ example_if, get_bids_path, get_zip_list, + mock_data, reindex_dataset, ) from snakebids.types import InputsConfig @@ -159,6 +159,28 @@ def test_non_deprecated_text_in_reset_raises_error(self, pybidsdb_reset: bool): _normalize_database_args(None, pybidsdb_reset, None, None) +def test_regex_search_removed_from_filters(): + assert not len(UnifiedFilter.from_filter_dict({"regex_search": "foo"}).prefilters) + + +@given( + filters=st.dictionaries(st.text(), st.text() | st.booleans() | st.lists(st.text())) +) +def test_get_matching_files_skips_get_when_empty_prefilter(filters: dict[str, Any]): + assert ( + get_matching_files( + ..., # type: ignore + UnifiedFilter.from_filter_dict({**filters, "foo": []}), + ) + == [] + ) + + +def test_attribute_errors_from_pybids_qualified_and_raised(): + with pytest.raises(PybidsError, match="Pybids has encountered a problem"): + get_matching_files(..., UnifiedFilter.from_filter_dict({})) # type: ignore + + class TestFilterBools: @pytest.fixture(autouse=True) def bids_fs(self, bids_fs: FakeFilesystem | None): @@ -359,7 +381,7 @@ def test_entity_excluded_when_filter_true(self, tmpdir: Path, dataset: BidsDatas entity=sb_st.bids_entity(path_safe=True), data=st.data(), ) - def test_filter_works_when_false_in_list( + def test_text_filter_selects_paths_when_in_list_with_false( self, tmpdir: Path, template: BidsComponent, @@ -422,7 +444,7 @@ def add_entity(component: BidsComponent, entity: str, value: str): entity=sb_st.bids_entity(path_safe=True), data=st.data(), ) - def test_filter_blank_paths_when_false_in_list( + def test_filter_false_in_list_selects_paths( self, tmpdir: Path, template: BidsComponent, @@ -471,6 +493,327 @@ def add_entity(component: BidsComponent, entity: str, value: str): assert result == BidsDataset({"template": dataset["template"]}) +class TestFilterMethods: + @pytest.fixture(autouse=True) + def bids_fs(self, bids_fs: FakeFilesystem | None): + return bids_fs + + @pytest.fixture + def tmpdir(self, fakefs_tmpdir: Path): + return fakefs_tmpdir + + @example( + component=BidsComponent( + name="template", + path="sub-{subject}/sub-{subject}_mt-{mt}", + zip_lists={ + "subject": ["0", "00"], + "mt": ["on", "on"], + }, + ), + data=mock_data(["0"]), + ) + @given( + component=sb_st.bids_components( + name="template", + min_entities=2, + restrict_patterns=True, + unique=True, + extra_entities=False, + # pybids bug prevents regex matching from working properly with extension + blacklist_entities=["extension"], + ), + data=st.data(), + ) + @settings( + deadline=None, + suppress_health_check=[ + HealthCheck.function_scoped_fixture, + HealthCheck.too_slow, + ], + ) + def test_regex_match_selects_paths( + self, tmpdir: Path, component: BidsComponent, data: st.DataObject + ): + root = tempfile.mkdtemp(dir=tmpdir) + entity = itx.first(component.entities) + selection = data.draw( + st.lists( + st.sampled_from(component.entities[entity]), unique=True, min_size=1 + ) + ) + dataset = BidsDataset.from_iterable( + [attrs.evolve(component, path=os.path.join(root, component.path))] + ) + create_dataset("", dataset) + pybids_inputs: InputsConfig = { + "template": { + "wildcards": [ + BidsEntity.from_tag(wildcard).entity + for wildcard in component.wildcards + ], + "filters": { + BidsEntity.from_tag(entity).entity: { + "match": "|".join(re.escape(sel) for sel in selection) + } + }, + } + } + result = generate_inputs(root, pybids_inputs) + assert result == BidsDataset( + {"template": dataset["template"].filter(**{entity: selection})} + ) + + @given( + component=sb_st.bids_components( + name="template", + min_entities=2, + restrict_patterns=True, + unique=True, + extra_entities=False, + # only specified entities work with free text + whitelist_entities=[ + "subject", + "session", + "sample", + "task", + "acquisition", + "ceagent", + "staning", + "tracer", + "reconstruction", + "direction", + "proc", + "modality", + "recording", + "space", + "split", + "atlas", + "roi", + "label", + "from", + "to", + "res", + "den", + "model", + "subset", + "desc", + "tracksys", + ], + ), + ) + @settings( + deadline=None, + suppress_health_check=[ + HealthCheck.function_scoped_fixture, + HealthCheck.too_slow, + ], + ) + def test_regex_search_selects_paths(self, tmpdir: Path, component: BidsComponent): + root = tempfile.mkdtemp(dir=tmpdir) + entity = itx.first(component.entities) + assume(f"prefix{component[entity][0]}suffix" not in component.entities[entity]) + zip_lists = { + ent: ( + [*value, f"prefix{value[0]}suffix"] + if ent is entity + else [*value, value[0]] + ) + for ent, value in component.zip_lists.items() + } + dataset = BidsDataset.from_iterable( + [ + attrs.evolve( + component, + path=os.path.join(root, component.path), + zip_lists=zip_lists, + ) + ] + ) + create_dataset("", dataset) + pybids_inputs: InputsConfig = { + "template": { + "wildcards": [ + BidsEntity.from_tag(wildcard).entity + for wildcard in component.wildcards + ], + "filters": { + BidsEntity.from_tag(entity).entity: { + "search": "|".join( + re.escape(val) for val in component.entities[entity] + ) + } + }, + } + } + result = generate_inputs(root, pybids_inputs) + assert result == BidsDataset({"template": dataset["template"]}) + + @example( + component=BidsComponent( + name="template", + path="sub-{subject}/sub-{subject}_mt-{mt}", + zip_lists={ + "subject": ["0", "00"], + "mt": ["on", "on"], + }, + ), + data=mock_data(["0"]), + ) + @given( + component=sb_st.bids_components( + name="template", + min_entities=2, + restrict_patterns=True, + unique=True, + extra_entities=False, + ), + data=st.data(), + ) + @settings( + deadline=None, + suppress_health_check=[ + HealthCheck.function_scoped_fixture, + HealthCheck.too_slow, + ], + ) + def test_get_method_selects_via_direct_matching( + self, tmpdir: Path, component: BidsComponent, data: st.DataObject + ): + root = tempfile.mkdtemp(dir=tmpdir) + entity = itx.first(component.entities) + selection = data.draw( + st.lists( + st.sampled_from(component.entities[entity]), unique=True, min_size=1 + ) + ) + dataset = BidsDataset.from_iterable( + [attrs.evolve(component, path=os.path.join(root, component.path))] + ) + create_dataset("", dataset) + pybids_inputs: InputsConfig = { + "template": { + "wildcards": [ + BidsEntity.from_tag(wildcard).entity + for wildcard in component.wildcards + ], + "filters": {BidsEntity.from_tag(entity).entity: {"get": selection}}, + } + } + result = generate_inputs(root, pybids_inputs) + assert result == BidsDataset( + {"template": dataset["template"].filter(**{entity: selection})} + ) + + @given( + component=sb_st.bids_components( + name="template", + min_entities=2, + max_entities=2, + # Again, extension doesn't work with regex + blacklist_entities={"extension"}, + min_values=2, + restrict_patterns=True, + unique=True, + cull=False, + extra_entities=False, + ), + ) + @settings( + deadline=None, + suppress_health_check=[ + HealthCheck.function_scoped_fixture, + HealthCheck.too_slow, + ], + ) + def test_combining_match_and_get_selects_correct_paths( + self, tmpdir: Path, component: BidsComponent + ): + root = tempfile.mkdtemp(dir=tmpdir) + entity1 = itx.first(component.entities) + entity2 = itx.nth_or_last(component.entities, 1) + dataset = BidsDataset.from_iterable( + [attrs.evolve(component, path=os.path.join(root, component.path))] + ) + create_dataset("", dataset) + pybids_inputs: InputsConfig = { + "template": { + "wildcards": [ + BidsEntity.from_tag(wildcard).entity + for wildcard in component.wildcards + ], + "filters": { + BidsEntity.from_tag(entity1).entity: {"get": component[entity1][0]}, + BidsEntity.from_tag(entity2).entity: { + "match": re.escape(component[entity2][0]) + }, + }, + } + } + result = generate_inputs(root, pybids_inputs) + assert len(itx.first(itx.first(result.values()).zip_lists.values())) == 1 + + @given( + methods=st.lists( + st.sampled_from(["match", "get", "search"]), unique=True, min_size=2 + ) + ) + @allow_function_scoped + def test_filter_with_multiple_methods_raises_error( + self, tmpdir: Path, methods: list[str] + ): + dataset = BidsDataset.from_iterable( + [BidsComponent(zip_lists={}, name="template", path=str(tmpdir))] + ) + create_dataset("", dataset) + pybids_inputs: InputsConfig = { + "template": { + "filters": { + "foo": {method: "foo" for method in methods} # type: ignore + }, + } + } + with pytest.raises(ConfigError, match="may not have more than one key"): + generate_inputs(tmpdir, pybids_inputs) + + @pytest.mark.disable_fakefs(True) + def test_filter_with_no_methods_raises_error(self, tmpdir: Path): + dataset = BidsDataset.from_iterable( + [BidsComponent(zip_lists={}, name="template", path=str(tmpdir))] + ) + create_dataset("", dataset) + pybids_inputs: InputsConfig = { + "template": { + "filters": {"foo": {}}, + } + } + with pytest.raises(ConfigError, match="was not given any keys"): + generate_inputs(tmpdir, pybids_inputs) + + @given( + method=st.text().filter(lambda s: s not in {"get", "match", "search"}), + ) + @settings( + deadline=None, + suppress_health_check=[ + HealthCheck.function_scoped_fixture, + HealthCheck.too_slow, + ], + ) + def test_filter_with_invalid_method_raises_error(self, tmpdir: Path, method: str): + dataset = BidsDataset.from_iterable( + [BidsComponent(zip_lists={}, name="template", path=str(tmpdir))] + ) + create_dataset("", dataset) + pybids_inputs: InputsConfig = { + "template": { + "filters": {"foo": {method: []}}, # type: ignore + } + } + with pytest.raises(ConfigError, match="Invalid query method specified"): + generate_inputs(tmpdir, pybids_inputs) + + class TestAbsentConfigEntries: def get_entities(self, root: Path): # Generate directory @@ -543,7 +886,7 @@ class TestPostfilter: def test_throws_error_if_labels_and_excludes_are_given( self, args: tuple[list[str] | str, list[str] | str] ): - filters = _Postfilter() + filters = PostFilter() with pytest.raises( ValueError, match="Cannot define both participant_label and exclude_participant_label ", @@ -552,7 +895,7 @@ def test_throws_error_if_labels_and_excludes_are_given( @given(st.text(), st_lists_or_text) def test_returns_participant_label_as_dict(self, key: str, label: list[str] | str): - filters = _Postfilter() + filters = PostFilter() filters.add_filter(key, label, None) if isinstance(label, str): assert filters.inclusions == {key: [label]} @@ -569,7 +912,7 @@ def test_returns_participant_label_as_dict(self, key: str, label: list[str] | st def test_exclude_gives_regex_that_matches_anything_except_exclude( self, key: str, excluded: list[str] | str, dummy_values: list[str], padding: str ): - filters = _Postfilter() + filters = PostFilter() # Make sure the dummy_values and padding we'll be testing against are different # from our test values for value in dummy_values: @@ -693,7 +1036,7 @@ def test_benchmark_test_custom_paths(self, benchmark: Benchmark, tmp_path: Path) entities = {"A": ["A", "B", "C"], "B": ["1", "2", "3"]} template = Path("{A}/A-{A}_B-{B}") test_path = self.generate_test_directory(entities, template, tmp_path) - benchmark(_parse_custom_path, test_path, _UnifiedFilter.from_filter_dict({})) + benchmark(_parse_custom_path, test_path, UnifiedFilter.from_filter_dict({})) @allow_function_scoped @given(path_entities=path_entities()) @@ -706,7 +1049,7 @@ def test_collects_all_paths_when_no_filters( test_path = self.generate_test_directory(entities, template, temp_dir) # Test without any filters - result = _parse_custom_path(test_path, _UnifiedFilter.from_filter_dict({})) + result = _parse_custom_path(test_path, UnifiedFilter.from_filter_dict({})) zip_lists = get_zip_list(entities, it.product(*entities.values())) assert BidsComponent( name="foo", path=get_bids_path(zip_lists), zip_lists=zip_lists @@ -726,7 +1069,7 @@ def test_collects_only_filtered_entities( # Test with filters result_filtered = MultiSelectDict( - _parse_custom_path(test_path, _UnifiedFilter.from_filter_dict(filters)) + _parse_custom_path(test_path, UnifiedFilter.from_filter_dict(filters)) ) zip_lists = MultiSelectDict( { @@ -752,12 +1095,12 @@ def test_collect_all_but_filters_when_exclusion_filters_used( entities, template, filters = path_entities test_path = self.generate_test_directory(entities, template, temp_dir) # Test with exclusion filters - exclude_filters = _Postfilter() + exclude_filters = PostFilter() for key, values in filters.items(): exclude_filters.add_filter(key, None, values) result_excluded = MultiSelectDict( _parse_custom_path( - test_path, _UnifiedFilter.from_filter_dict({}, exclude_filters) + test_path, UnifiedFilter.from_filter_dict({}, exclude_filters) ) ) @@ -780,6 +1123,29 @@ def test_collect_all_but_filters_when_exclusion_filters_used( name="foo", path=get_bids_path(result_excluded), zip_lists=result_excluded ) + @given( + boolean=st.booleans(), + filter=st.none() | st.lists(st.text()), + path_entities=path_entities(), + ) + @allow_function_scoped + def test_errors_when_bools_given_as_filters( + self, + temp_dir: Path, + path_entities: PathEntities, + boolean: bool, + filter: list[str] | None, + ): + entities, template, _ = path_entities + test_path = self.generate_test_directory(entities, template, temp_dir) + with pytest.raises(ValueError, match="Boolean filters in items with custom "): + _parse_custom_path( + test_path, + UnifiedFilter.from_filter_dict( + {"foo": boolean if filter is None else [*filter, boolean]} + ), + ) + def test_custom_pybids_config(tmpdir: Path): # Generate directory @@ -1115,7 +1481,11 @@ def test_get_lists_from_bids_raises_run_error(): } with pytest.raises(RunError): next( - _get_lists_from_bids(bids_layout, pybids_inputs, postfilters=_Postfilter()) + _get_components( + bids_layout=bids_layout, + inputs_config=pybids_inputs, + postfilters=PostFilter(), + ) ) @@ -1155,7 +1525,9 @@ def test_get_lists_from_bids(): pybids_inputs["t1"]["custom_path"] = wildcard_path_t1 pybids_inputs["t2"]["custom_path"] = wildcard_path_t2 - result = _get_lists_from_bids(layout, pybids_inputs, postfilters=_Postfilter()) + result = _get_components( + bids_layout=layout, inputs_config=pybids_inputs, postfilters=PostFilter() + ) for bids_lists in result: if bids_lists.input_name == "t1": template = BidsComponent( diff --git a/snakebids/tests/test_template.py b/snakebids/tests/test_template.py index 6bdea58c..33f7bc13 100644 --- a/snakebids/tests/test_template.py +++ b/snakebids/tests/test_template.py @@ -306,7 +306,9 @@ def test_template_dry_runs_successfully(tmp_path: Path, build: BuildBackend, ven assert "All set" in cmd.stdout.decode() -def test_template_dry_runs_with_current_repository(tmp_path: Path): +def test_template_dry_runs_with_current_repository( + tmp_path: Path, request: pytest.FixtureRequest +): app_name = "snakebids_app" data = get_empty_data(app_name, "setuptools") @@ -316,16 +318,18 @@ def test_template_dry_runs_with_current_repository(tmp_path: Path): data=data, unsafe=True, ) - app_path = tmp_path / app_name cmd = sp.run( [ - sys.executable, - app_path / app_name / "run.py", - app_path / "tests/data", - app_path / "tests/results", - "participant", - "-c1", - "--skip-bids-validation", + "docker", + "run", + "-v", + f"{tmp_path / app_name}:/app", + "-v", + f"{request.config.rootpath}:/src", + "--rm", + f"snakebids/test-template:{platform.python_version()}", + "setuptools", + app_name, ], capture_output=True, check=False, diff --git a/snakebids/types.py b/snakebids/types.py index fa750b85..cc3243ff 100644 --- a/snakebids/types.py +++ b/snakebids/types.py @@ -12,10 +12,22 @@ _S_co = TypeVar("_S_co", covariant=True) +class FilterSpec(TypedDict, total=False): + """Optional filter specification allowing regex matching.""" + + get: str | bool | Sequence[str | bool] + match: str + search: str + + +FilterValue: TypeAlias = "str | bool | Sequence[str | bool] | FilterSpec" +FilterMap: TypeAlias = "Mapping[str, FilterValue]" + + class InputConfig(TypedDict, total=False): """Configuration for a single bids component.""" - filters: Mapping[str, str | bool | Sequence[str | bool]] + filters: FilterMap """Filters to pass on to :class:`BIDSLayout.get() ` Each key refers to the name of an entity. Values may take the following forms: