From 01f593c41de73b3b62dae5e8ae5cec4e79d3d0ce Mon Sep 17 00:00:00 2001 From: Miles Mason Winther <42948872+mmwinther@users.noreply.github.com> Date: Wed, 16 Aug 2023 16:10:10 +0200 Subject: [PATCH] Use Ruff (#94) * Replace linting tools with ruff * Ruff auto-fixes * Fix lint errors in app.py and DataDocMetadata.py * Use 3.11 in workflow * Fix lint errors in DatasetParser.py * Add import annotations where needed * Fix lint errors in ModelBackwardsCompatibility.py * Fix lint errors in StorageAdapter.py * Remove unused code * Fix tests * Fix lint errors in callbacks package * Fix lint errors in fields package * Fix lint errors in components package * Fix lint errors in tests package * Tidy final lint errors * Rename to datadoc_metadata.py * Rename to dataset_parser.py * Rename to model_backwards_compatibility.py * Rename to storage_adapter.py * Rename to alerts.py * Fix tests * Rename to builders.py * Rename to control_bars.py * Rename to dataset_tab.py * Rename to variables_tab.py * Rename to display_base.py * Rename to display_dataset.py * Rename to display_variables.py * Add VSCode config to gitignore * Actually rename files --- .github/workflows/unit-tests.yml | 9 +- .gitignore | 6 +- .pre-commit-config.yaml | 40 +- .vscode/settings.json | 2 +- SECURITY.md | 2 +- datadoc/__init__.py | 4 +- datadoc/app.py | 47 ++- datadoc/assets/bootstrap.min.css | 2 +- datadoc/backend/DatasetParser.py | 174 -------- .../backend/ModelBackwardsCompatibility.py | 67 ---- datadoc/backend/StorageAdapter.py | 117 ------ datadoc/backend/VariableDefinition.py | 34 -- datadoc/backend/__init__.py | 1 + ...DataDocMetadata.py => datadoc_metadata.py} | 154 +++++--- datadoc/backend/dataset_parser.py | 212 ++++++++++ .../backend/model_backwards_compatibility.py | 100 +++++ datadoc/backend/storage_adapter.py | 160 ++++++++ datadoc/frontend/callbacks/__init__.py | 8 + datadoc/frontend/callbacks/dataset.py | 129 ++++-- .../frontend/callbacks/register_callbacks.py | 114 +++--- datadoc/frontend/callbacks/utils.py | 36 +- datadoc/frontend/callbacks/variables.py | 113 +++--- datadoc/frontend/components/__init__.py | 4 + .../components/{Alerts.py => alerts.py} | 16 +- .../components/{Builders.py => builders.py} | 21 +- .../{HeaderBars.py => control_bars.py} | 31 +- .../{DatasetTab.py => dataset_tab.py} | 29 +- .../{VariablesTab.py => variables_tab.py} | 17 +- datadoc/frontend/fields/DisplayBase.py | 74 ---- datadoc/frontend/fields/__init__.py | 1 + datadoc/frontend/fields/display_base.py | 104 +++++ .../{DisplayDataset.py => display_dataset.py} | 17 +- ...splayVariables.py => display_variables.py} | 8 +- datadoc/gunicorn.conf.py | 2 + datadoc/state.py | 20 +- datadoc/tests/__init__.py | 1 + datadoc/tests/conftest.py | 85 ++-- datadoc/tests/pytest.ini | 2 +- datadoc/tests/test_callbacks.py | 77 ++-- datadoc/tests/test_datadoc_metadata.py | 80 ++-- datadoc/tests/test_dataset_parser.py | 56 +-- datadoc/tests/test_model.py | 11 +- .../test_model_backwards_compatibility.py | 21 +- datadoc/tests/test_smoke.py | 6 +- datadoc/tests/test_storage_adapter.py | 45 ++- datadoc/tests/test_utils.py | 9 +- datadoc/tests/utils.py | 2 + datadoc/utils.py | 30 +- datadoc/wsgi.py | 2 + poetry.lock | 374 ++---------------- pyproject.toml | 83 ++-- 51 files changed, 1430 insertions(+), 1329 deletions(-) delete mode 100644 datadoc/backend/DatasetParser.py delete mode 100644 datadoc/backend/ModelBackwardsCompatibility.py delete mode 100644 datadoc/backend/StorageAdapter.py delete mode 100644 datadoc/backend/VariableDefinition.py create mode 100644 datadoc/backend/__init__.py rename datadoc/backend/{DataDocMetadata.py => datadoc_metadata.py} (54%) create mode 100644 datadoc/backend/dataset_parser.py create mode 100644 datadoc/backend/model_backwards_compatibility.py create mode 100644 datadoc/backend/storage_adapter.py create mode 100644 datadoc/frontend/callbacks/__init__.py create mode 100644 datadoc/frontend/components/__init__.py rename datadoc/frontend/components/{Alerts.py => alerts.py} (61%) rename datadoc/frontend/components/{Builders.py => builders.py} (71%) rename datadoc/frontend/components/{HeaderBars.py => control_bars.py} (82%) rename datadoc/frontend/components/{DatasetTab.py => dataset_tab.py} (69%) rename datadoc/frontend/components/{VariablesTab.py => variables_tab.py} (86%) delete mode 100644 datadoc/frontend/fields/DisplayBase.py create mode 100644 datadoc/frontend/fields/__init__.py create mode 100644 datadoc/frontend/fields/display_base.py rename datadoc/frontend/fields/{DisplayDataset.py => display_dataset.py} (96%) rename datadoc/frontend/fields/{DisplayVariables.py => display_variables.py} (96%) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index ba69979d..e6793661 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-python@v1 with: - python-version: "3.10" + python-version: "3.11" - uses: Gr1N/setup-poetry@v8 - uses: actions/cache@v2 with: @@ -31,12 +31,9 @@ jobs: - name: Install dependencies run: | poetry install --all-extras - - name: Lint with flake8 + - name: Commit hooks run: | - # stop the build if there are Python syntax errors or undefined names - poetry run pflake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - poetry run pflake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + poetry run pre-commit run --all-files - name: Run unit tests run: | set -o pipefail; poetry run pytest -v --cache-clear --junitxml=pytest.xml --cov-report=term-missing --cov=datadoc | tee pytest-coverage.txt diff --git a/.gitignore b/.gitignore index e8268a22..a67ec736 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,12 @@ # The file created from the example parquet file klargjorte_data/person_data_v1__DOC.json -# This file is changed every time we run it, avoid that being committed -DataDoc.ipynb - # Jetbrains IDE config .idea/ +# VSCode config +.vscode/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 48153153..7de3b104 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.4.0 hooks: - id: check-ast - id: check-added-large-files @@ -13,30 +13,18 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - id: mixed-line-ending - - repo: local + - repo: https://github.com/psf/black + rev: 23.7.0 hooks: - - id: autoflake - name: autoflake - entry: poetry run autoflake -r -i --remove-all-unused-imports --remove-unused-variables - language: system - types: [python] - - id: isort - name: isort - entry: poetry run isort - language: system - types: [python] - id: black - name: black - entry: poetry run black - language: system - types: [python] - - id: pyupgrade - name: pyupgrade - entry: poetry run pyupgrade --py37-plus - language: system - types: [python] - - id: flake8 - name: flake8 - entry: poetry run pflake8 - language: system - types: [python] + # It is recommended to specify the latest version of Python + # supported by your project here, or alternatively use + # pre-commit's default_language_version, see + # https://pre-commit.com/#top_level-default_language_version + language_version: python3.11 + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.0.283 + hooks: + - id: ruff + args: [ --fix, --exit-non-zero-on-fix ] diff --git a/.vscode/settings.json b/.vscode/settings.json index 3876d4a7..5e625045 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,7 @@ "--max-line-length=88", "--ignore=E402,F841,F401,E302,E305,W503,E501" ], - "python.linting.enabled": true, + "python.linting.enabled": false, "python.linting.pylintEnabled": false, "python.linting.mypyEnabled": false, "python.languageServer": "Pylance", diff --git a/SECURITY.md b/SECURITY.md index dea51de7..c0e649fd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy -SSB takes the security of our software products and services seriously, which +SSB takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organization. We believe that responsible disclosure of security vulnerabilities helps us ensure diff --git a/datadoc/__init__.py b/datadoc/__init__.py index e26e3bd3..fedb4f6f 100644 --- a/datadoc/__init__.py +++ b/datadoc/__init__.py @@ -1 +1,3 @@ -from datadoc.app import main # noqa +"""Datadoc: Document datasets in Statistics Norway.""" + +from datadoc.app import main diff --git a/datadoc/app.py b/datadoc/app.py index c75e77b8..64baf6a8 100644 --- a/datadoc/app.py +++ b/datadoc/app.py @@ -1,29 +1,35 @@ +"""Top-level entrypoint, configuration and layout for the datadoc app. + +Members of this module should not be imported into any sub-modules, this will cause circular imports. +""" +from __future__ import annotations + import logging -import os +from pathlib import Path import dash_bootstrap_components as dbc from dash import Dash from datadoc_model.Enums import SupportedLanguages from flask_healthz import healthz -import datadoc.state as state -from datadoc.backend.DataDocMetadata import DataDocMetadata +from datadoc import state +from datadoc.backend.datadoc_metadata import DataDocMetadata from datadoc.frontend.callbacks.register_callbacks import register_callbacks -from datadoc.frontend.components.Alerts import ( +from datadoc.frontend.components.alerts import ( dataset_validation_error, opened_dataset_error, opened_dataset_success, saved_metadata_success, variables_validation_error, ) -from datadoc.frontend.components.DatasetTab import get_dataset_tab -from datadoc.frontend.components.HeaderBars import ( - get_controls_bar, - get_language_dropdown, +from datadoc.frontend.components.control_bars import ( + build_controls_bar, + build_language_dropdown, header, progress_bar, ) -from datadoc.frontend.components.VariablesTab import get_variables_tab +from datadoc.frontend.components.dataset_tab import build_dataset_tab +from datadoc.frontend.components.variables_tab import build_variables_tab from datadoc.utils import get_app_version, pick_random_port, running_in_notebook logger = logging.getLogger(__name__) @@ -32,10 +38,11 @@ def build_app() -> Dash: + """Instantiate the Dash app object, define the layout, register callbacks.""" app = Dash( name=NAME, title=NAME, - assets_folder=f"{os.path.dirname(__file__)}/assets", + assets_folder=f"{Path(__file__).parent}/assets", ) app.layout = dbc.Container( @@ -43,7 +50,7 @@ def build_app() -> Dash: children=[ header, progress_bar, - get_controls_bar(), + build_controls_bar(), variables_validation_error, dataset_validation_error, opened_dataset_error, @@ -56,13 +63,13 @@ def build_app() -> Dash: id="tabs", class_name="ssb-tabs", children=[ - get_dataset_tab(), - get_variables_tab(), + build_dataset_tab(), + build_variables_tab(), ], ), ], ), - get_language_dropdown(), + build_language_dropdown(), ], ) @@ -71,9 +78,10 @@ def build_app() -> Dash: return app -def get_app(dataset_path: str = None) -> Dash: +def get_app(dataset_path: str | None = None) -> Dash: + """Centralize all the ugliness around initializing the app.""" logging.basicConfig(level=logging.INFO, force=True) - logger.info(f"Datadoc version v{get_app_version()}") + logger.info("Datadoc version v%s", get_app_version()) state.current_metadata_language = SupportedLanguages.NORSK_BOKMÅL state.metadata = DataDocMetadata(dataset_path) app = build_app() @@ -88,15 +96,16 @@ def get_app(dataset_path: str = None) -> Dash: return app -def main(dataset_path: str = None): +def main(dataset_path: str | None = None) -> None: + """Entrypoint when running as a script.""" logging.basicConfig(level=logging.DEBUG, force=True) - logger.info(f"Starting app with {dataset_path = }") + logger.info("Starting app with dataset_path = %s", dataset_path) app = get_app(dataset_path) if running_in_notebook(): logger.info("Running in notebook") port = pick_random_port() app.run(jupyter_height=1000, port=port) - logger.info(f"Server running on port {port}") + logger.info("Server running on port %s", port) else: # Assume running in server mode is better (largely for development purposes) logging.basicConfig(level=logging.DEBUG, force=True) diff --git a/datadoc/assets/bootstrap.min.css b/datadoc/assets/bootstrap.min.css index 1472dec0..278436bc 100644 --- a/datadoc/assets/bootstrap.min.css +++ b/datadoc/assets/bootstrap.min.css @@ -4,4 +4,4 @@ * Copyright 2011-2021 Twitter, Inc. * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) */:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:#6c757d}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{width:100%;padding-right:var(--bs-gutter-x,.75rem);padding-left:var(--bs-gutter-x,.75rem);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.6666666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.6666666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.6666666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.6666666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.3333333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.6666666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-bg:transparent;--bs-table-accent-bg:transparent;--bs-table-striped-color:#212529;--bs-table-striped-bg:rgba(0, 0, 0, 0.05);--bs-table-active-color:#212529;--bs-table-active-bg:rgba(0, 0, 0, 0.1);--bs-table-hover-color:#212529;--bs-table-hover-bg:rgba(0, 0, 0, 0.075);width:100%;margin-bottom:1rem;color:#212529;vertical-align:top;border-color:#dee2e6}.table>:not(caption)>*>*{padding:.5rem .5rem;background-color:var(--bs-table-bg);border-bottom-width:1px;box-shadow:inset 0 0 0 9999px var(--bs-table-accent-bg)}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table>:not(:first-child){border-top:2px solid currentColor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:1px 0}.table-bordered>:not(caption)>*>*{border-width:0 1px}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-accent-bg:var(--bs-table-striped-bg);color:var(--bs-table-striped-color)}.table-active{--bs-table-accent-bg:var(--bs-table-active-bg);color:var(--bs-table-active-color)}.table-hover>tbody>tr:hover>*{--bs-table-accent-bg:var(--bs-table-hover-bg);color:var(--bs-table-hover-color)}.table-primary{--bs-table-bg:#cfe2ff;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:#000;border-color:#bacbe6}.table-secondary{--bs-table-bg:#e2e3e5;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:#000;border-color:#cbccce}.table-success{--bs-table-bg:#d1e7dd;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:#000;border-color:#bcd0c7}.table-info{--bs-table-bg:#cff4fc;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:#000;border-color:#badce3}.table-warning{--bs-table-bg:#fff3cd;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:#000;border-color:#e6dbb9}.table-danger{--bs-table-bg:#f8d7da;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:#000;border-color:#dfc2c4}.table-light{--bs-table-bg:#f8f9fa;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:#000;border-color:#dfe0e1}.table-dark{--bs-table-bg:#212529;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:#fff;border-color:#373b3e}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:#6c757d}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:#212529;background-color:#fff;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{height:1.5em}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:#dde0e3}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:#212529;background-color:#e9ecef;pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:1px;border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:#dde0e3}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + 2px)}textarea.form-control-sm{min-height:calc(1.5em + .5rem + 2px)}textarea.form-control-lg{min-height:calc(1.5em + 1rem + 2px)}.form-control-color{width:3rem;height:auto;padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{height:1.5em;border-radius:.25rem}.form-control-color::-webkit-color-swatch{height:1.5em;border-radius:.25rem}.form-select{display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;-moz-padding-start:calc(0.75rem - 3px);font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:#e9ecef}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #212529}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:.2rem}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:.3rem}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-input{width:1em;height:1em;margin-top:.25em;vertical-align:top;background-color:#fff;background-repeat:no-repeat;background-position:center;background-size:contain;border:1px solid rgba(0,0,0,.25);-webkit-appearance:none;-moz-appearance:none;appearance:none;-webkit-print-color-adjust:exact;color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{width:2em;margin-left:-2.5em;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}.form-range{width:100%;height:1.5rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.form-range:disabled::-moz-range-thumb{background-color:#adb5bd}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-select{height:calc(3.5rem + 2px);line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;height:100%;padding:1rem .75rem;pointer-events:none;border:1px solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control{padding:1rem .75rem}.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{opacity:.65;transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-select:focus{z-index:3}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:3}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:-1px;border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#198754}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(25,135,84,.9);border-radius:.25rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#198754;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:#198754}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:#198754;box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:#198754}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:#198754}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#198754}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group .form-control.is-valid,.input-group .form-select.is-valid,.was-validated .input-group .form-control:valid,.was-validated .input-group .form-select:valid{z-index:1}.input-group .form-control.is-valid:focus,.input-group .form-select.is-valid:focus,.was-validated .input-group .form-control:valid:focus,.was-validated .input-group .form-select:valid:focus{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:#dc3545}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{padding-right:4.125rem;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e"),url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:#dc3545}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:#dc3545}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group .form-control.is-invalid,.input-group .form-select.is-invalid,.was-validated .input-group .form-control:invalid,.was-validated .input-group .form-select:invalid{z-index:2}.input-group .form-control.is-invalid:focus,.input-group .form-select.is-invalid:focus,.was-validated .input-group .form-control:invalid:focus,.was-validated .input-group .form-select:invalid:focus{z-index:3}.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529}.btn-check:focus+.btn,.btn:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{pointer-events:none;opacity:.65}.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-primary:hover{color:#fff;background-color:#0b5ed7;border-color:#0a58ca}.btn-check:focus+.btn-primary,.btn-primary:focus{color:#fff;background-color:#0b5ed7;border-color:#0a58ca;box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-check:active+.btn-primary,.btn-check:checked+.btn-primary,.btn-primary.active,.btn-primary:active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0a58ca;border-color:#0a53be}.btn-check:active+.btn-primary:focus,.btn-check:checked+.btn-primary:focus,.btn-primary.active:focus,.btn-primary:active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(49,132,253,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5c636a;border-color:#565e64}.btn-check:focus+.btn-secondary,.btn-secondary:focus{color:#fff;background-color:#5c636a;border-color:#565e64;box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-check:active+.btn-secondary,.btn-check:checked+.btn-secondary,.btn-secondary.active,.btn-secondary:active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#565e64;border-color:#51585e}.btn-check:active+.btn-secondary:focus,.btn-check:checked+.btn-secondary:focus,.btn-secondary.active:focus,.btn-secondary:active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-success{color:#fff;background-color:#198754;border-color:#198754}.btn-success:hover{color:#fff;background-color:#157347;border-color:#146c43}.btn-check:focus+.btn-success,.btn-success:focus{color:#fff;background-color:#157347;border-color:#146c43;box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-check:active+.btn-success,.btn-check:checked+.btn-success,.btn-success.active,.btn-success:active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#146c43;border-color:#13653f}.btn-check:active+.btn-success:focus,.btn-check:checked+.btn-success:focus,.btn-success.active:focus,.btn-success:active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(60,153,110,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#198754;border-color:#198754}.btn-info{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-info:hover{color:#000;background-color:#31d2f2;border-color:#25cff2}.btn-check:focus+.btn-info,.btn-info:focus{color:#000;background-color:#31d2f2;border-color:#25cff2;box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-check:active+.btn-info,.btn-check:checked+.btn-info,.btn-info.active,.btn-info:active,.show>.btn-info.dropdown-toggle{color:#000;background-color:#3dd5f3;border-color:#25cff2}.btn-check:active+.btn-info:focus,.btn-check:checked+.btn-info:focus,.btn-info.active:focus,.btn-info:active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(11,172,204,.5)}.btn-info.disabled,.btn-info:disabled{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-warning{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#000;background-color:#ffca2c;border-color:#ffc720}.btn-check:focus+.btn-warning,.btn-warning:focus{color:#000;background-color:#ffca2c;border-color:#ffc720;box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-check:active+.btn-warning,.btn-check:checked+.btn-warning,.btn-warning.active,.btn-warning:active,.show>.btn-warning.dropdown-toggle{color:#000;background-color:#ffcd39;border-color:#ffc720}.btn-check:active+.btn-warning:focus,.btn-check:checked+.btn-warning:focus,.btn-warning.active:focus,.btn-warning:active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(217,164,6,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#bb2d3b;border-color:#b02a37}.btn-check:focus+.btn-danger,.btn-danger:focus{color:#fff;background-color:#bb2d3b;border-color:#b02a37;box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-check:active+.btn-danger,.btn-check:checked+.btn-danger,.btn-danger.active,.btn-danger:active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#b02a37;border-color:#a52834}.btn-check:active+.btn-danger:focus,.btn-check:checked+.btn-danger:focus,.btn-danger.active:focus,.btn-danger:active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-light{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:focus+.btn-light,.btn-light:focus{color:#000;background-color:#f9fafb;border-color:#f9fafb;box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-check:active+.btn-light,.btn-check:checked+.btn-light,.btn-light.active,.btn-light:active,.show>.btn-light.dropdown-toggle{color:#000;background-color:#f9fafb;border-color:#f9fafb}.btn-check:active+.btn-light:focus,.btn-check:checked+.btn-light:focus,.btn-light.active:focus,.btn-light:active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(211,212,213,.5)}.btn-light.disabled,.btn-light:disabled{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-dark{color:#fff;background-color:#212529;border-color:#212529}.btn-dark:hover{color:#fff;background-color:#1c1f23;border-color:#1a1e21}.btn-check:focus+.btn-dark,.btn-dark:focus{color:#fff;background-color:#1c1f23;border-color:#1a1e21;box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-check:active+.btn-dark,.btn-check:checked+.btn-dark,.btn-dark.active,.btn-dark:active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1a1e21;border-color:#191c1f}.btn-check:active+.btn-dark:focus,.btn-check:checked+.btn-dark:focus,.btn-dark.active:focus,.btn-dark:active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .25rem rgba(66,70,73,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#212529;border-color:#212529}.btn-outline-primary{color:#0d6efd;border-color:#0d6efd}.btn-outline-primary:hover{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:focus+.btn-outline-primary,.btn-outline-primary:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-check:active+.btn-outline-primary,.btn-check:checked+.btn-outline-primary,.btn-outline-primary.active,.btn-outline-primary.dropdown-toggle.show,.btn-outline-primary:active{color:#fff;background-color:#0d6efd;border-color:#0d6efd}.btn-check:active+.btn-outline-primary:focus,.btn-check:checked+.btn-outline-primary:focus,.btn-outline-primary.active:focus,.btn-outline-primary.dropdown-toggle.show:focus,.btn-outline-primary:active:focus{box-shadow:0 0 0 .25rem rgba(13,110,253,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#0d6efd;background-color:transparent}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:focus+.btn-outline-secondary,.btn-outline-secondary:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-check:active+.btn-outline-secondary,.btn-check:checked+.btn-outline-secondary,.btn-outline-secondary.active,.btn-outline-secondary.dropdown-toggle.show,.btn-outline-secondary:active{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-check:active+.btn-outline-secondary:focus,.btn-check:checked+.btn-outline-secondary:focus,.btn-outline-secondary.active:focus,.btn-outline-secondary.dropdown-toggle.show:focus,.btn-outline-secondary:active:focus{box-shadow:0 0 0 .25rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-success{color:#198754;border-color:#198754}.btn-outline-success:hover{color:#fff;background-color:#198754;border-color:#198754}.btn-check:focus+.btn-outline-success,.btn-outline-success:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-check:active+.btn-outline-success,.btn-check:checked+.btn-outline-success,.btn-outline-success.active,.btn-outline-success.dropdown-toggle.show,.btn-outline-success:active{color:#fff;background-color:#198754;border-color:#198754}.btn-check:active+.btn-outline-success:focus,.btn-check:checked+.btn-outline-success:focus,.btn-outline-success.active:focus,.btn-outline-success.dropdown-toggle.show:focus,.btn-outline-success:active:focus{box-shadow:0 0 0 .25rem rgba(25,135,84,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#198754;background-color:transparent}.btn-outline-info{color:#0dcaf0;border-color:#0dcaf0}.btn-outline-info:hover{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:focus+.btn-outline-info,.btn-outline-info:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-check:active+.btn-outline-info,.btn-check:checked+.btn-outline-info,.btn-outline-info.active,.btn-outline-info.dropdown-toggle.show,.btn-outline-info:active{color:#000;background-color:#0dcaf0;border-color:#0dcaf0}.btn-check:active+.btn-outline-info:focus,.btn-check:checked+.btn-outline-info:focus,.btn-outline-info.active:focus,.btn-outline-info.dropdown-toggle.show:focus,.btn-outline-info:active:focus{box-shadow:0 0 0 .25rem rgba(13,202,240,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#0dcaf0;background-color:transparent}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:focus+.btn-outline-warning,.btn-outline-warning:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-check:active+.btn-outline-warning,.btn-check:checked+.btn-outline-warning,.btn-outline-warning.active,.btn-outline-warning.dropdown-toggle.show,.btn-outline-warning:active{color:#000;background-color:#ffc107;border-color:#ffc107}.btn-check:active+.btn-outline-warning:focus,.btn-check:checked+.btn-outline-warning:focus,.btn-outline-warning.active:focus,.btn-outline-warning.dropdown-toggle.show:focus,.btn-outline-warning:active:focus{box-shadow:0 0 0 .25rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:focus+.btn-outline-danger,.btn-outline-danger:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-check:active+.btn-outline-danger,.btn-check:checked+.btn-outline-danger,.btn-outline-danger.active,.btn-outline-danger.dropdown-toggle.show,.btn-outline-danger:active{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-check:active+.btn-outline-danger:focus,.btn-check:checked+.btn-outline-danger:focus,.btn-outline-danger.active:focus,.btn-outline-danger.dropdown-toggle.show:focus,.btn-outline-danger:active:focus{box-shadow:0 0 0 .25rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:focus+.btn-outline-light,.btn-outline-light:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-check:active+.btn-outline-light,.btn-check:checked+.btn-outline-light,.btn-outline-light.active,.btn-outline-light.dropdown-toggle.show,.btn-outline-light:active{color:#000;background-color:#f8f9fa;border-color:#f8f9fa}.btn-check:active+.btn-outline-light:focus,.btn-check:checked+.btn-outline-light:focus,.btn-outline-light.active:focus,.btn-outline-light.dropdown-toggle.show:focus,.btn-outline-light:active:focus{box-shadow:0 0 0 .25rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-dark{color:#212529;border-color:#212529}.btn-outline-dark:hover{color:#fff;background-color:#212529;border-color:#212529}.btn-check:focus+.btn-outline-dark,.btn-outline-dark:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-check:active+.btn-outline-dark,.btn-check:checked+.btn-outline-dark,.btn-outline-dark.active,.btn-outline-dark.dropdown-toggle.show,.btn-outline-dark:active{color:#fff;background-color:#212529;border-color:#212529}.btn-check:active+.btn-outline-dark:focus,.btn-check:checked+.btn-outline-dark:focus,.btn-outline-dark.active:focus,.btn-outline-dark.dropdown-toggle.show:focus,.btn-outline-dark:active:focus{box-shadow:0 0 0 .25rem rgba(33,37,41,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#212529;background-color:transparent}.btn-link{font-weight:400;color:#0d6efd;text-decoration:underline}.btn-link:hover{color:#0a58ca}.btn-link.disabled,.btn-link:disabled{color:#6c757d}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.2rem}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropend,.dropstart,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;z-index:1000;display:none;min-width:10rem;padding:.5rem 0;margin:0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:.125rem}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid rgba(0,0,0,.15)}.dropdown-item{display:block;width:100%;padding:.25rem 1rem;clear:both;font-weight:400;color:#212529;text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#1e2125;background-color:#e9ecef}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#0d6efd}.dropdown-item.disabled,.dropdown-item:disabled{color:#adb5bd;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1rem;color:#212529}.dropdown-menu-dark{color:#dee2e6;background-color:#343a40;border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item{color:#dee2e6}.dropdown-menu-dark .dropdown-item:focus,.dropdown-menu-dark .dropdown-item:hover{color:#fff;background-color:rgba(255,255,255,.15)}.dropdown-menu-dark .dropdown-item.active,.dropdown-menu-dark .dropdown-item:active{color:#fff;background-color:#0d6efd}.dropdown-menu-dark .dropdown-item.disabled,.dropdown-menu-dark .dropdown-item:disabled{color:#adb5bd}.dropdown-menu-dark .dropdown-divider{border-color:rgba(0,0,0,.15)}.dropdown-menu-dark .dropdown-item-text{color:#dee2e6}.dropdown-menu-dark .dropdown-header{color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem;color:#0d6efd;text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:#0a58ca}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-link{margin-bottom:-1px;background:0 0;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6;isolation:isolate}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{background:0 0;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#0d6efd}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding-top:.5rem;padding-bottom:.5rem}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;text-decoration:none;white-space:nowrap}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem;transition:box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 .25rem}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas-header{display:none}.navbar-expand-sm .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-sm .offcanvas-bottom,.navbar-expand-sm .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas-header{display:none}.navbar-expand-md .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-md .offcanvas-bottom,.navbar-expand-md .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas-header{display:none}.navbar-expand-lg .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-lg .offcanvas-bottom,.navbar-expand-lg .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas-header{display:none}.navbar-expand-xl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xl .offcanvas-bottom,.navbar-expand-xl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand-xxl .offcanvas-bottom,.navbar-expand-xxl .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas-header{display:none}.navbar-expand .offcanvas{position:inherit;bottom:0;z-index:1000;flex-grow:1;visibility:visible!important;background-color:transparent;border-right:0;border-left:0;transition:none;transform:none}.navbar-expand .offcanvas-bottom,.navbar-expand .offcanvas-top{height:auto;border-top:0;border-bottom:0}.navbar-expand .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.55)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.55);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.55)}.navbar-light .navbar-text a,.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.55)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.55);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.55)}.navbar-dark .navbar-text a,.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:1rem 1rem}.card-title{margin-bottom:.5rem}.card-subtitle{margin-top:-.25rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:1rem}.card-header{padding:.5rem 1rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.5rem 1rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.5rem;margin-bottom:-.5rem;margin-left:-.5rem;border-bottom:0}.card-header-pills{margin-right:-.5rem;margin-left:-.5rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-group>.card{margin-bottom:.75rem}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:1rem 1.25rem;font-size:1rem;color:#212529;text-align:left;background-color:#fff;border:0;border-radius:0;overflow-anchor:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,border-radius .15s ease}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:#0c63e4;background-color:#e7f1ff;box-shadow:inset 0 -1px 0 rgba(0,0,0,.125)}.accordion-button:not(.collapsed)::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230c63e4'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");transform:rotate(-180deg)}.accordion-button::after{flex-shrink:0;width:1.25rem;height:1.25rem;margin-left:auto;content:"";background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-size:1.25rem;transition:transform .2s ease-in-out}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.accordion-header{margin-bottom:0}.accordion-item{background-color:#fff;border:1px solid rgba(0,0,0,.125)}.accordion-item:first-of-type{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.accordion-item:first-of-type .accordion-button{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-item:last-of-type .accordion-button.collapsed{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.accordion-item:last-of-type .accordion-collapse{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.accordion-body{padding:1rem 1.25rem}.accordion-flush .accordion-collapse{border-width:0}.accordion-flush .accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush .accordion-item:first-child{border-top:0}.accordion-flush .accordion-item:last-child{border-bottom:0}.accordion-flush .accordion-item .accordion-button{border-radius:0}.breadcrumb{display:flex;flex-wrap:wrap;padding:0 0;margin-bottom:1rem;list-style:none}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:.5rem;color:#6c757d;content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:#6c757d}.pagination{display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;color:#0d6efd;text-decoration:none;background-color:#fff;border:1px solid #dee2e6;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:#0a58ca;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:3;color:#0a58ca;background-color:#e9ecef;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.page-item:not(:first-child) .page-link{margin-left:-1px}.page-item.active .page-link{z-index:3;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;background-color:#fff;border-color:#dee2e6}.page-link{padding:.375rem .75rem}.page-item:first-child .page-link{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.35em .65em;font-size:.75em;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{position:relative;padding:1rem 1rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{color:#084298;background-color:#cfe2ff;border-color:#b6d4fe}.alert-primary .alert-link{color:#06357a}.alert-secondary{color:#41464b;background-color:#e2e3e5;border-color:#d3d6d8}.alert-secondary .alert-link{color:#34383c}.alert-success{color:#0f5132;background-color:#d1e7dd;border-color:#badbcc}.alert-success .alert-link{color:#0c4128}.alert-info{color:#055160;background-color:#cff4fc;border-color:#b6effb}.alert-info .alert-link{color:#04414d}.alert-warning{color:#664d03;background-color:#fff3cd;border-color:#ffecb5}.alert-warning .alert-link{color:#523e02}.alert-danger{color:#842029;background-color:#f8d7da;border-color:#f5c2c7}.alert-danger .alert-link{color:#6a1a21}.alert-light{color:#636464;background-color:#fefefe;border-color:#fdfdfe}.alert-light .alert-link{color:#4f5050}.alert-dark{color:#141619;background-color:#d3d3d4;border-color:#bcbebf}.alert-dark .alert-link{color:#101214}@-webkit-keyframes progress-bar-stripes{0%{background-position-x:1rem}}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress{display:flex;height:1rem;overflow:hidden;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#0d6efd;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:1s linear infinite progress-bar-stripes;animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.list-group{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>li::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.5rem 1rem;color:#212529;text-decoration:none;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#0d6efd;border-color:#0d6efd}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#084298;background-color:#cfe2ff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#084298;background-color:#bacbe6}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#084298;border-color:#084298}.list-group-item-secondary{color:#41464b;background-color:#e2e3e5}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#41464b;background-color:#cbccce}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#41464b;border-color:#41464b}.list-group-item-success{color:#0f5132;background-color:#d1e7dd}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#0f5132;background-color:#bcd0c7}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#0f5132;border-color:#0f5132}.list-group-item-info{color:#055160;background-color:#cff4fc}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#055160;background-color:#badce3}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#055160;border-color:#055160}.list-group-item-warning{color:#664d03;background-color:#fff3cd}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#664d03;background-color:#e6dbb9}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#664d03;border-color:#664d03}.list-group-item-danger{color:#842029;background-color:#f8d7da}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#842029;background-color:#dfc2c4}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#842029;border-color:#842029}.list-group-item-light{color:#636464;background-color:#fefefe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#636464;background-color:#e5e5e5}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#636464;border-color:#636464}.list-group-item-dark{color:#141619;background-color:#d3d3d4}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#141619;background-color:#bebebf}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#141619;border-color:#141619}.btn-close{box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:#000;background:transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;border:0;border-radius:.25rem;opacity:.5}.btn-close:hover{color:#000;text-decoration:none;opacity:.75}.btn-close:focus{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25);opacity:1}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:.25}.btn-close-white{filter:invert(1) grayscale(100%) brightness(200%)}.toast{width:350px;max-width:100%;font-size:.875rem;pointer-events:auto;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .5rem 1rem rgba(0,0,0,.15);border-radius:.25rem}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:.75rem}.toast-header{display:flex;align-items:center;padding:.5rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-header .btn-close{margin-right:-.375rem;margin-left:.75rem}.toast-body{padding:.75rem;word-wrap:break-word}.modal{position:fixed;top:0;left:0;z-index:1055;display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - 1rem)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1050;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:flex;flex-shrink:0;align-items:center;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .btn-close{padding:.5rem .5rem;margin:-.5rem -.5rem -.5rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;flex:1 1 auto;padding:1rem}.modal-footer{display:flex;flex-wrap:wrap;flex-shrink:0;align-items:center;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{height:calc(100% - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}.modal-fullscreen .modal-footer{border-radius:0}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}.modal-fullscreen-sm-down .modal-footer{border-radius:0}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}.modal-fullscreen-md-down .modal-footer{border-radius:0}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}.modal-fullscreen-lg-down .modal-footer{border-radius:0}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}.modal-fullscreen-xl-down .modal-footer{border-radius:0}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}.modal-fullscreen-xxl-down .modal-footer{border-radius:0}}.tooltip{position:absolute;z-index:1080;display:block;margin:0;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .tooltip-arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:0}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[data-popper-placement^=right],.bs-tooltip-end{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[data-popper-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:0}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[data-popper-placement^=left],.bs-tooltip-start{padding:0 .4rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1070;display:block;max-width:276px;font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .popover-arrow{position:absolute;display:block;width:1rem;height:.5rem}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f0f0f0}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem 1rem;margin-bottom:0;font-size:1rem;background-color:#f0f0f0;border-bottom:1px solid rgba(0,0,0,.2);border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:1rem 1rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%;list-style:none}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}@-webkit-keyframes spinner-border{to{transform:rotate(360deg)}}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:.75s linear infinite spinner-border;animation:.75s linear infinite spinner-border}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:.75s linear infinite spinner-grow;animation:.75s linear infinite spinner-grow}.spinner-grow-sm{width:1rem;height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{-webkit-animation-duration:1.5s;animation-duration:1.5s}}.offcanvas{position:fixed;bottom:0;z-index:1045;display:flex;flex-direction:column;max-width:100%;visibility:hidden;background-color:#fff;background-clip:padding-box;outline:0;transition:transform .3s ease-in-out}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1rem}.offcanvas-header .btn-close{padding:.5rem .5rem;margin-top:-.5rem;margin-right:-.5rem;margin-bottom:-.5rem}.offcanvas-title{margin-bottom:0;line-height:1.5}.offcanvas-body{flex-grow:1;padding:1rem 1rem;overflow-y:auto}.offcanvas-start{top:0;left:0;width:400px;border-right:1px solid rgba(0,0,0,.2);transform:translateX(-100%)}.offcanvas-end{top:0;right:0;width:400px;border-left:1px solid rgba(0,0,0,.2);transform:translateX(100%)}.offcanvas-top{top:0;right:0;left:0;height:30vh;max-height:100%;border-bottom:1px solid rgba(0,0,0,.2);transform:translateY(-100%)}.offcanvas-bottom{right:0;left:0;height:30vh;max-height:100%;border-top:1px solid rgba(0,0,0,.2);transform:translateY(100%)}.offcanvas.show{transform:none}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentColor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{-webkit-animation:placeholder-glow 2s ease-in-out infinite;animation:placeholder-glow 2s ease-in-out infinite}@-webkit-keyframes placeholder-glow{50%{opacity:.2}}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;-webkit-animation:placeholder-wave 2s linear infinite;animation:placeholder-wave 2s linear infinite}@-webkit-keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.link-primary{color:#0d6efd}.link-primary:focus,.link-primary:hover{color:#0a58ca}.link-secondary{color:#6c757d}.link-secondary:focus,.link-secondary:hover{color:#565e64}.link-success{color:#198754}.link-success:focus,.link-success:hover{color:#146c43}.link-info{color:#0dcaf0}.link-info:focus,.link-info:hover{color:#3dd5f3}.link-warning{color:#ffc107}.link-warning:focus,.link-warning:hover{color:#ffcd39}.link-danger{color:#dc3545}.link-danger:focus,.link-danger:hover{color:#b02a37}.link-light{color:#f8f9fa}.link-light:focus,.link-light:hover{color:#f9fafb}.link-dark{color:#212529}.link-dark:focus,.link-dark:hover{color:#1a1e21}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:1px;min-height:1em;background-color:currentColor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:1px solid #dee2e6!important}.border-0{border:0!important}.border-top{border-top:1px solid #dee2e6!important}.border-top-0{border-top:0!important}.border-end{border-right:1px solid #dee2e6!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:1px solid #dee2e6!important}.border-start-0{border-left:0!important}.border-primary{border-color:#0d6efd!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#198754!important}.border-info{border-color:#0dcaf0!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#212529!important}.border-white{border-color:#fff!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-light{font-weight:300!important}.fw-lighter{font-weight:lighter!important}.fw-normal{font-weight:400!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:#6c757d!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:.25rem!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:.2rem!important}.rounded-2{border-radius:.25rem!important}.rounded-3{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-end{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-start{border-bottom-left-radius:.25rem!important;border-top-left-radius:.25rem!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} -/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file +/*# sourceMappingURL=bootstrap.min.css.map */ diff --git a/datadoc/backend/DatasetParser.py b/datadoc/backend/DatasetParser.py deleted file mode 100644 index ad3498ef..00000000 --- a/datadoc/backend/DatasetParser.py +++ /dev/null @@ -1,174 +0,0 @@ -import pathlib -import re -from abc import ABC, abstractmethod -from typing import List, Optional, TypeVar - -import pandas as pd -import pyarrow.parquet as pq -from datadoc_model.Enums import Datatype -from datadoc_model.LanguageStrings import LanguageStrings -from datadoc_model.Model import DataDocVariable - -from datadoc import state -from datadoc.backend.StorageAdapter import StorageAdapter - -TDatasetParser = TypeVar("TDatasetParser", bound="DatasetParser") - -KNOWN_INTEGER_TYPES = ( - "int", - "int_", - "int8", - "int16", - "int32", - "int64", - "integer", - "long", - "uint", - "uint8", - "uint16", - "uint32", - "uint64", -) - -KNOWN_FLOAT_TYPES = ( - "double", - "float", - "float_", - "float16", - "float32", - "float64", - "decimal", - "number", - "numeric", - "num", -) - -KNOWN_STRING_TYPES = ( - "string", - "str", - "char", - "varchar", - "varchar2", - "text", - "txt", - "bytes", -) - -KNOWN_DATETIME_TYPES = ( - "timestamp", - "timestamp[us]", - "timestamp[ns]", - "datetime64", - " datetime64[ns]", - " datetime64[us]", - "date", - "datetime", - "time", -) - -KNOWN_BOOLEAN_TYPES = ("bool", "bool_", "boolean") - - -class DatasetParser(ABC): - def __init__(self, dataset: str): - self.dataset: StorageAdapter = StorageAdapter.for_path(dataset) - - @staticmethod - def for_file(dataset: str) -> TDatasetParser: - """Factory method to return the correct subclass based on the given dataset file""" - supported_file_types = { - "parquet": DatasetParserParquet, - "sas7bdat": DatasetParserSas7Bdat, - "parquet.gzip": DatasetParserParquet, - } - file_type = "Unknown" - try: - file_type = str(pathlib.Path(dataset)).lower().split(".")[-1] - # Gzipped parquet files can be read with DatasetParserParquet - match = re.search(r'(.parquet.gzip)', str(pathlib.Path(dataset)).lower()) - file_type = "parquet.gzip" if match else file_type - # Extract the appropriate reader class from the SUPPORTED_FILE_TYPES dict and return an instance of it - reader = supported_file_types[file_type](dataset) - except IndexError as e: - # Thrown when just one element is returned from split, meaning there is no file extension supplied - raise FileNotFoundError( - f"Could not recognise file type for provided {dataset = }. Supported file types are: {', '.join(supported_file_types.keys())}" - ) from e - except KeyError as e: - # In this case the file type is not supported, so we throw a helpful exception - raise NotImplementedError( - f"{file_type = } is not supported. Please open one of the following supported files types: {', '.join(supported_file_types.keys())} or contact the maintainers to request support." - ) from e - else: - return reader - - @staticmethod - def transform_data_type(data_type: str) -> Optional[Datatype]: - v_data_type = data_type.lower() - if v_data_type in KNOWN_INTEGER_TYPES: - return Datatype.INTEGER - elif v_data_type in KNOWN_FLOAT_TYPES: - return Datatype.FLOAT - elif v_data_type in KNOWN_STRING_TYPES: - return Datatype.STRING - elif v_data_type in KNOWN_DATETIME_TYPES: - return Datatype.DATETIME - elif v_data_type in KNOWN_BOOLEAN_TYPES: - return Datatype.BOOLEAN - else: - # Unknown data type. There's no need to throw an exception here, - # the user can still define the data type manually in the GUI - return None - - @abstractmethod - def get_fields(self) -> List[DataDocVariable]: - """Abstract method, must be implemented by subclasses""" - - -class DatasetParserParquet(DatasetParser): - def __init__(self, dataset: str): - super().__init__(dataset) - - def get_fields(self) -> List[DataDocVariable]: - fields = [] - with self.dataset.open(mode="rb") as f: - data_table = pq.read_table(f) - for data_field in data_table.schema: - fields.append( - DataDocVariable( - short_name=data_field.name, - data_type=self.transform_data_type(str(data_field.type)), - ) - ) - return fields - - -class DatasetParserSas7Bdat(DatasetParser): - def __init__(self, dataset: str): - super().__init__(dataset) - - def get_fields(self) -> List[DataDocVariable]: - fields = [] - with self.dataset.open(mode="rb") as f: - # Use an iterator to avoid reading in the entire dataset - sas_reader = pd.read_sas(f, format="sas7bdat", iterator=True) - - # Get the first row from the iterator - row = next(sas_reader) - - # Get all the values from the row and loop through them - for i, v in enumerate(row.values.tolist()[0]): - fields.append( - DataDocVariable( - short_name=sas_reader.columns[i].name, - # Assume labels are defined in the default language (NORSK_BOKMÅL) - # If this is not correct, the user may fix it via the UI - name=LanguageStrings( - **{state.current_metadata_language: sas_reader.columns[i].label} - ), - # Access the python type for the value and transform it to a DataDoc Data type - data_type=self.transform_data_type(type(v).__name__.lower()), - ) - ) - - return fields diff --git a/datadoc/backend/ModelBackwardsCompatibility.py b/datadoc/backend/ModelBackwardsCompatibility.py deleted file mode 100644 index ed7c7de3..00000000 --- a/datadoc/backend/ModelBackwardsCompatibility.py +++ /dev/null @@ -1,67 +0,0 @@ -from dataclasses import dataclass -from typing import Callable, Dict - -VERSION_FIELD_NAME = "document_version" - - -class UnknownModelVersionError(Exception): - def __init__(self, supplied_version, *args): - super().__init__(args) - self.supplied_version = supplied_version - - def __str__(self): - return f"Document Version ({self.supplied_version}) of discovered file is not supported" - - -SUPPORTED_VERSIONS: Dict[str, "BackwardsCompatibleVersion"] = {} - - -@dataclass() -class BackwardsCompatibleVersion: - version_string: int - upgrade_function: Callable - - def __post_init__(self): - SUPPORTED_VERSIONS[self.version_string] = self - - -def handle_current_version(supplied_metadata: Dict) -> Dict: - """Nothing to do here""" - return supplied_metadata - - -def handle_version_0_1_1(supplied_metadata: Dict) -> Dict: - """Handles changes made in this PR: https://github.com/statisticsnorway/ssb-datadoc-model/pull/4""" - key_renaming = [ - ("metadata_created_date", "created_date"), - ("metadata_created_by", "created_by"), - ("metadata_last_updated_date", "last_updated_date"), - ("metadata_last_updated_by", "last_updated_by"), - ] - for new_key, old_key in key_renaming: - supplied_metadata["dataset"][new_key] = supplied_metadata["dataset"].pop( - old_key - ) - return supplied_metadata - - -# Register all the supported versions and their handlers -BackwardsCompatibleVersion("0.1.1", handle_version_0_1_1) -BackwardsCompatibleVersion( - "1", handle_version_0_1_1 -) # Some documents exist with incorrect version specification - - -def upgrade_metadata(fresh_metadata: Dict, current_model_version: str) -> Dict: - # Special case for current version, we expose the current_model_version parameter for test purposes - SUPPORTED_VERSIONS[current_model_version] = BackwardsCompatibleVersion( - current_model_version, handle_current_version - ) - supplied_version = fresh_metadata[VERSION_FIELD_NAME] - try: - # Retrieve the upgrade function for this version - upgrade = SUPPORTED_VERSIONS[supplied_version].upgrade_function - except KeyError: - raise UnknownModelVersionError(supplied_version) # noqa TC200 - else: - return upgrade(fresh_metadata) diff --git a/datadoc/backend/StorageAdapter.py b/datadoc/backend/StorageAdapter.py deleted file mode 100644 index dce20702..00000000 --- a/datadoc/backend/StorageAdapter.py +++ /dev/null @@ -1,117 +0,0 @@ -import logging -import os -import pathlib -from io import IOBase, TextIOWrapper -from typing import Protocol, Union -from urllib.parse import urlsplit, urlunsplit - -GCS_PROTOCOL_PREFIX = "gs://" - -logger = logging.getLogger(__name__) - - -class GCSObject: - def __init__(self, path: str): - self._url = urlsplit(path) - try: - from dapla import AuthClient, FileClient - - if AuthClient.is_ready(): - # Running on Dapla, rely on dapla-toolbelt for auth - self.fs = FileClient.get_gcs_file_system() - else: - # All other environments, rely on Standard Google credential system - # If this doesn't work for you, try running the following commands: - # - # gcloud auth application-default revoke - # gcloud auth application-default login - from gcsfs import GCSFileSystem - - self.fs = GCSFileSystem() - - except ImportError as e: - msg = "Missing support for GCS. Install datadoc with 'pip install ssb-datadoc[gcs]'" - raise ImportError(msg) from e # noqa: TC200 - - def _rebuild_url(self, new_path: str) -> str: - return urlunsplit((self._url.scheme, self._url.netloc, new_path, None, None)) - - def open(self, **kwargs) -> IOBase: - return self.fs.open(self.location, **kwargs) - - def parent(self) -> str: - parent = os.path.dirname(self._url.path) - return self._rebuild_url(parent) - - def joinpath(self, part): - """Modify the path in place""" - self._url = urlsplit(self._rebuild_url(os.path.join(self._url.path, part))) - - def exists(self) -> bool: - return self.fs.exists(self.location) - - def write_text(self, text: str) -> None: - f: TextIOWrapper - with self.fs.open(self.location, mode="w") as f: - f.write(text) - - @property - def location(self) -> str: - return urlunsplit(self._url) - - -class LocalFile: - def __init__(self, path): - self._path_object: pathlib.Path = pathlib.Path(path) - - def open(self, **kwargs) -> IOBase: - return open(str(self._path_object), **kwargs) - - def parent(self) -> str: - return str(self._path_object.resolve().parent) - - def joinpath(self, part): - """Modify the path in place""" - self._path_object = self._path_object.joinpath(part) - - def exists(self) -> bool: - return self._path_object.exists() - - def write_text(self, text: str) -> None: - self._path_object.write_text(text, encoding="utf-8") - - @property - def location(self) -> str: - return str(self._path_object) - - -class StorageAdapter(Protocol): - @staticmethod - def for_path(path: Union[str, pathlib.Path]) -> "StorageAdapter": - """ - Return a concrete class implementing this Protocol based on the structure of the path. - """ - path = str(path) - if path.startswith(GCS_PROTOCOL_PREFIX): - return GCSObject(path) - else: - return LocalFile(path) - - def open(self, **kwargs) -> IOBase: - ... - - def parent(self) -> str: - ... - - def joinpath(self, part: str) -> None: - ... - - def exists(self) -> bool: - ... - - def write_text(self, text: str) -> None: - ... - - @property - def location(self) -> str: - ... diff --git a/datadoc/backend/VariableDefinition.py b/datadoc/backend/VariableDefinition.py deleted file mode 100644 index 976eece1..00000000 --- a/datadoc/backend/VariableDefinition.py +++ /dev/null @@ -1,34 +0,0 @@ -from xml.dom.minidom import parseString - -import requests - - -class VariableDefinition: - # TODO: Denne skal gå mot ny VarDef, men bruker foreløpig gamle VarDok! - def __init__(self, vardef_id): - self.vardef_id = vardef_id - self.vardef_uri = ( - "https://www.ssb.no/a/xml/metadata/conceptvariable/vardok/" - + self.vardef_id - + "/nb" - ) - self.vardef_gui_uri = ( - "https://www.ssb.no/a/metadata/conceptvariable/vardok/" - + self.vardef_id - + "/nb" - ) - self.vardef_name = None - self.vardef_definition = None - self.vardef_short_name = None - self.get_variable_definition() - - def get_variable_definition(self): - # TODO: Denne skal gå mot ny VarDef, men bruker foreløpig gamle VarDok! - vardok_xml = requests.get(self.vardef_uri) - variable_document = parseString(vardok_xml.text) - self.vardef_name = variable_document.getElementsByTagName("Title")[ - 0 - ].firstChild.nodeValue - self.vardef_definition = variable_document.getElementsByTagName("Description")[ - 0 - ].firstChild.nodeValue diff --git a/datadoc/backend/__init__.py b/datadoc/backend/__init__.py new file mode 100644 index 00000000..750f62ba --- /dev/null +++ b/datadoc/backend/__init__.py @@ -0,0 +1 @@ +"""Code which does not directly interact with th UI.""" diff --git a/datadoc/backend/DataDocMetadata.py b/datadoc/backend/datadoc_metadata.py similarity index 54% rename from datadoc/backend/DataDocMetadata.py rename to datadoc/backend/datadoc_metadata.py index 7cae2085..8dc363a0 100644 --- a/datadoc/backend/DataDocMetadata.py +++ b/datadoc/backend/datadoc_metadata.py @@ -1,49 +1,46 @@ +"""Handle reading, updating and writing of metadata.""" +from __future__ import annotations + import json import logging import os import pathlib +import typing as t import uuid -from datetime import datetime -from typing import Dict, Optional +from typing import TYPE_CHECKING from datadoc_model import Model -from datadoc_model.Enums import DatasetState +from datadoc_model.Enums import DatasetState, SupportedLanguages -import datadoc.frontend.fields.DisplayDataset as DisplayDataset -import datadoc.frontend.fields.DisplayVariables as DisplayVariables -from datadoc.backend.DatasetParser import DatasetParser -from datadoc.backend.ModelBackwardsCompatibility import upgrade_metadata -from datadoc.backend.StorageAdapter import StorageAdapter +from datadoc.backend.dataset_parser import DatasetParser +from datadoc.backend.model_backwards_compatibility import upgrade_metadata +from datadoc.backend.storage_adapter import StorageAdapter +from datadoc.frontend.fields import display_dataset, display_variables from datadoc.utils import calculate_percentage, get_timestamp_now +if TYPE_CHECKING: + from datetime import datetime + logger = logging.getLogger(__name__) OBLIGATORY_DATASET_METADATA = [ m.identifier - for m in DisplayDataset.DISPLAY_DATASET.values() + for m in display_dataset.DISPLAY_DATASET.values() if m.obligatory and m.editable ] OBLIGATORY_VARIABLES_METADATA = [ m.identifier - for m in DisplayVariables.DISPLAY_VARIABLES.values() + for m in display_variables.DISPLAY_VARIABLES.values() if m.obligatory and m.editable ] # These don't vary at runtime so we calculate them as constants here NUM_OBLIGATORY_DATASET_FIELDS = len( - [ - k - for k in Model.DataDocDataSet().dict().keys() - if k in OBLIGATORY_DATASET_METADATA - ] + [k for k in Model.DataDocDataSet().dict() if k in OBLIGATORY_DATASET_METADATA], ) NUM_OBLIGATORY_VARIABLES_FIELDS = len( - [ - k - for k in Model.DataDocVariable().dict().keys() - if k in OBLIGATORY_VARIABLES_METADATA - ] + [k for k in Model.DataDocVariable().dict() if k in OBLIGATORY_VARIABLES_METADATA], ) METADATA_DOCUMENT_FILE_SUFFIX = "__DOC.json" @@ -52,17 +49,23 @@ class DataDocMetadata: - def __init__(self, dataset: Optional[str]): + """Handle reading, updating and writing of metadata.""" + + def __init__( + self: t.Self @ DataDocMetadata, + dataset: str | None, + ) -> None: + """Read in a dataset if supplied, otherwise naively instantiate the class.""" self.dataset: str = dataset if self.dataset: self.short_name: str = pathlib.Path( - self.dataset + self.dataset, ).stem # filename without file ending self.metadata_document: StorageAdapter = StorageAdapter.for_path( - StorageAdapter.for_path(self.dataset).parent() + StorageAdapter.for_path(self.dataset).parent(), ) self.metadata_document.joinpath( - self.short_name + METADATA_DOCUMENT_FILE_SUFFIX + self.short_name + METADATA_DOCUMENT_FILE_SUFFIX, ) self.dataset_state: DatasetState = self.get_dataset_state(self.dataset) try: @@ -70,63 +73,80 @@ def __init__(self, dataset: Optional[str]): except KeyError: self.current_user = PLACEHOLDER_USERNAME logger.warning( - f"JUPYTERHUB_USER env variable not set, using {self.current_user} as placeholder" + "JUPYTERHUB_USER env variable not set, using %s as placeholder", + self.current_user, ) - self.meta: "Model.MetadataDocument" = Model.MetadataDocument( + self.meta: Model.MetadataDocument = Model.MetadataDocument( percentage_complete=0, document_version=Model.MODEL_VERSION, dataset=Model.DataDocDataSet(), variables=[], ) - self.variables_lookup: Dict[str, "Model.DataDocVariable"] = {} + self.variables_lookup: dict[str, Model.DataDocVariable] = {} if self.dataset: self.extract_metadata_from_files() - def get_dataset_state(self, dataset: str) -> Optional[DatasetState]: - """Use the path to attempt to guess the state of the dataset""" + def get_dataset_state( + self: t.Self @ DataDocMetadata, + dataset: str, + ) -> DatasetState | None: + """Use the path to attempt to guess the state of the dataset.""" if dataset is None: return None - dataset_path_parts = list(pathlib.Path(dataset).parts) - if "utdata" in dataset_path_parts: - return DatasetState.OUTPUT_DATA - elif "statistikk" in dataset_path_parts: - return DatasetState.STATISTIC - elif "klargjorte-data" in dataset_path_parts: - return DatasetState.PROCESSED_DATA - elif "klargjorte_data" in dataset_path_parts: - return DatasetState.PROCESSED_DATA - elif "kildedata" in dataset_path_parts: - return DatasetState.SOURCE_DATA - elif "inndata" in dataset_path_parts: - return DatasetState.INPUT_DATA - else: - return None + dataset_path_parts = set(pathlib.Path(dataset).parts) + for state in DatasetState: + # We assume that files are saved in the Norwegian language as specified by SSB. + norwegian_dataset_state_path_part = state.get_value_for_language( + SupportedLanguages.NORSK_BOKMÅL, + ).lower() + norwegian_dataset_state_path_part_variations = { + norwegian_dataset_state_path_part.replace(" ", x) for x in ["-", "_"] + } + # Match on any of the variations anywhere in the path. + if norwegian_dataset_state_path_part_variations.intersection( + dataset_path_parts, + ): + return state - def get_dataset_version(self, dataset_stem: str) -> Optional[str]: - """Find version information if exists in filename, - eg. 'v1' in filename 'person_data_v1.parquet'""" - splitted_file_name = str(dataset_stem).split("_") - if len(splitted_file_name) >= 2: - last_filename_element = str(splitted_file_name[-1]) + return None + + def get_dataset_version( + self: t.Self @ DataDocMetadata, + dataset_stem: str, + ) -> str | None: + """Find version information if exists in filename. + + eg. 'v1' in filename 'person_data_v1.parquet' + """ + minimum_elements_in_file_name: t.Final[int] = 2 + minimum_characters_in_version_string: t.Final[int] = 2 + split_file_name = str(dataset_stem).split("_") + if len(split_file_name) >= minimum_elements_in_file_name: + last_filename_element = str(split_file_name[-1]) if ( - len(last_filename_element) >= 2 + len(last_filename_element) >= minimum_characters_in_version_string and last_filename_element[0:1] == "v" and last_filename_element[1:].isdigit() ): return last_filename_element[1:] return None - def extract_metadata_from_files(self): + def extract_metadata_from_files(self: t.Self @ DataDocMetadata) -> None: + """Read metadata from a dataset. + + If a metadata document already exists, read in the metadata from that instead. + """ fresh_metadata = {} if self.metadata_document.exists(): try: with self.metadata_document.open(mode="r", encoding="utf-8") as file: fresh_metadata = json.load(file) logger.info( - f"Opened existing metadata file {self.metadata_document.location}" + "Opened existing metadata file %s", + self.metadata_document.location, ) fresh_metadata = upgrade_metadata(fresh_metadata, Model.MODEL_VERSION) @@ -137,12 +157,13 @@ def extract_metadata_from_files(self): Model.DataDocVariable(**v) for v in variables_list ] self.meta.dataset = Model.DataDocDataSet( - **fresh_metadata.pop("dataset", None) + **fresh_metadata.pop("dataset", None), ) except json.JSONDecodeError: logger.warning( - f"Could not open existing metadata file {self.metadata_document.location}. \ + "Could not open existing metadata file %s. \ Falling back to collecting data from the dataset", + self.metadata_document.location, exc_info=True, ) self.extract_metadata_from_dataset() @@ -162,7 +183,12 @@ def extract_metadata_from_files(self): self.variables_lookup = {v.short_name: v for v in self.meta.variables} - def extract_metadata_from_dataset(self): + def extract_metadata_from_dataset(self: t.Self @ DataDocMetadata) -> None: + """Obtain what metadata we can from the dataset itself. + + This makes it easier for the user by 'pre-filling' certain fields. + Certain elements are dependent on the dataset being saved according to SSB's standard. + """ self.ds_schema = DatasetParser.for_file(self.dataset) self.meta.dataset = Model.DataDocDataSet( @@ -175,8 +201,8 @@ def extract_metadata_from_dataset(self): self.meta.variables = self.ds_schema.get_fields() - def write_metadata_document(self) -> None: - """Write all currently known metadata to file""" + def write_metadata_document(self: t.Self @ DataDocMetadata) -> None: + """Write all currently known metadata to file.""" timestamp: datetime = get_timestamp_now() if self.meta.dataset.metadata_created_date is None: self.meta.dataset.metadata_created_date = timestamp @@ -185,23 +211,23 @@ def write_metadata_document(self) -> None: self.meta.dataset.metadata_last_updated_date = timestamp self.meta.dataset.metadata_last_updated_by = self.current_user self.metadata_document.write_text(self.meta.json(indent=4, sort_keys=False)) - logger.info(f"Saved metadata document {self.metadata_document.location}") + logger.info("Saved metadata document %s", self.metadata_document.location) @property - def percent_complete(self) -> int: + def percent_complete(self: t.Self @ DataDocMetadata) -> int: """The percentage of obligatory metadata completed. A metadata field is counted as complete when any non-None value is assigned. Used for a live progress bar in the UI, as well as being - saved in the datadoc as a simple quality indicator.""" - + saved in the datadoc as a simple quality indicator. + """ num_all_fields = NUM_OBLIGATORY_DATASET_FIELDS num_set_fields = len( [ k for k, v in self.meta.dataset.dict().items() if k in OBLIGATORY_DATASET_METADATA and v is not None - ] + ], ) for variable in self.meta.variables: @@ -211,7 +237,7 @@ def percent_complete(self) -> int: k for k, v in variable.dict().items() if k in OBLIGATORY_VARIABLES_METADATA and v is not None - ] + ], ) return calculate_percentage(num_set_fields, num_all_fields) diff --git a/datadoc/backend/dataset_parser.py b/datadoc/backend/dataset_parser.py new file mode 100644 index 00000000..a6b51c76 --- /dev/null +++ b/datadoc/backend/dataset_parser.py @@ -0,0 +1,212 @@ +"""Abstractions for dataset file formats. + +Handles reading in the data and transforming data types to generic metadata types. +""" + +from __future__ import annotations + +import pathlib +import re +import typing as t +from abc import ABC, abstractmethod + +import pandas as pd +import pyarrow.parquet as pq +from datadoc_model.Enums import Datatype +from datadoc_model.LanguageStrings import LanguageStrings +from datadoc_model.Model import DataDocVariable + +from datadoc import state +from datadoc.backend.storage_adapter import StorageAdapter + +TDatasetParser = t.TypeVar("TDatasetParser", bound="DatasetParser") + +KNOWN_INTEGER_TYPES = ( + "int", + "int_", + "int8", + "int16", + "int32", + "int64", + "integer", + "long", + "uint", + "uint8", + "uint16", + "uint32", + "uint64", +) + +KNOWN_FLOAT_TYPES = ( + "double", + "float", + "float_", + "float16", + "float32", + "float64", + "decimal", + "number", + "numeric", + "num", +) + +KNOWN_STRING_TYPES = ( + "string", + "str", + "char", + "varchar", + "varchar2", + "text", + "txt", + "bytes", +) + +KNOWN_DATETIME_TYPES = ( + "timestamp", + "timestamp[us]", + "timestamp[ns]", + "datetime64", + " datetime64[ns]", + " datetime64[us]", + "date", + "datetime", + "time", +) + +KNOWN_BOOLEAN_TYPES = ("bool", "bool_", "boolean") + + +TYPE_CORRESPONDENCE: list[tuple[list[str], Datatype]] = [ + (KNOWN_INTEGER_TYPES, Datatype.INTEGER), + (KNOWN_FLOAT_TYPES, Datatype.FLOAT), + (KNOWN_STRING_TYPES, Datatype.STRING), + (KNOWN_DATETIME_TYPES, Datatype.DATETIME), + (KNOWN_BOOLEAN_TYPES, Datatype.BOOLEAN), +] +TYPE_MAP: dict[str:Datatype] = {} +for concrete_type, abstract_type in TYPE_CORRESPONDENCE: + TYPE_MAP.update({c: abstract_type for c in concrete_type}) + + +class DatasetParser(ABC): + """Abstract Base Class for all Dataset parsers. + + Implements: + - A static factory method to get the correct implementation for each file extension. + - A static method for data type conversion. + + Requires implementation by subclasses: + - A method to extract variables (columns) from the dataset, so they may be documented. + """ + + def __init__(self: t.Self @ DatasetParser, dataset: str) -> None: + """Initialize for a given dataset.""" + self.dataset: StorageAdapter = StorageAdapter.for_path(dataset) + + @staticmethod + def for_file(dataset: str) -> TDatasetParser: + """Return the correct subclass based on the given dataset file.""" + supported_file_types = { + "parquet": DatasetParserParquet, + "sas7bdat": DatasetParserSas7Bdat, + "parquet.gzip": DatasetParserParquet, + } + file_type = "Unknown" + try: + file_type = str(pathlib.Path(dataset)).lower().split(".")[-1] + # Gzipped parquet files can be read with DatasetParserParquet + match = re.search(r"(.parquet.gzip)", str(pathlib.Path(dataset)).lower()) + file_type = "parquet.gzip" if match else file_type + # Extract the appropriate reader class from the SUPPORTED_FILE_TYPES dict and return an instance of it + reader = supported_file_types[file_type](dataset) + except IndexError as e: + # Thrown when just one element is returned from split, meaning there is no file extension supplied + msg = f"Could not recognise file type for provided {dataset = }. Supported file types are: {', '.join(supported_file_types.keys())}" + raise FileNotFoundError( + msg, + ) from e + except KeyError as e: + # In this case the file type is not supported, so we throw a helpful exception + msg = f"{file_type = } is not supported. Please open one of the following supported files types: {', '.join(supported_file_types.keys())} or contact the maintainers to request support." + raise NotImplementedError( + msg, + ) from e + else: + return reader + + @staticmethod + def transform_data_type(data_type: str) -> Datatype | None: + """Transform a concrete data type to an abstract data type. + + In statistical metadata, one is not interested in how the data is + technically stored, but in the meaning of the data type. Because of + this, we transform known data types to their abstract metadata + representations. + + If we encounter a data type we don't know, we just ignore it and let + the user handle it in the GUI. + """ + return TYPE_MAP.get(data_type.lower(), None) + + @abstractmethod + def get_fields(self: t.Self @ DatasetParser) -> list[DataDocVariable]: + """Abstract method, must be implemented by subclasses.""" + + +class DatasetParserParquet(DatasetParser): + """Concrete implementation for parsing parquet files.""" + + def __init__(self: t.Self @ DatasetParserParquet, dataset: str) -> None: + """Use the super init method.""" + super().__init__(dataset) + + def get_fields(self: t.Self @ DatasetParserParquet) -> list[DataDocVariable]: + """Extract the fields from this dataset.""" + with self.dataset.open(mode="rb") as f: + data_table = pq.read_table(f) + return [ + DataDocVariable( + short_name=data_field.name, + data_type=self.transform_data_type(str(data_field.type)), + ) + for data_field in data_table.schema + ] + + +class DatasetParserSas7Bdat(DatasetParser): + """Concrete implementation for parsing SAS7BDAT files.""" + + def __init__(self: t.Self @ DatasetParserSas7Bdat, dataset: str) -> None: + """Use the super init method.""" + super().__init__(dataset) + + def get_fields(self: t.Self @ DatasetParserSas7Bdat) -> list[DataDocVariable]: + """Extract the fields from this dataset.""" + fields = [] + with self.dataset.open(mode="rb") as f: + # Use an iterator to avoid reading in the entire dataset + sas_reader = pd.read_sas(f, format="sas7bdat", iterator=True) + + # Get the first row from the iterator + row = next(sas_reader) + + # Get all the values from the row and loop through them + for i, v in enumerate(row.to_numpy().tolist()[0]): + fields.append( + DataDocVariable( + short_name=sas_reader.columns[i].name, + # Assume labels are defined in the default language (NORSK_BOKMÅL) + # If this is not correct, the user may fix it via the UI + name=LanguageStrings( + **{ + state.current_metadata_language: sas_reader.columns[ + i + ].label, + }, + ), + # Access the python type for the value and transform it to a DataDoc Data type + data_type=self.transform_data_type(type(v).__name__.lower()), + ), + ) + + return fields diff --git a/datadoc/backend/model_backwards_compatibility.py b/datadoc/backend/model_backwards_compatibility.py new file mode 100644 index 00000000..e6278723 --- /dev/null +++ b/datadoc/backend/model_backwards_compatibility.py @@ -0,0 +1,100 @@ +"""Upgrade old metadata files to be compatible with new versions of the datadoc model. + +An important principle of Datadoc is that we ALWAYS guarantee backwards compatibility of existing metadata documents. +This means that we guarantee that a user will never lose data, even if their document is decades old. + +For each document version we release with breaking changes, we implement a handler and register the version by defining a +BackwardsCompatibleVersion instance. These documents will then be upgraded when they're opened in Datadoc. + +A test must also be implemented for each new version. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +if t.TYPE_CHECKING: + from collections.abc import Callable + +VERSION_FIELD_NAME = "document_version" + + +class UnknownModelVersionError(Exception): + """Throw this error if we haven't seen the version before.""" + + def __init__( + self: t.Self @ UnknownModelVersionError, + supplied_version: str, + *args: tuple[t.Any, ...], + ) -> None: + """Initialize class.""" + super().__init__(args) + self.supplied_version = supplied_version + + def __str__(self: t.Self @ UnknownModelVersionError) -> str: + """Return string representation.""" + return f"Document Version ({self.supplied_version}) of discovered file is not supported" + + +SUPPORTED_VERSIONS: dict[str, BackwardsCompatibleVersion] = {} + + +@dataclass() +class BackwardsCompatibleVersion: + """A version which we support with backwards compatibility.""" + + version: str + handler: Callable + + def __post_init__(self: t.Self @ BackwardsCompatibleVersion) -> None: + """Register this version in the supported versions map.""" + SUPPORTED_VERSIONS[self.version] = self + + +def handle_current_version(supplied_metadata: dict) -> dict: + """Nothing to do here.""" + return supplied_metadata + + +def handle_version_0_1_1(supplied_metadata: dict) -> dict: + """Handle breaking changes for v0.1.1. + + PR ref: https://github.com/statisticsnorway/ssb-datadoc-model/pull/4. + """ + key_renaming = [ + ("metadata_created_date", "created_date"), + ("metadata_created_by", "created_by"), + ("metadata_last_updated_date", "last_updated_date"), + ("metadata_last_updated_by", "last_updated_by"), + ] + for new_key, old_key in key_renaming: + supplied_metadata["dataset"][new_key] = supplied_metadata["dataset"].pop( + old_key, + ) + return supplied_metadata + + +# Register all the supported versions and their handlers +BackwardsCompatibleVersion(version="0.1.1", handler=handle_version_0_1_1) +BackwardsCompatibleVersion( + version="1", + handler=handle_version_0_1_1, +) # Some documents exist with incorrect version specification + + +def upgrade_metadata(fresh_metadata: dict, current_model_version: str) -> dict: + """Run the handler for this version to upgrade the document to the latest version.""" + # Special case for current version, we expose the current_model_version parameter for test purposes + SUPPORTED_VERSIONS[current_model_version] = BackwardsCompatibleVersion( + current_model_version, + handle_current_version, + ) + supplied_version = fresh_metadata[VERSION_FIELD_NAME] + try: + # Retrieve the upgrade function for this version + upgrade = SUPPORTED_VERSIONS[supplied_version].handler + except KeyError as e: + raise UnknownModelVersionError(supplied_version) from e + else: + return upgrade(fresh_metadata) diff --git a/datadoc/backend/storage_adapter.py b/datadoc/backend/storage_adapter.py new file mode 100644 index 00000000..69fed3f4 --- /dev/null +++ b/datadoc/backend/storage_adapter.py @@ -0,0 +1,160 @@ +"""Perform operations on files stored with a range of technologies. + +Defines a Protocol and the concrete implementations of it. +""" + +from __future__ import annotations + +import logging +import pathlib +import typing as t +from urllib.parse import urlsplit, urlunsplit + +if t.TYPE_CHECKING: + from io import IOBase, TextIOWrapper + +GCS_PROTOCOL_PREFIX = "gs://" + +logger = logging.getLogger(__name__) + + +class GCSObject: + """Implementation of the Protocol 'StorageAdapter' for Google Cloud Storage.""" + + def __init__(self: t.Self @ GCSObject, path: str) -> None: + """Initialize the class.""" + self._url = urlsplit(path) + try: + from dapla import AuthClient, FileClient + + if AuthClient.is_ready(): + # Running on Dapla, rely on dapla-toolbelt for auth + self.fs = FileClient.get_gcs_file_system() + else: + # All other environments, rely on Standard Google credential system + # If this doesn't work for you, try running the following commands: + # + # gcloud auth application-default revoke + # gcloud auth application-default login + from gcsfs import GCSFileSystem + + self.fs = GCSFileSystem() + + except ImportError as e: + msg = "Missing support for GCS. Install datadoc with 'pip install ssb-datadoc[gcs]'" + raise ImportError(msg) from e + + def _rebuild_url(self: t.Self @ GCSObject, new_path: str | pathlib.Path) -> str: + return urlunsplit( + (self._url.scheme, self._url.netloc, str(new_path), None, None), + ) + + def open(self: t.Self @ GCSObject, **kwargs: dict[str, t.Any]) -> IOBase: + """Return a file-like-object.""" + return self.fs.open(self.location, **kwargs) + + def parent(self: t.Self @ GCSObject) -> str: + """Return the logical parent of this object.""" + parent = pathlib.Path(self._url.path).parent + return self._rebuild_url(parent) + + def joinpath(self: t.Self @ GCSObject, part: str) -> None: + """Join 'part' onto the current path. + + In-place operation. + """ + self._url = urlsplit( + self._rebuild_url(pathlib.Path(self._url.path) / part), + ) + + def exists(self: t.Self @ GCSObject) -> bool: + """Return True if the object exists.""" + return self.fs.exists(self.location) + + def write_text(self: t.Self @ GCSObject, text: str) -> None: + """Write the given text to disk.""" + f: TextIOWrapper + with self.fs.open(self.location, mode="w") as f: + f.write(text) + + @property + def location(self: t.Self @ GCSObject) -> str: + """Return a locator for this object.""" + return urlunsplit(self._url) + + +class LocalFile: + """Implementation of the Protocol 'StorageAdapter' for file systems.""" + + def __init__(self: t.Self @ LocalFile, path: str) -> None: + """Initialize the class.""" + self._path_object: pathlib.Path = pathlib.Path(path) + + def open(self: t.Self @ LocalFile, **kwargs: dict[str, t.Any]) -> IOBase: + """Return a file-like-object.""" + return pathlib.Path.open(str(self._path_object), **kwargs) + + def parent(self: t.Self @ LocalFile) -> str: + """Return the parent of this file.""" + return str(self._path_object.resolve().parent) + + def joinpath(self: t.Self @ LocalFile, part: str) -> None: + """Join 'part' onto the current path. + + In-place operation. + """ + self._path_object = self._path_object.joinpath(part) + + def exists(self: t.Self @ LocalFile) -> bool: + """Return True if the file exists.""" + return self._path_object.exists() + + def write_text(self: t.Self @ LocalFile, text: str) -> None: + """Write the given text to disk.""" + self._path_object.write_text(text, encoding="utf-8") + + @property + def location(self: t.Self @ LocalFile) -> str: + """Return a locator for this object.""" + return str(self._path_object) + + +class StorageAdapter(t.Protocol): + """Implement this Protocol for the technologies on which we store datasets and metadata documents.""" + + @staticmethod + def for_path(path: str | pathlib.Path) -> StorageAdapter: + """Return a concrete class implementing this Protocol based on the structure of the path.""" + path = str(path) + if path.startswith(GCS_PROTOCOL_PREFIX): + return GCSObject(path) + + return LocalFile(path) + + def open(self: t.Self @ StorageAdapter, **kwargs: dict[str, t.Any]) -> IOBase: + """Return a file-like-object.""" + ... + + def parent(self: t.Self @ StorageAdapter) -> str: + """Return the logical parent of this instance.""" + ... + + def joinpath(self: t.Self @ StorageAdapter, part: str) -> None: + """Join 'part' onto the current path. + + In-place operation. + """ + ... + + def exists(self: t.Self @ StorageAdapter) -> bool: + """Return True if the object exists.""" + ... + + def write_text(self: t.Self @ StorageAdapter, text: str) -> None: + """Write the given text to disk.""" + ... + + @property + def location(self: t.Self @ StorageAdapter) -> str: + """Return a locator for this object.""" + ... diff --git a/datadoc/frontend/callbacks/__init__.py b/datadoc/frontend/callbacks/__init__.py new file mode 100644 index 00000000..618fd03d --- /dev/null +++ b/datadoc/frontend/callbacks/__init__.py @@ -0,0 +1,8 @@ +"""Dash callback functions. + +The actual decorated callbacks should be very minimal functions which +call other functions where the logic lives. This is done to support unit +testing because the decorated functions are difficult to test. + +The functions where the logic lives should be categorised into files. +""" diff --git a/datadoc/frontend/callbacks/dataset.py b/datadoc/frontend/callbacks/dataset.py index 6564988a..c7698f96 100644 --- a/datadoc/frontend/callbacks/dataset.py +++ b/datadoc/frontend/callbacks/dataset.py @@ -1,78 +1,132 @@ +"""Callbacks relating to datasets.""" + +from __future__ import annotations + import logging import os -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +import traceback +import typing as t -from datadoc_model.Enums import SupportedLanguages from pydantic import ValidationError -import datadoc.state as state -from datadoc.backend.DataDocMetadata import DataDocMetadata +from datadoc import state +from datadoc.backend.datadoc_metadata import DataDocMetadata from datadoc.frontend.callbacks.utils import ( + MetadataInputTypes, find_existing_language_string, get_options_for_language, update_global_language_state, ) -from datadoc.frontend.fields.DisplayDataset import ( +from datadoc.frontend.fields.display_dataset import ( DISPLAYED_DATASET_METADATA, DISPLAYED_DROPDOWN_DATASET_ENUMS, MULTIPLE_LANGUAGE_DATASET_METADATA, DatasetIdentifiers, ) +if t.TYPE_CHECKING: + from pathlib import Path + + from datadoc_model import Model + from datadoc_model.Enums import SupportedLanguages + logger = logging.getLogger(__name__) DATADOC_DATASET_PATH_ENV_VAR = "DATADOC_DATASET_PATH" def get_dataset_path() -> str | Path | None: + """Extract the path to the dataset from the potential sources.""" if state.metadata.dataset is not None: return state.metadata.dataset - elif path_from_env := os.getenv(DATADOC_DATASET_PATH_ENV_VAR): - logger.info( - f"Dataset path from {DATADOC_DATASET_PATH_ENV_VAR}: '{path_from_env}'" - ) - dataset = path_from_env - else: - dataset = None - - return dataset + path_from_env = os.getenv(DATADOC_DATASET_PATH_ENV_VAR) + logger.info( + "Dataset path from %s: '%s'", + DATADOC_DATASET_PATH_ENV_VAR, + path_from_env, + ) + return path_from_env def open_dataset(dataset_path: str | Path | None = None) -> None: + """Load the given dataset into an DataDocMetadata instance.""" dataset = dataset_path or get_dataset_path() state.metadata = DataDocMetadata(dataset) - logger.info(f"Opened dataset {dataset}") + logger.info("Opened dataset %s", dataset) + + +def open_dataset_handling( + n_clicks: int, + dataset_path: str, +) -> tuple[bool, bool, str, SupportedLanguages]: + """Handle errors and other logic around opening a dataset file.""" + try: + open_dataset(dataset_path) + except FileNotFoundError: + return ( + False, + True, + f"Datasettet '{dataset_path}' finnes ikke.", + state.current_metadata_language.value, + ) + except Exception as e: # noqa: BLE001 + return ( + False, + True, + "\n".join(traceback.format_exception_only(type(e), e)), + state.current_metadata_language.value, + ) + if n_clicks and n_clicks > 0: + return True, False, "", state.current_metadata_language.value + + return False, False, "", state.current_metadata_language.value + +def process_keyword(value: str) -> list[str] | None: + """Convert a comma separated string to a list of strings. -def process_keyword(value: str) -> Optional[List[str]]: + e.g. 'a,b ,c' -> ['a', 'b', 'c'] + """ if value is None: return None return [item.strip() for item in value.split(",")] -def process_special_cases(value: Any, metadata_identifier: str): - """ - docstring +def process_special_cases( + value: str, + metadata_identifier: str, +) -> list[str] | Model.LanguageStrings | None: + """Pre-process metadata where needed. + + Some types of metadata need processing before being saved + to the model. Handle these cases here, other values are + returned unchanged. """ if metadata_identifier == DatasetIdentifiers.KEYWORD.value: value = process_keyword(value) if metadata_identifier in MULTIPLE_LANGUAGE_DATASET_METADATA: value = find_existing_language_string( - state.metadata.meta.dataset, value, metadata_identifier + state.metadata.meta.dataset, + value, + metadata_identifier, ) + # Other values get returned unchanged return value def accept_dataset_metadata_input( - value: Any, metadata_identifier: str -) -> Tuple[bool, str]: - logger.debug(f"Received update {value = } for {metadata_identifier = }") + value: MetadataInputTypes, + metadata_identifier: str, +) -> tuple[bool, str]: + """Handle user inputs of dataset metadata values.""" + logger.debug( + "Received updated value = %s for metadata_identifier = %s", + value, + metadata_identifier, + ) try: value = process_special_cases(value, metadata_identifier) - - logger.debug(f"Updating {value = } for {metadata_identifier = }") # Update the value in the model setattr( state.metadata.meta.dataset, @@ -86,14 +140,20 @@ def accept_dataset_metadata_input( else: show_error = False error_explanation = "" - logger.debug(f"Successfully updated {metadata_identifier} with {value}") + logger.debug( + "Successfully updated value = %s for metadata_identifier = %s", + value, + metadata_identifier, + ) return show_error, error_explanation -def update_dataset_metadata_language() -> List[Any]: - """Return new values for ALL the dataset metadata inputs to allow - editing of strings in the chosen language""" +def update_dataset_metadata_language() -> list[MetadataInputTypes]: + """Return new values for ALL the dataset metadata inputs. + + This allows editing of strings in the chosen language. + """ return [ m.value_getter(state.metadata.meta.dataset, m.identifier) for m in DISPLAYED_DATASET_METADATA @@ -102,7 +162,14 @@ def update_dataset_metadata_language() -> List[Any]: def change_language_dataset_metadata( language: SupportedLanguages, -) -> tuple[tuple[List[Dict[str, str]], ...], List]: +) -> tuple[tuple[list[dict[str, str]], ...], list]: + """Change the language for the displayed dataset metadata. + + This is done in three steps: + - Update the chosen language globally. + - Update the language for dropdown options. + - Update the language of all the metadata values. + """ update_global_language_state(language) return ( *( diff --git a/datadoc/frontend/callbacks/register_callbacks.py b/datadoc/frontend/callbacks/register_callbacks.py index b4ad6ad3..6a082d5c 100644 --- a/datadoc/frontend/callbacks/register_callbacks.py +++ b/datadoc/frontend/callbacks/register_callbacks.py @@ -1,37 +1,48 @@ -import traceback -from typing import Any, Dict, List, Tuple +"""All decorated callback functions should be defined here. + +Implementations of the callback functionality should be in other functions (in other files), to enable unit testing. +""" + + +from __future__ import annotations + +from typing import TYPE_CHECKING from dash import ALL, Dash, Input, Output, State, ctx from datadoc_model.Enums import SupportedLanguages -import datadoc.state as state +from datadoc import state from datadoc.frontend.callbacks.dataset import ( accept_dataset_metadata_input, change_language_dataset_metadata, - open_dataset, + open_dataset_handling, ) from datadoc.frontend.callbacks.variables import ( accept_variable_metadata_input, update_variable_table_dropdown_options_for_language, update_variable_table_language, ) -from datadoc.frontend.components.DatasetTab import DATASET_METADATA_INPUT -from datadoc.frontend.fields.DisplayDataset import DISPLAYED_DROPDOWN_DATASET_METADATA +from datadoc.frontend.components.dataset_tab import DATASET_METADATA_INPUT +from datadoc.frontend.fields.display_dataset import DISPLAYED_DROPDOWN_DATASET_METADATA -# Avoid implementing callbacks here. -# -# The Dash Inputs and Outputs etc. shall be defined here, but the -# implementations should be in other functions, to enable unit testing +if TYPE_CHECKING: + from datadoc.frontend.callbacks.utils import MetadataInputTypes def register_callbacks(app: Dash) -> None: + """Define and register callbacks.""" + @app.callback( Output("progress-bar", "value"), Output("progress-bar", "label"), Input({"type": DATASET_METADATA_INPUT, "id": ALL}, "value"), Input("variables-table", "data"), ) - def callback_update_progress(value, data) -> Tuple[int, str]: + def callback_update_progress( + value: MetadataInputTypes, # noqa: ARG001 argument required by Dash + data: list[dict], # noqa: ARG001 argument required by Dash + ) -> tuple[int, str]: + """Update the progress bar when new data is entered.""" completion = state.metadata.percent_complete return completion, f"{completion}%" @@ -40,14 +51,15 @@ def callback_update_progress(value, data) -> Tuple[int, str]: Input("save-button", "n_clicks"), prevent_initial_call=True, ) - def callback_save_metadata_file(n_clicks): + def callback_save_metadata_file(n_clicks: int) -> bool: + """Save the metadata document to disk.""" if n_clicks and n_clicks > 0: # Write the final completion percentage to the model state.metadata.meta.percentage_complete = state.metadata.percent_complete state.metadata.write_metadata_document() return True - else: - return False + + return False @app.callback( *[ @@ -66,7 +78,10 @@ def callback_save_metadata_file(n_clicks): ), Input("language-dropdown", "value"), ) - def callback_change_language_dataset_metadata(language: str): + def callback_change_language_dataset_metadata( + language: str, + ) -> tuple[tuple[list[dict[str, str]], ...], list]: + """Update dataset metadata values upon change of language.""" return change_language_dataset_metadata(SupportedLanguages(language)) @app.callback( @@ -75,10 +90,17 @@ def callback_change_language_dataset_metadata(language: str): Input({"type": DATASET_METADATA_INPUT, "id": ALL}, "value"), prevent_initial_call=True, ) - def callback_accept_dataset_metadata_input(value: Any) -> Tuple[bool, str]: + def callback_accept_dataset_metadata_input( + value: MetadataInputTypes, # noqa: ARG001 argument required by Dash + ) -> tuple[bool, str]: + """Save updated dataset metadata values. + + Will display an alert if validation fails. + """ # Get the ID of the input that changed. This MUST match the attribute name defined in DataDocDataSet return accept_dataset_metadata_input( - ctx.triggered[0]["value"], ctx.triggered_id["id"] + ctx.triggered[0]["value"], + ctx.triggered_id["id"], ) @app.callback( @@ -92,18 +114,32 @@ def callback_accept_dataset_metadata_input(value: Any) -> Tuple[bool, str]: prevent_initial_call=True, ) def callback_variable_table( - active_cell: Dict, data: List[Dict], data_previous: List[Dict], language: str - ) -> Tuple[List[Dict], bool, str]: + active_cell: dict, + data: list[dict], + data_previous: list[dict], + language: str, + ) -> tuple[list[dict], bool, str]: + """Update data in the variable table. + + Triggered upon: + - New data enetered in the variable table. + - Change of language. + + Will display an alert if validation fails. + """ if ctx.triggered_id == "language-dropdown": return update_variable_table_language(SupportedLanguages(language)) - else: - return accept_variable_metadata_input(data, active_cell, data_previous) + + return accept_variable_metadata_input(data, active_cell, data_previous) @app.callback( Output("variables-table", "dropdown"), Input("language-dropdown", "value"), ) - def callback_variable_table_dropdown_options(language: str): + def callback_variable_table_dropdown_options( + language: str, + ) -> dict[str, dict[str, list[dict[str, str]]]]: + """Update the options in variable table dropdowns when the language changes.""" language = SupportedLanguages(language) return update_variable_table_dropdown_options_for_language(language) @@ -116,25 +152,15 @@ def callback_variable_table_dropdown_options(language: str): State("dataset-path-input", "value"), ) def callback_open_dataset( - n_clicks: int, dataset_path: str - ) -> Tuple[bool, bool, str, SupportedLanguages]: - try: - open_dataset(dataset_path) - except FileNotFoundError: - return ( - False, - True, - f"Datasettet '{dataset_path}' finnes ikke.", - state.current_metadata_language.value, - ) - except Exception as e: - return ( - False, - True, - "\n".join(traceback.format_exception_only(type(e), e)), - state.current_metadata_language.value, - ) - if n_clicks and n_clicks > 0: - return True, False, "", state.current_metadata_language.value - else: - return False, False, "", state.current_metadata_language.value + n_clicks: int, + dataset_path: str, + ) -> tuple[bool, bool, str, SupportedLanguages]: + """Open a dataset. + + Shows an alert on success or failure. + + To trigger reload of data in the UI, we update the + language dropdown. This is a hack and could be replaced + by a more formal mechanism. + """ + return open_dataset_handling(n_clicks, dataset_path) diff --git a/datadoc/frontend/callbacks/utils.py b/datadoc/frontend/callbacks/utils.py index 8310bf7b..a495e834 100644 --- a/datadoc/frontend/callbacks/utils.py +++ b/datadoc/frontend/callbacks/utils.py @@ -1,24 +1,35 @@ +"""Functions which aren't directly called from a decorated callback.""" + +from __future__ import annotations + import logging -from typing import Dict, List, Optional +from typing import TYPE_CHECKING from datadoc_model import Model -from datadoc_model.Enums import SupportedLanguages -from datadoc_model.LanguageStringsEnum import LanguageStringsEnum -import datadoc.state as state +from datadoc import state + +if TYPE_CHECKING: + from datadoc_model.Enums import SupportedLanguages + from datadoc_model.LanguageStringsEnum import LanguageStringsEnum logger = logging.getLogger(__name__) -def update_global_language_state(language: SupportedLanguages): - logger.debug(f"Updating language: {language.name}") +MetadataInputTypes: type = str | int | float | bool | None + + +def update_global_language_state(language: SupportedLanguages) -> None: + """Update global language state.""" + logger.debug("Updating language: %s", language.name) state.current_metadata_language = language def get_options_for_language( - language: SupportedLanguages, enum: LanguageStringsEnum -) -> List[Dict[str, str]]: - """Generate the list of options based on the currently chosen language""" + language: SupportedLanguages, + enum: LanguageStringsEnum, +) -> list[dict[str, str]]: + """Generate the list of options based on the currently chosen language.""" return [ { "label": i.get_value_for_language(language), @@ -29,17 +40,18 @@ def get_options_for_language( def find_existing_language_string( - metadata_model_object: "Model.DataDocBaseModel", + metadata_model_object: Model.DataDocBaseModel, value: str, metadata_identifier: str, -) -> Optional[Model.LanguageStrings]: +) -> Model.LanguageStrings | None: + """Get or create a LanguageStrings object and return it.""" # In this case we need to set the string to the correct language code language_strings = getattr(metadata_model_object, metadata_identifier) if language_strings is None: # This means that no strings have been saved yet so we need to construct # a new LanguageStrings object language_strings = Model.LanguageStrings( - **{state.current_metadata_language.value: value} + **{state.current_metadata_language.value: value}, ) else: # In this case there's an existing object so we save this string diff --git a/datadoc/frontend/callbacks/variables.py b/datadoc/frontend/callbacks/variables.py index be3b423f..ce2b8bb5 100644 --- a/datadoc/frontend/callbacks/variables.py +++ b/datadoc/frontend/callbacks/variables.py @@ -1,15 +1,19 @@ +"""Callback functions to do with variables metadata.""" + +from __future__ import annotations + import logging -from typing import Dict, List, Optional, Tuple from datadoc_model.Enums import SupportedLanguages from pydantic import ValidationError -import datadoc.state as state +from datadoc import state from datadoc.frontend.callbacks.utils import ( + MetadataInputTypes, find_existing_language_string, get_options_for_language, ) -from datadoc.frontend.fields.DisplayVariables import ( +from datadoc.frontend.fields.display_variables import ( DISPLAYED_DROPDOWN_VARIABLES_METADATA, DISPLAYED_DROPDOWN_VARIABLES_TYPES, MULTIPLE_LANGUAGE_VARIABLES_METADATA, @@ -20,7 +24,14 @@ logger = logging.getLogger(__name__) -def get_boolean_options_for_language(language: SupportedLanguages): +def get_boolean_options_for_language( + language: SupportedLanguages, +) -> list[dict[str, bool | str]]: + """Get boolean options for the given language. + + The Dash Datatable has no good support for boolean + choices, so we use a Dropdown. + """ true_labels = { SupportedLanguages.ENGLISH: "Yes", SupportedLanguages.NORSK_NYNORSK: "Ja", @@ -43,31 +54,30 @@ def get_boolean_options_for_language(language: SupportedLanguages): ] -def get_metadata_field(data, data_previous, active_cell) -> str: +def get_metadata_field( + data: list[dict], + data_previous: list[dict], + active_cell: dict, +) -> str: + """Find which field (column in the data table) has been updated.""" for i in range(len(data)): - # First strategy to find which column we're in; diff the current and previous data + # Find which column we're in; by diffing the current and previous data update_diff = list(data[i].items() - data_previous[i].items()) if update_diff: - metadata_field = update_diff[-1][0] - return ( - metadata_field # We're only interested in one change so we break here - ) + return update_diff[-1][0] - # When copy/pasting the diff fails, so we fall back to the active cell - metadata_field = active_cell["column_id"] - return metadata_field + # When diffing fails, we fall back to the active cell (this happens + # when the user pastes a value into the Data Table) + return active_cell["column_id"] def handle_multi_language_metadata( - metadata_field, new_value, updated_row_id -) -> Optional[str]: - if type(new_value) is str: - return find_existing_language_string( - state.metadata.variables_lookup[updated_row_id], - new_value, - metadata_field, - ) - elif new_value is None: + metadata_field: str, + new_value: MetadataInputTypes, + updated_row_id: str, +) -> str | None: + """Handle updates to fields which support multiple languages.""" + if new_value is None: # This edge case occurs when the user removes the text in an input field # We want to ensure we only remove the content for the current language, # not create a new blank object! @@ -76,37 +86,44 @@ def handle_multi_language_metadata( "", metadata_field, ) - else: - return new_value + + if isinstance(new_value, str): + return find_existing_language_string( + state.metadata.variables_lookup[updated_row_id], + new_value, + metadata_field, + ) + + return new_value def accept_variable_metadata_input( - data: List[Dict], - active_cell: Dict, - data_previous: List[Dict], -) -> Tuple[List[Dict], bool, str]: + data: list[dict], + active_cell: dict, + data_previous: list[dict], +) -> tuple[list[dict], bool, str]: + """Validate and save the value when variable metadata is updated.""" show_error = False error_explanation = "" output_data = data - metadata_field = get_metadata_field(data, data_previous, active_cell) + metadata_field: str = get_metadata_field(data, data_previous, active_cell) for row_index in range(len(data)): # Update all the variables for this column to ensure we read in the value - new_value = data[row_index][metadata_field] + new_value: MetadataInputTypes = data[row_index][metadata_field] updated_row_id = data[row_index][VariableIdentifiers.SHORT_NAME.value] try: if metadata_field in MULTIPLE_LANGUAGE_VARIABLES_METADATA: new_value = handle_multi_language_metadata( - metadata_field, new_value, updated_row_id + metadata_field, + new_value, + updated_row_id, ) elif new_value == "": # Allow clearing non-multiple-language text fields new_value = None - logger.debug( - f"{row_index = } | {updated_row_id = } | {metadata_field = } | {new_value = }" - ) # Write the value to the variables structure setattr( state.metadata.variables_lookup[updated_row_id], @@ -119,16 +136,17 @@ def accept_variable_metadata_input( output_data = data_previous logger.debug("Caught ValidationError:", exc_info=True) else: - logger.debug(f"Successfully updated {updated_row_id} with {new_value}") + logger.debug("Successfully updated %s with %s", updated_row_id, new_value) return output_data, show_error, error_explanation def update_variable_table_dropdown_options_for_language( language: SupportedLanguages, -) -> Dict[str, Dict[str, List[Dict[str, str]]]]: - """Retrieves enum options for dropdowns in the Datatable. Handles the - special case of boolean values which we represent in the Datatable +) -> dict[str, dict[str, list[dict[str, str]]]]: + """Retrieve enum options for dropdowns in the Datatable. + + Handles the special case of boolean values which we represent in the Datatable with a Dropdown but they're not backed by an Enum. Example return structure: @@ -151,20 +169,23 @@ def update_variable_table_dropdown_options_for_language( else get_options_for_language(language, field_type) ) options.append({"options": value}) - return dict(zip(DISPLAYED_DROPDOWN_VARIABLES_METADATA, options)) + return dict(zip(DISPLAYED_DROPDOWN_VARIABLES_METADATA, options, strict=True)) def update_variable_table_language( language: SupportedLanguages, -) -> Tuple[List[Dict], bool, str]: +) -> tuple[list[dict], bool, str]: + """Get data in the relevant language.""" state.current_metadata_language = language - new_data = [] - for v in state.metadata.meta.variables: - new_data.append( + logger.debug("Updated variable table language: %s", language.name) + return ( + [ get_display_values( v, state.current_metadata_language, ) - ) - logger.debug(f"Updated variable table language: {language.name}") - return new_data, False, "" + for v in state.metadata.meta.variables + ], + False, # Don't show validation error + "", # No validation explanation needed + ) diff --git a/datadoc/frontend/components/__init__.py b/datadoc/frontend/components/__init__.py new file mode 100644 index 00000000..5e99e857 --- /dev/null +++ b/datadoc/frontend/components/__init__.py @@ -0,0 +1,4 @@ +"""All components (UI elements) for datadoc are defined in this package. + +When components use an repeated code, we should make a factory for that component, as a function in Builders.py +""" diff --git a/datadoc/frontend/components/Alerts.py b/datadoc/frontend/components/alerts.py similarity index 61% rename from datadoc/frontend/components/Alerts.py rename to datadoc/frontend/components/alerts.py index e48d1eda..861ad56d 100644 --- a/datadoc/frontend/components/Alerts.py +++ b/datadoc/frontend/components/alerts.py @@ -1,34 +1,38 @@ -from datadoc.frontend.components.Builders import AlertTypes, make_ssb_alert +"""Components for different types of alerts are defined here.""" -dataset_validation_error = make_ssb_alert( +from __future__ import annotations + +from datadoc.frontend.components.builders import AlertTypes, build_ssb_alert + +dataset_validation_error = build_ssb_alert( AlertTypes.WARNING, "dataset-validation-error", "Validering feilet", "dataset-validation-explanation", ) -variables_validation_error = make_ssb_alert( +variables_validation_error = build_ssb_alert( AlertTypes.WARNING, "variables-validation-error", "Validering feilet", "variables-validation-explanation", ) -opened_dataset_error = make_ssb_alert( +opened_dataset_error = build_ssb_alert( AlertTypes.WARNING, "opened-dataset-error", "Kunne ikke åpne datasettet", "opened-dataset-error-explanation", ) -saved_metadata_success = make_ssb_alert( +saved_metadata_success = build_ssb_alert( AlertTypes.SUCCESS, "saved-metadata-success", "Lagret metadata", "saved-metadata-success-explanation", ) -opened_dataset_success = make_ssb_alert( +opened_dataset_success = build_ssb_alert( AlertTypes.SUCCESS, "opened-dataset-success", "Åpnet datasett", diff --git a/datadoc/frontend/components/Builders.py b/datadoc/frontend/components/builders.py similarity index 71% rename from datadoc/frontend/components/Builders.py rename to datadoc/frontend/components/builders.py index 626a8833..4439231b 100644 --- a/datadoc/frontend/components/Builders.py +++ b/datadoc/frontend/components/builders.py @@ -1,3 +1,5 @@ +"""Factory functions for different components are defined here.""" + import re from dataclasses import dataclass from enum import Enum, auto @@ -7,17 +9,22 @@ class AlertTypes(Enum): + """Types of alerts.""" + SUCCESS = auto() WARNING = auto() @dataclass class AlertType: + """Attributes of a concrete alert type.""" + alert_class_name: str color: str @staticmethod def get_type(alert_type: AlertTypes) -> "AlertType": + """Get a concrete alert type based on the given enum values.""" return ALERT_TYPES[alert_type] @@ -33,7 +40,8 @@ def get_type(alert_type: AlertTypes) -> "AlertType": } -def make_ssb_styled_tab(label: str, content: dbc.Container) -> dbc.Tab: +def build_ssb_styled_tab(label: str, content: dbc.Container) -> dbc.Tab: + """Make a Dash Tab according to SSBs Design System.""" return dbc.Tab( label=label, # Replace all whitespace with dashes @@ -45,9 +53,13 @@ def make_ssb_styled_tab(label: str, content: dbc.Container) -> dbc.Tab: ) -def make_ssb_alert( - alert_type: AlertTypes, alert_identifier: str, title: str, content_identifier: str +def build_ssb_alert( + alert_type: AlertTypes, + alert_identifier: str, + title: str, + content_identifier: str, ) -> dbc.Alert: + """Make a Dash Alert according to SSBs Design System.""" alert = AlertType.get_type(alert_type) return dbc.Alert( id=alert_identifier, @@ -67,7 +79,8 @@ def make_ssb_alert( ) -def make_ssb_button(text: str, icon_class: str, button_id: str) -> dbc.Button: +def build_ssb_button(text: str, icon_class: str, button_id: str) -> dbc.Button: + """Make a Dash Button according to SSBs Design System.""" return dbc.Button( [ html.I( diff --git a/datadoc/frontend/components/HeaderBars.py b/datadoc/frontend/components/control_bars.py similarity index 82% rename from datadoc/frontend/components/HeaderBars.py rename to datadoc/frontend/components/control_bars.py index daf4972d..d29722c5 100644 --- a/datadoc/frontend/components/HeaderBars.py +++ b/datadoc/frontend/components/control_bars.py @@ -1,10 +1,14 @@ +"""Components and layout which are not inside a tab.""" + +from __future__ import annotations + import dash_bootstrap_components as dbc from dash import dcc, html from datadoc_model.Enums import SupportedLanguages from datadoc import state from datadoc.frontend.callbacks.dataset import get_dataset_path -from datadoc.frontend.components.Builders import make_ssb_button +from datadoc.frontend.components.builders import build_ssb_button from datadoc.utils import get_app_version COLORS = {"dark_1": "#F0F8F9", "green_1": "#ECFEED", "green_4": "#00824D"} @@ -24,12 +28,14 @@ ) -def get_language_dropdown() -> dbc.Row: +def build_language_dropdown() -> dbc.Row: + """Build the language dropdown.""" return dbc.CardBody( dbc.Row( [ dbc.Col( - html.P(f"v{get_app_version()}", className="small"), align="end" + html.P(f"v{get_app_version()}", className="small"), + align="end", ), dbc.Col( dcc.Dropdown( @@ -47,11 +53,18 @@ def get_language_dropdown() -> dbc.Row: ), ], justify="between", - ) + ), ) -def get_controls_bar() -> dbc.CardBody: +def build_controls_bar() -> dbc.CardBody: + """Build the Controls Bar. + + This contains: + - A text input to specify the path to a dataset + - A button to open a dataset + - A button to save metadata to disk + """ return dbc.CardBody( children=[ dbc.Row( @@ -70,20 +83,20 @@ def get_controls_bar() -> dbc.CardBody: width="auto", ), dbc.Col( - make_ssb_button( + build_ssb_button( text="Åpne datasett", icon_class="bi bi-folder2-open", button_id="open-button", ), width=2, ), - ] + ], ), width=6, ), dbc.Col(), dbc.Col( - make_ssb_button( + build_ssb_button( text="Lagre metadata", icon_class="bi bi-save", button_id="save-button", @@ -92,6 +105,6 @@ def get_controls_bar() -> dbc.CardBody: ), ], justify="between", - ) + ), ], ) diff --git a/datadoc/frontend/components/DatasetTab.py b/datadoc/frontend/components/dataset_tab.py similarity index 69% rename from datadoc/frontend/components/DatasetTab.py rename to datadoc/frontend/components/dataset_tab.py index a96cbc0f..7470a47f 100644 --- a/datadoc/frontend/components/DatasetTab.py +++ b/datadoc/frontend/components/dataset_tab.py @@ -1,10 +1,12 @@ -from typing import List +"""Components and layout for the Dataset metadata tab.""" + +from __future__ import annotations import dash_bootstrap_components as dbc from dash import html -from datadoc.frontend.components.Builders import make_ssb_styled_tab -from datadoc.frontend.fields.DisplayDataset import ( +from datadoc.frontend.components.builders import build_ssb_styled_tab +from datadoc.frontend.fields.display_dataset import ( NON_EDITABLE_DATASET_METADATA, OBLIGATORY_EDITABLE_DATASET_METADATA, OPTIONAL_DATASET_METADATA, @@ -14,10 +16,14 @@ DATASET_METADATA_INPUT = "dataset-metadata-input" -def make_dataset_metadata_accordion_item( +def build_dataset_metadata_accordion_item( title: str, - metadata_inputs: List[DisplayDatasetMetadata], + metadata_inputs: list[DisplayDatasetMetadata], ) -> dbc.AccordionItem: + """Build a Dash AccordionItem for the given Metadata inputs. + + Typically used to categorize metadata fields. + """ return dbc.AccordionItem( title=title, children=[ @@ -37,15 +43,16 @@ def make_dataset_metadata_accordion_item( width=5, ), dbc.Col(width=4), - ] + ], ) for i in metadata_inputs ], ) -def get_dataset_tab() -> dbc.Tab: - return make_ssb_styled_tab( +def build_dataset_tab() -> dbc.Tab: + """Build the Dataset metadata tab.""" + return build_ssb_styled_tab( "Datasett", dbc.Container( [ @@ -53,15 +60,15 @@ def get_dataset_tab() -> dbc.Tab: dbc.Accordion( always_open=True, children=[ - make_dataset_metadata_accordion_item( + build_dataset_metadata_accordion_item( "Obligatorisk", OBLIGATORY_EDITABLE_DATASET_METADATA, ), - make_dataset_metadata_accordion_item( + build_dataset_metadata_accordion_item( "Valgfritt", OPTIONAL_DATASET_METADATA, ), - make_dataset_metadata_accordion_item( + build_dataset_metadata_accordion_item( "Maskingenerert", NON_EDITABLE_DATASET_METADATA, ), diff --git a/datadoc/frontend/components/VariablesTab.py b/datadoc/frontend/components/variables_tab.py similarity index 86% rename from datadoc/frontend/components/VariablesTab.py rename to datadoc/frontend/components/variables_tab.py index c4b537c4..d0fce2c5 100644 --- a/datadoc/frontend/components/VariablesTab.py +++ b/datadoc/frontend/components/variables_tab.py @@ -1,17 +1,22 @@ +"""Components and layout for the Variables metadata tab.""" + +from __future__ import annotations + import dash_bootstrap_components as dbc from dash import dash_table, html -import datadoc.state as state -from datadoc.frontend.components.Builders import make_ssb_styled_tab -from datadoc.frontend.fields.DisplayVariables import ( +from datadoc import state +from datadoc.frontend.components.builders import build_ssb_styled_tab +from datadoc.frontend.fields.display_variables import ( DISPLAY_VARIABLES, VariableIdentifiers, ) from datadoc.utils import get_display_values -def get_variables_tab() -> dbc.Tab: - return make_ssb_styled_tab( +def build_variables_tab() -> dbc.Tab: + """Build the Variables metadata tab.""" + return build_ssb_styled_tab( "Variabler", dbc.Container( children=[ @@ -35,7 +40,7 @@ def get_variables_tab() -> dbc.Tab: } for variable in DISPLAY_VARIABLES.values() if variable.identifier - != VariableIdentifiers.IDENTIFIER.value # TODO: Remove this from the model, for now we hide it + != VariableIdentifiers.IDENTIFIER.value # Should be removed from the model, for now we hide it ], # Non-obligatory variables are hidden by default hidden_columns=[ diff --git a/datadoc/frontend/fields/DisplayBase.py b/datadoc/frontend/fields/DisplayBase.py deleted file mode 100644 index f95c0795..00000000 --- a/datadoc/frontend/fields/DisplayBase.py +++ /dev/null @@ -1,74 +0,0 @@ -import logging -from dataclasses import dataclass, field -from typing import Any, Callable, Dict, List, Optional, Type - -from dash import dcc -from dash.development.base_component import Component -from datadoc_model.LanguageStrings import LanguageStrings -from pydantic import BaseModel - -from datadoc import state - -logger = logging.getLogger(__name__) - -INPUT_KWARGS = { - "debounce": True, - "style": {"width": "100%"}, - "className": "ssb-input", -} -NUMBER_KWARGS = dict(**INPUT_KWARGS, **{"type": "number"}) -DROPDOWN_KWARGS = { - "style": {"width": "100%"}, - "className": "ssb-dropdown", -} - - -def kwargs_factory(): - """For initialising the field extra_kwargs. We aren't allowed to - directly assign a mutable type like a dict to a dataclass field""" - return INPUT_KWARGS - - -def get_standard_metadata(metadata: BaseModel, identifier: str) -> Any: - return metadata.dict()[identifier] - - -def get_metadata_and_stringify(metadata: BaseModel, identifier: str) -> str: - return str(metadata.dict()[identifier]) - - -def get_multi_language_metadata(metadata: BaseModel, identifier: str) -> Optional[str]: - value: LanguageStrings = getattr(metadata, identifier) - if value is None: - return value - return getattr(value, state.current_metadata_language) - - -def get_list_of_strings(metadata: BaseModel, identifier: str) -> str: - value: List[str] = getattr(metadata, identifier) - if value is None: - return "" - return ", ".join(value) - - -@dataclass -class DisplayMetadata: - identifier: str - display_name: str - description: str - obligatory: bool = False - editable: bool = True - multiple_language_support: bool = False - - -@dataclass -class DisplayVariablesMetadata(DisplayMetadata): - options: Optional[Dict[str, List[Dict[str, str]]]] = None - presentation: Optional[str] = "input" - - -@dataclass -class DisplayDatasetMetadata(DisplayMetadata): - extra_kwargs: Dict[str, Any] = field(default_factory=kwargs_factory) - component: Type[Component] = dcc.Input - value_getter: Callable[[BaseModel, str], Any] = get_standard_metadata diff --git a/datadoc/frontend/fields/__init__.py b/datadoc/frontend/fields/__init__.py new file mode 100644 index 00000000..734897b6 --- /dev/null +++ b/datadoc/frontend/fields/__init__.py @@ -0,0 +1 @@ +"""Functionality for displaying dataset and variables metadata fields.""" diff --git a/datadoc/frontend/fields/display_base.py b/datadoc/frontend/fields/display_base.py new file mode 100644 index 00000000..01c0adcf --- /dev/null +++ b/datadoc/frontend/fields/display_base.py @@ -0,0 +1,104 @@ +"""Functionality common to displaying dataset and variables metadata.""" + +from __future__ import annotations + +import logging +import typing as t +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from dash import dcc + +from datadoc import state + +if TYPE_CHECKING: + from collections.abc import Callable + + from dash.development.base_component import Component + from datadoc_model.LanguageStrings import LanguageStrings + from pydantic import BaseModel + + from datadoc.frontend.callbacks.utils import MetadataInputTypes + +logger = logging.getLogger(__name__) + +INPUT_KWARGS = { + "debounce": True, + "style": {"width": "100%"}, + "className": "ssb-input", +} +NUMBER_KWARGS = dict(type="number", **INPUT_KWARGS) +DROPDOWN_KWARGS = { + "style": {"width": "100%"}, + "className": "ssb-dropdown", +} + + +def kwargs_factory() -> dict[str, t.Any]: + """Initialize the field extra_kwargs. + + We aren't allowed to directly assign a mutable type like a dict to + a dataclass field. + """ + return INPUT_KWARGS + + +def get_standard_metadata(metadata: BaseModel, identifier: str) -> MetadataInputTypes: + """Get a metadata value from the model.""" + return metadata.dict()[identifier] + + +def get_metadata_and_stringify(metadata: BaseModel, identifier: str) -> str: + """Get a metadata value from the model and cast to string.""" + return str(get_standard_metadata(metadata, identifier)) + + +def get_multi_language_metadata(metadata: BaseModel, identifier: str) -> str | None: + """Get a metadata value supportng multiple languages from the model.""" + value: LanguageStrings = getattr(metadata, identifier) + if value is None: + return value + return getattr(value, state.current_metadata_language) + + +def get_comma_separated_string(metadata: BaseModel, identifier: str) -> str: + """Get a metadata value which is a list of strings from the model and convert it to a comma separated string.""" + value: list[str] = getattr(metadata, identifier) + if value is None: + return "" + return ", ".join(value) + + +@dataclass +class DisplayMetadata: + """Controls for how a given metadata field should be displayed.""" + + identifier: str + display_name: str + description: str + obligatory: bool = False + editable: bool = True + multiple_language_support: bool = False + + +@dataclass +class DisplayVariablesMetadata(DisplayMetadata): + """Controls for how a given metadata field should be displayed. + + Specific to variable fields. + """ + + options: dict[str, list[dict[str, str]]] | None = None + presentation: str | None = "input" + + +@dataclass +class DisplayDatasetMetadata(DisplayMetadata): + """Controls for how a given metadata field should be displayed. + + Specific to dataset fields. + """ + + extra_kwargs: dict[str, Any] = field(default_factory=kwargs_factory) + component: type[Component] = dcc.Input + value_getter: Callable[[BaseModel, str], Any] = get_standard_metadata diff --git a/datadoc/frontend/fields/DisplayDataset.py b/datadoc/frontend/fields/display_dataset.py similarity index 96% rename from datadoc/frontend/fields/DisplayDataset.py rename to datadoc/frontend/fields/display_dataset.py index df03bf48..a6fcc124 100644 --- a/datadoc/frontend/fields/DisplayDataset.py +++ b/datadoc/frontend/fields/display_dataset.py @@ -1,15 +1,18 @@ +"""Functionality for displaying dataset metadata.""" + +from __future__ import annotations + import logging from enum import Enum -from typing import Dict, List from dash import dcc from datadoc_model import Model -from datadoc.frontend.fields.DisplayBase import ( +from datadoc.frontend.fields.display_base import ( DROPDOWN_KWARGS, NUMBER_KWARGS, DisplayDatasetMetadata, - get_list_of_strings, + get_comma_separated_string, get_metadata_and_stringify, get_multi_language_metadata, ) @@ -18,7 +21,7 @@ class DatasetIdentifiers(str, Enum): - """As defined here: https://statistics-norway.atlassian.net/l/c/aoSfEWJU""" + """As defined here: https://statistics-norway.atlassian.net/l/c/aoSfEWJU.""" SHORT_NAME = "short_name" ASSESSMENT = "assessment" @@ -46,7 +49,7 @@ class DatasetIdentifiers(str, Enum): CONTAINS_DATA_UNTIL = "contains_data_until" -DISPLAY_DATASET: Dict[DatasetIdentifiers, DisplayDatasetMetadata] = { +DISPLAY_DATASET: dict[DatasetIdentifiers, DisplayDatasetMetadata] = { DatasetIdentifiers.SHORT_NAME: DisplayDatasetMetadata( identifier=DatasetIdentifiers.SHORT_NAME.value, display_name="Kortnavn", @@ -141,7 +144,7 @@ class DatasetIdentifiers(str, Enum): identifier=DatasetIdentifiers.KEYWORD.value, display_name="Nøkkelord", description="En kommaseparert liste med søkbare nøkkelord som kan bidra til utvikling av effektive filtrerings- og søketjeneste.", - value_getter=get_list_of_strings, + value_getter=get_comma_separated_string, ), DatasetIdentifiers.SPATIAL_COVERAGE_DESCRIPTION: DisplayDatasetMetadata( identifier=DatasetIdentifiers.SPATIAL_COVERAGE_DESCRIPTION.value, @@ -236,7 +239,7 @@ class DatasetIdentifiers(str, Enum): # The order of this list MUST match the order of display components, as defined in DatasetTab.py -DISPLAYED_DATASET_METADATA: List[DisplayDatasetMetadata] = ( +DISPLAYED_DATASET_METADATA: list[DisplayDatasetMetadata] = ( OBLIGATORY_EDITABLE_DATASET_METADATA + OPTIONAL_DATASET_METADATA + NON_EDITABLE_DATASET_METADATA diff --git a/datadoc/frontend/fields/DisplayVariables.py b/datadoc/frontend/fields/display_variables.py similarity index 96% rename from datadoc/frontend/fields/DisplayVariables.py rename to datadoc/frontend/fields/display_variables.py index 01b62a3e..ce670058 100644 --- a/datadoc/frontend/fields/DisplayVariables.py +++ b/datadoc/frontend/fields/display_variables.py @@ -1,13 +1,17 @@ +"""Functionality for displaying variables metadata.""" + +from __future__ import annotations + from enum import Enum from datadoc_model import Model from datadoc_model.LanguageStringsEnum import LanguageStringsEnum -from datadoc.frontend.fields.DisplayBase import DisplayVariablesMetadata +from datadoc.frontend.fields.display_base import DisplayVariablesMetadata class VariableIdentifiers(str, Enum): - """As defined here: https://statistics-norway.atlassian.net/wiki/spaces/MPD/pages/3042869256/Variabelforekomst""" + """As defined here: https://statistics-norway.atlassian.net/wiki/spaces/MPD/pages/3042869256/Variabelforekomst.""" SHORT_NAME = "short_name" NAME = "name" diff --git a/datadoc/gunicorn.conf.py b/datadoc/gunicorn.conf.py index 3c7b2518..77961c08 100644 --- a/datadoc/gunicorn.conf.py +++ b/datadoc/gunicorn.conf.py @@ -1,3 +1,5 @@ +"""Configuraion for the Gunicorn server.""" + bind = "0.0.0.0:8050" workers = 1 loglevel = "info" diff --git a/datadoc/state.py b/datadoc/state.py index 5e86749c..07bbad2b 100644 --- a/datadoc/state.py +++ b/datadoc/state.py @@ -1,3 +1,13 @@ +"""Global state. + +DANGER: This global is safe when Datadoc is run as designed, with +an individual instance per user run within a Jupyter Notebook. + +If Datadoc is redeployed as a multi-user web app then this storage +strategy must be modified, since users will modify each others data. +See here: https://dash.plotly.com/sharing-data-between-callbacks +""" + from typing import TYPE_CHECKING from datadoc_model.Enums import SupportedLanguages @@ -5,14 +15,8 @@ if TYPE_CHECKING: # This is only needed for a type hint so we put the import inside # this check to avoid circular imports - from datadoc.backend.DataDocMetadata import DataDocMetadata - -# DANGER: This global is safe when Datadoc is run as designed, with -# an individual instance per user run within a Jupyter Notebook. -# -# If Datadoc is redeployed as a multi-user web app then this storage -# strategy must be modified, since users will modify each others data. -# See here: https://dash.plotly.com/sharing-data-between-callbacks + from datadoc.backend.datadoc_metadata import DataDocMetadata + # Global metadata container metadata: "DataDocMetadata" diff --git a/datadoc/tests/__init__.py b/datadoc/tests/__init__.py index e69de29b..c2801b9b 100644 --- a/datadoc/tests/__init__.py +++ b/datadoc/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for Datadoc.""" diff --git a/datadoc/tests/conftest.py b/datadoc/tests/conftest.py index e8ce954c..af7899bf 100644 --- a/datadoc/tests/conftest.py +++ b/datadoc/tests/conftest.py @@ -1,13 +1,19 @@ +"""Shared fixtures and configuration.""" + import shutil -from datetime import datetime +import traceback +from datetime import datetime, timezone +from pathlib import Path +from unittest import mock import pytest from datadoc_model.Enums import SupportedLanguages +from pytest_mock import MockerFixture from datadoc import state +from datadoc.backend.datadoc_metadata import DataDocMetadata +from datadoc.backend.storage_adapter import StorageAdapter -from ..backend.DataDocMetadata import DataDocMetadata -from ..backend.StorageAdapter import StorageAdapter from .utils import ( TEST_BUCKET_PARQUET_FILEPATH, TEST_EXISTING_METADATA_DIRECTORY, @@ -19,38 +25,42 @@ ) -@pytest.fixture +@pytest.fixture() def dummy_timestamp() -> datetime: - return datetime(2022, 1, 1) + return datetime(2022, 1, 1, tzinfo=timezone.utc) -@pytest.fixture -def mock_timestamp(mocker, dummy_timestamp): +@pytest.fixture() +def _mock_timestamp(mocker: MockerFixture, dummy_timestamp: datetime) -> None: mocker.patch( - "datadoc.backend.DataDocMetadata.get_timestamp_now", + "datadoc.backend.datadoc_metadata.get_timestamp_now", return_value=dummy_timestamp, ) -@pytest.fixture -def metadata(mock_timestamp): - yield DataDocMetadata(str(TEST_PARQUET_FILEPATH)) +@pytest.fixture() +def metadata(_mock_timestamp: None) -> DataDocMetadata: + return DataDocMetadata(str(TEST_PARQUET_FILEPATH)) -@pytest.fixture +@pytest.fixture() def remove_document_file() -> None: yield None # Dummy value, No need to return anything in particular here full_path = TEST_RESOURCES_DIRECTORY / TEST_EXISTING_METADATA_FILE_NAME - full_path.unlink() + try: + full_path.unlink() + except FileNotFoundError as e: + print("File not deleted on teardown, exception caught:") # noqa: T201 + traceback.print_exception(type(e), e) -@pytest.fixture -def existing_metadata_path(): +@pytest.fixture() +def existing_metadata_path() -> Path: return TEST_EXISTING_METADATA_DIRECTORY -@pytest.fixture -def existing_metadata_file(existing_metadata_path) -> str: +@pytest.fixture() +def existing_metadata_file(existing_metadata_path: Path) -> str: # Setup by copying the file into the relevant directory shutil.copy( existing_metadata_path / TEST_EXISTING_METADATA_FILE_NAME, @@ -59,44 +69,47 @@ def existing_metadata_file(existing_metadata_path) -> str: return str(TEST_RESOURCES_METADATA_DOCUMENT) -@pytest.fixture +@pytest.fixture() @pytest.mark.parametrize( - "existing_metadata_path", [TEST_EXISTING_METADATA_WITH_VALID_ID_DIRECTORY] + "existing_metadata_file", + [TEST_EXISTING_METADATA_WITH_VALID_ID_DIRECTORY], ) -def existing_metadata_with_valid_id_file(existing_metadata_file) -> str: +def existing_metadata_with_valid_id_file(existing_metadata_file: Path) -> Path: return existing_metadata_file -@pytest.fixture -def clear_state(): +@pytest.fixture() +def _clear_state() -> None: + """Global fixture, referred to in pytest.ini.""" state.metadata = None state.current_metadata_language = SupportedLanguages.NORSK_BOKMÅL -@pytest.fixture -def mock_gcsfs_open(mocker): - mock = mocker.patch("gcsfs.GCSFileSystem.open") - return mock +@pytest.fixture() +def mock_gcsfs_open(mocker: MockerFixture): + return mocker.patch("gcsfs.GCSFileSystem.open") -@pytest.fixture -def mock_gcsfs_exists(mocker): +@pytest.fixture() +def mock_gcsfs_exists(mocker: MockerFixture): mock = mocker.patch("gcsfs.GCSFileSystem.exists") mock.return_value = True return mock -@pytest.fixture -def mock_pathlib_write_text(mocker): - mock = mocker.patch("pathlib.Path.write_text") - return mock +@pytest.fixture() +def mock_pathlib_write_text(mocker: MockerFixture): + return mocker.patch("pathlib.Path.write_text") -@pytest.fixture -def local_parquet_file(mock_pathlib_write_text): +@pytest.fixture() +def local_parquet_file(mock_pathlib_write_text: mock.patch): # noqa: ARG001 return StorageAdapter.for_path(str(TEST_PARQUET_FILEPATH)) -@pytest.fixture -def bucket_object_parquet_file(mock_gcsfs_open, mock_gcsfs_exists): +@pytest.fixture() +def bucket_object_parquet_file( + mock_gcsfs_open: mock.patch, # noqa: ARG001 + mock_gcsfs_exists: mock.patch, # noqa: ARG001 +): return StorageAdapter.for_path(TEST_BUCKET_PARQUET_FILEPATH) diff --git a/datadoc/tests/pytest.ini b/datadoc/tests/pytest.ini index b06ffac1..d33aabfe 100644 --- a/datadoc/tests/pytest.ini +++ b/datadoc/tests/pytest.ini @@ -1,2 +1,2 @@ [pytest] -usefixtures = clear_state +usefixtures = _clear_state diff --git a/datadoc/tests/test_callbacks.py b/datadoc/tests/test_callbacks.py index 82be76bf..bb49533c 100644 --- a/datadoc/tests/test_callbacks.py +++ b/datadoc/tests/test_callbacks.py @@ -1,3 +1,6 @@ +"""Tests for the callbacks package.""" +from __future__ import annotations + import random from copy import deepcopy @@ -5,22 +8,25 @@ from datadoc_model.Enums import DatasetState, Datatype, SupportedLanguages from datadoc_model.Model import DataDocDataSet, DataDocVariable, LanguageStrings -import datadoc.state as state -from datadoc.backend.DataDocMetadata import DataDocMetadata +from datadoc import state +from datadoc.backend.datadoc_metadata import DataDocMetadata from datadoc.frontend.callbacks.dataset import ( accept_dataset_metadata_input, change_language_dataset_metadata, update_dataset_metadata_language, update_global_language_state, ) -from datadoc.frontend.callbacks.utils import find_existing_language_string +from datadoc.frontend.callbacks.utils import ( + MetadataInputTypes, + find_existing_language_string, +) from datadoc.frontend.callbacks.variables import ( accept_variable_metadata_input, update_variable_table_dropdown_options_for_language, update_variable_table_language, ) -from datadoc.frontend.fields.DisplayDataset import DISPLAYED_DROPDOWN_DATASET_ENUMS -from datadoc.frontend.fields.DisplayVariables import VariableIdentifiers +from datadoc.frontend.fields.display_dataset import DISPLAYED_DROPDOWN_DATASET_ENUMS +from datadoc.frontend.fields.display_variables import VariableIdentifiers from datadoc.tests.utils import TEST_PARQUET_FILEPATH DATA_ORIGINAL = [ @@ -55,12 +61,15 @@ LANGUAGE_OBJECT = LanguageStrings(en=ENGLISH_NAME, nb=BOKMÅL_NAME) -@pytest.fixture -def active_cell(): +@pytest.fixture() +def active_cell() -> dict[str, MetadataInputTypes]: return {"row": 1, "column": 1, "column_id": "short_name", "row_id": None} -def test_accept_variable_metadata_input_no_change_in_data(metadata, active_cell): +def test_accept_variable_metadata_input_no_change_in_data( + metadata: DataDocMetadata, + active_cell: dict[str, MetadataInputTypes], +): state.metadata = metadata output = accept_variable_metadata_input(DATA_ORIGINAL, active_cell, DATA_ORIGINAL) assert output[0] == DATA_ORIGINAL @@ -68,7 +77,9 @@ def test_accept_variable_metadata_input_no_change_in_data(metadata, active_cell) assert output[2] == "" -def test_accept_variable_metadata_input_new_data(active_cell): +def test_accept_variable_metadata_input_new_data( + active_cell: dict[str, MetadataInputTypes], +): state.metadata = DataDocMetadata(str(TEST_PARQUET_FILEPATH)) output = accept_variable_metadata_input(DATA_VALID, active_cell, DATA_ORIGINAL) @@ -78,7 +89,9 @@ def test_accept_variable_metadata_input_new_data(active_cell): assert output[2] == "" -def test_accept_variable_metadata_clear_string(active_cell): +def test_accept_variable_metadata_clear_string( + active_cell: dict[str, MetadataInputTypes], +): state.metadata = DataDocMetadata(str(TEST_PARQUET_FILEPATH)) output = accept_variable_metadata_input(DATA_CLEAR_URI, active_cell, DATA_ORIGINAL) @@ -88,7 +101,9 @@ def test_accept_variable_metadata_clear_string(active_cell): assert output[2] == "" -def test_accept_variable_metadata_input_incorrect_data_type(active_cell): +def test_accept_variable_metadata_input_incorrect_data_type( + active_cell: dict[str, MetadataInputTypes], +): state.metadata = DataDocMetadata(str(TEST_PARQUET_FILEPATH)) previous_metadata = deepcopy(state.metadata.meta.variables) output = accept_variable_metadata_input(DATA_INVALID, active_cell, DATA_ORIGINAL) @@ -146,7 +161,9 @@ def test_find_existing_language_string_no_existing_strings(): dataset_metadata = DataDocDataSet() state.current_metadata_language = SupportedLanguages.NORSK_BOKMÅL language_strings = find_existing_language_string( - dataset_metadata, BOKMÅL_NAME, "name" + dataset_metadata, + BOKMÅL_NAME, + "name", ) assert language_strings == LanguageStrings(nb=BOKMÅL_NAME) @@ -156,29 +173,35 @@ def test_find_existing_language_string_pre_existing_strings(): dataset_metadata.name = LANGUAGE_OBJECT state.current_metadata_language = SupportedLanguages.NORSK_NYNORSK language_strings = find_existing_language_string( - dataset_metadata, NYNORSK_NAME, "name" + dataset_metadata, + NYNORSK_NAME, + "name", ) assert language_strings == LanguageStrings( - nb=BOKMÅL_NAME, en=ENGLISH_NAME, nn=NYNORSK_NAME + nb=BOKMÅL_NAME, + en=ENGLISH_NAME, + nn=NYNORSK_NAME, ) def test_update_variable_table_language(): state.metadata = DataDocMetadata(str(TEST_PARQUET_FILEPATH)) - test_variable = random.choice([v.short_name for v in state.metadata.meta.variables]) + test_variable = random.choice( # noqa: S311 not for cryptographic purposes + [v.short_name for v in state.metadata.meta.variables], + ) state.metadata.variables_lookup[test_variable].name = LANGUAGE_OBJECT output = update_variable_table_language( SupportedLanguages.NORSK_BOKMÅL, ) - name = [ + name = next( d[VariableIdentifiers.NAME.value] for d in output[0] if d[VariableIdentifiers.SHORT_NAME.value] == test_variable - ][0] + ) assert name == BOKMÅL_NAME -def test_nonetype_value_for_language_string(active_cell): +def test_nonetype_value_for_language_string(active_cell: dict[str, MetadataInputTypes]): state.metadata = DataDocMetadata(str(TEST_PARQUET_FILEPATH)) state.metadata.variables_lookup["pers_id"].name = LANGUAGE_OBJECT state.current_metadata_language = SupportedLanguages.NORSK_NYNORSK @@ -189,14 +212,14 @@ def test_nonetype_value_for_language_string(active_cell): def test_update_variable_table_dropdown_options_for_language(): options = update_variable_table_dropdown_options_for_language( - SupportedLanguages.NORSK_BOKMÅL + SupportedLanguages.NORSK_BOKMÅL, ) - assert all(k in DataDocVariable.__fields__.keys() for k in options.keys()) + assert all(k in DataDocVariable.__fields__ for k in options) assert all(list(v.keys()) == ["options"] for v in options.values()) assert all( list(d.keys()) == ["label", "value"] for v in options.values() - for d in list(v.values())[0] + for d in next(iter(v.values())) ) assert [d["label"] for d in options["data_type"]["options"]] == [ i.get_value_for_language(SupportedLanguages.NORSK_BOKMÅL) for i in Datatype @@ -204,7 +227,11 @@ def test_update_variable_table_dropdown_options_for_language(): def test_update_global_language_state(): - language: SupportedLanguages = random.choice(list(SupportedLanguages)) + language: SupportedLanguages = ( + random.choice( # noqa: S311 not for cryptographic purposes + list(SupportedLanguages), + ) + ) update_global_language_state(language) assert state.current_metadata_language == language @@ -212,13 +239,15 @@ def test_update_global_language_state(): def test_change_language_dataset_metadata(): state.metadata = DataDocMetadata(str(TEST_PARQUET_FILEPATH)) value = change_language_dataset_metadata(SupportedLanguages.NORSK_NYNORSK) - test = random.choice(DISPLAYED_DROPDOWN_DATASET_ENUMS) + test = random.choice( # noqa: S311 not for cryptographic purposes + DISPLAYED_DROPDOWN_DATASET_ENUMS, + ) assert isinstance(value, tuple) for options in value[0:-1]: assert all(list(d.keys()) == ["label", "value"] for d in options) - member_names = set(test._member_names_) + member_names = set(test._member_names_) # noqa: SLF001 values = [i for d in options for i in d.values()] if member_names.intersection(values): diff --git a/datadoc/tests/test_datadoc_metadata.py b/datadoc/tests/test_datadoc_metadata.py index 6ffe6290..78caa062 100644 --- a/datadoc/tests/test_datadoc_metadata.py +++ b/datadoc/tests/test_datadoc_metadata.py @@ -1,9 +1,10 @@ +"""Tests for the DataDocMetadata class.""" +from __future__ import annotations + import json -import os from copy import copy -from datetime import datetime -from pathlib import PurePath -from typing import List, Tuple +from pathlib import Path, PurePath +from typing import TYPE_CHECKING from uuid import UUID import pytest @@ -11,7 +12,7 @@ from datadoc_model.Enums import DatasetState from datadoc_model.Model import DataDocDataSet, DataDocVariable, MetadataDocument -from datadoc.backend.DataDocMetadata import PLACEHOLDER_USERNAME, DataDocMetadata +from datadoc.backend.datadoc_metadata import PLACEHOLDER_USERNAME, DataDocMetadata from .utils import ( TEST_EXISTING_METADATA_DIRECTORY, @@ -20,8 +21,11 @@ TEST_RESOURCES_DIRECTORY, ) +if TYPE_CHECKING: + from datetime import datetime + -def make_paths() -> List[Tuple[str, DatasetState]]: +def make_paths() -> list[tuple[str, DatasetState]]: split_path = list(PurePath(TEST_PARQUET_FILEPATH).parts) initial_data = [ ("kildedata", DatasetState.SOURCE_DATA), @@ -38,7 +42,7 @@ def make_paths() -> List[Tuple[str, DatasetState]]: for to_insert, state in initial_data: new_path = copy(split_path) new_path.insert(-2, to_insert) - new_path = PurePath("").joinpath(*new_path) + new_path = PurePath().joinpath(*new_path) test_data.append((str(new_path), state)) return test_data @@ -46,21 +50,26 @@ def make_paths() -> List[Tuple[str, DatasetState]]: @pytest.mark.parametrize(("path", "expected_result"), make_paths()) def test_get_dataset_state( - path: str, expected_result: DatasetState, metadata: DataDocMetadata + path: str, + expected_result: DatasetState, + metadata: DataDocMetadata, ): actual_state = metadata.get_dataset_state(path) assert actual_state == expected_result -def test_get_dataset_state_none(metadata): +def test_get_dataset_state_none(metadata: DataDocMetadata): assert metadata.get_dataset_state(None) is None -def test_existing_metadata_file(existing_metadata_file, metadata, remove_document_file): +@pytest.mark.usefixtures("existing_metadata_file", "remove_document_file") +def test_existing_metadata_file( + metadata: DataDocMetadata, +): assert metadata.meta.dataset.name.en == "successfully_read_existing_file" -def test_metadata_document_percent_complete(metadata): +def test_metadata_document_percent_complete(metadata: DataDocMetadata): dataset = DataDocDataSet(dataset_state=Enums.DatasetState.OUTPUT_DATA) variable_1 = DataDocVariable(data_type=Enums.Datatype.BOOLEAN) variable_2 = DataDocVariable(data_type=Enums.Datatype.INTEGER) @@ -72,7 +81,7 @@ def test_metadata_document_percent_complete(metadata): ) metadata.meta = document - assert metadata.percent_complete == 17 + assert metadata.percent_complete == 17 # noqa: PLR2004 def test_get_dataset_version(metadata: DataDocMetadata): @@ -83,24 +92,26 @@ def test_get_dataset_version_unknown(metadata: DataDocMetadata): assert metadata.get_dataset_version("person_data.parquet") is None +@pytest.mark.usefixtures("remove_document_file") def test_write_metadata_document( - dummy_timestamp: datetime, metadata: DataDocMetadata, remove_document_file + dummy_timestamp: datetime, + metadata: DataDocMetadata, ): metadata.write_metadata_document() written_document = TEST_RESOURCES_DIRECTORY / TEST_EXISTING_METADATA_FILE_NAME - assert os.path.exists(written_document) + assert Path.exists(written_document) assert metadata.meta.dataset.metadata_created_date == dummy_timestamp assert metadata.meta.dataset.metadata_created_by == PLACEHOLDER_USERNAME assert metadata.meta.dataset.metadata_last_updated_date == dummy_timestamp assert metadata.meta.dataset.metadata_last_updated_by == PLACEHOLDER_USERNAME - with open(written_document) as f: + with Path.open(written_document) as f: written_metadata = json.loads(f.read()) assert ( # Use our pydantic model to read in the datetime string so we get the correct format DataDocDataSet( - metadata_created_date=written_metadata["dataset"]["metadata_created_date"] + metadata_created_date=written_metadata["dataset"]["metadata_created_date"], ).metadata_created_date == dummy_timestamp ) @@ -110,7 +121,7 @@ def test_write_metadata_document( DataDocDataSet( metadata_last_updated_date=written_metadata["dataset"][ "metadata_last_updated_date" - ] + ], ).metadata_last_updated_date == dummy_timestamp ) @@ -119,11 +130,10 @@ def test_write_metadata_document( ) +@pytest.mark.usefixtures("existing_metadata_file", "remove_document_file") def test_write_metadata_document_existing_document( - dummy_timestamp, - existing_metadata_file, + dummy_timestamp: datetime, metadata: DataDocMetadata, - remove_document_file, ): original_created_date: datetime = metadata.meta.dataset.metadata_created_date original_created_by = metadata.meta.dataset.metadata_created_by @@ -139,52 +149,54 @@ def test_metadata_id(metadata: DataDocMetadata): @pytest.mark.parametrize( - "existing_metadata_path", [TEST_EXISTING_METADATA_DIRECTORY / "invalid_id_field"] + "existing_metadata_path", + [TEST_EXISTING_METADATA_DIRECTORY / "invalid_id_field"], ) +@pytest.mark.usefixtures("remove_document_file") def test_existing_metadata_none_id( - existing_metadata_file, metadata: DataDocMetadata, remove_document_file + existing_metadata_file: str, + metadata: DataDocMetadata, ): pre_open_id = "" post_write_id = "" - with open(existing_metadata_file) as f: + with Path.open(existing_metadata_file) as f: pre_open_id = json.load(f)["dataset"]["id"] assert pre_open_id is None assert isinstance(metadata.meta.dataset.id, UUID) metadata.write_metadata_document() - with open(existing_metadata_file) as f: + with Path.open(existing_metadata_file) as f: post_write_id = json.load(f)["dataset"]["id"] assert post_write_id == str(metadata.meta.dataset.id) @pytest.mark.parametrize( - "existing_metadata_path", [TEST_EXISTING_METADATA_DIRECTORY / "valid_id_field"] + "existing_metadata_path", + [TEST_EXISTING_METADATA_DIRECTORY / "valid_id_field"], ) +@pytest.mark.usefixtures("remove_document_file") def test_existing_metadata_valid_id( - existing_metadata_file, + existing_metadata_file: str, metadata: DataDocMetadata, - remove_document_file, ): pre_open_id = "" post_write_id = "" - with open(existing_metadata_file) as f: + with Path.open(existing_metadata_file) as f: pre_open_id = json.load(f)["dataset"]["id"] assert pre_open_id is not None assert isinstance(metadata.meta.dataset.id, UUID) assert str(metadata.meta.dataset.id) == pre_open_id metadata.write_metadata_document() - with open(existing_metadata_file) as f: + with Path.open(existing_metadata_file) as f: post_write_id = json.load(f)["dataset"]["id"] assert post_write_id == pre_open_id def test_variable_role_default_value(metadata: DataDocMetadata): assert all( - [ - v.variable_role == Enums.VariableRole.MEASURE.value - for v in metadata.meta.variables - ] + v.variable_role == Enums.VariableRole.MEASURE.value + for v in metadata.meta.variables ) def test_direct_person_identifying_default_value(metadata: DataDocMetadata): - assert all([not v.direct_person_identifying for v in metadata.meta.variables]) + assert all(not v.direct_person_identifying for v in metadata.meta.variables) diff --git a/datadoc/tests/test_dataset_parser.py b/datadoc/tests/test_dataset_parser.py index 84016cd4..0c036d53 100644 --- a/datadoc/tests/test_dataset_parser.py +++ b/datadoc/tests/test_dataset_parser.py @@ -1,34 +1,40 @@ -import random +"""Tests for the DatasetParser class.""" import pytest from datadoc_model.Enums import Datatype, SupportedLanguages from datadoc_model.Model import DataDocVariable, LanguageStrings -from pytest import raises from datadoc import state -from datadoc.backend.DatasetParser import ( +from datadoc.backend.dataset_parser import ( KNOWN_BOOLEAN_TYPES, KNOWN_DATETIME_TYPES, KNOWN_FLOAT_TYPES, KNOWN_INTEGER_TYPES, KNOWN_STRING_TYPES, DatasetParser, + DatasetParserParquet, +) + +from .utils import ( + TEST_PARQUET_FILEPATH, + TEST_PARQUET_GZIP_FILEPATH, + TEST_SAS7BDAT_FILEPATH, ) -from .utils import TEST_PARQUET_FILEPATH, TEST_SAS7BDAT_FILEPATH, TEST_PARQUET_GZIP_FILEPATH def test_use_abstract_class_directly(): - with raises(TypeError): + with pytest.raises(TypeError): DatasetParser().get_fields() @pytest.mark.parametrize( "local_parser", - [DatasetParser.for_file(TEST_PARQUET_FILEPATH), - DatasetParser.for_file(TEST_PARQUET_GZIP_FILEPATH) - ], + [ + DatasetParser.for_file(TEST_PARQUET_FILEPATH), + DatasetParser.for_file(TEST_PARQUET_GZIP_FILEPATH), + ], ) -def test_get_fields_parquet(local_parser): +def test_get_fields_parquet(local_parser: DatasetParserParquet): expected_fields = [ DataDocVariable(short_name="pers_id", data_type=Datatype.STRING), DataDocVariable(short_name="tidspunkt", data_type=Datatype.DATETIME), @@ -53,7 +59,9 @@ def test_get_fields_sas7bdat(): data_type=Datatype.STRING, ), DataDocVariable( - short_name="tall", name=LanguageStrings(nb="Tall"), data_type=Datatype.FLOAT + short_name="tall", + name=LanguageStrings(nb="Tall"), + data_type=Datatype.FLOAT, ), DataDocVariable( short_name="dato", @@ -69,12 +77,12 @@ def test_get_fields_sas7bdat(): def test_get_fields_unknown_file_type(): - with raises(NotImplementedError): + with pytest.raises(NotImplementedError): DatasetParser.for_file("my_dataset.csv").get_fields() def test_get_fields_no_extension_provided(): - with raises(NotImplementedError): + with pytest.raises(NotImplementedError): DatasetParser.for_file("my_dataset").get_fields() @@ -85,14 +93,16 @@ def test_transform_datatype_unknown_type(): assert actual == expected -def test_transform_datatype(): - for expected, input_options in [ - (Datatype.INTEGER, KNOWN_INTEGER_TYPES), - (Datatype.FLOAT, KNOWN_FLOAT_TYPES), - (Datatype.STRING, KNOWN_STRING_TYPES), - (Datatype.DATETIME, KNOWN_DATETIME_TYPES), - (Datatype.BOOLEAN, KNOWN_BOOLEAN_TYPES), - ]: - input_data = random.choice(input_options) - actual = DatasetParser.transform_data_type(input_data) - assert actual == expected +@pytest.mark.parametrize( + ("expected", "concrete_type"), + [ + *[(Datatype.INTEGER, i) for i in KNOWN_INTEGER_TYPES], + *[(Datatype.FLOAT, i) for i in KNOWN_FLOAT_TYPES], + *[(Datatype.STRING, i) for i in KNOWN_STRING_TYPES], + *[(Datatype.DATETIME, i) for i in KNOWN_DATETIME_TYPES], + *[(Datatype.BOOLEAN, i) for i in KNOWN_BOOLEAN_TYPES], + ], +) +def test_transform_datatype(expected: Datatype, concrete_type: str): + actual = DatasetParser.transform_data_type(concrete_type) + assert actual == expected diff --git a/datadoc/tests/test_model.py b/datadoc/tests/test_model.py index 02062989..b8f688f3 100644 --- a/datadoc/tests/test_model.py +++ b/datadoc/tests/test_model.py @@ -1,9 +1,12 @@ -from datadoc.frontend.fields.DisplayDataset import DISPLAY_DATASET, DatasetIdentifiers -from datadoc.frontend.fields.DisplayVariables import ( +"""Verify that we are in sync with the Model.""" + +from datadoc_model.Model import DataDocDataSet, DataDocVariable + +from datadoc.frontend.fields.display_dataset import DISPLAY_DATASET, DatasetIdentifiers +from datadoc.frontend.fields.display_variables import ( DISPLAY_VARIABLES, VariableIdentifiers, ) -from datadoc_model.Model import DataDocDataSet, DataDocVariable def test_dataset_metadata_definition_parity(): @@ -15,6 +18,6 @@ def test_dataset_metadata_definition_parity(): def test_variables_metadata_definition_parity(): """The metadata fields are currently defined in multiple places for technical reasons. We want these to always be exactly identical.""" assert [i.value for i in VariableIdentifiers] == list( - DataDocVariable().dict().keys() + DataDocVariable().dict().keys(), ) assert list(VariableIdentifiers) == list(DISPLAY_VARIABLES.keys()) diff --git a/datadoc/tests/test_model_backwards_compatibility.py b/datadoc/tests/test_model_backwards_compatibility.py index 51995e3b..593cf9fb 100644 --- a/datadoc/tests/test_model_backwards_compatibility.py +++ b/datadoc/tests/test_model_backwards_compatibility.py @@ -1,14 +1,16 @@ +"""Tests for the ModelBackwardsCompatibility class.""" + import json -from pprint import pprint +from pathlib import Path import pytest -from datadoc.backend.DataDocMetadata import DataDocMetadata - -from ..backend.ModelBackwardsCompatibility import ( +from datadoc.backend.datadoc_metadata import DataDocMetadata +from datadoc.backend.model_backwards_compatibility import ( UnknownModelVersionError, upgrade_metadata, ) + from .utils import TEST_COMPATIBILITY_DIRECTORY BACKWARDS_COMPATIBLE_VERSION_DIRECTORIES = [ @@ -38,22 +40,23 @@ def test_existing_metadata_unknown_model_version(): BACKWARDS_COMPATIBLE_VERSION_DIRECTORIES, ids=BACKWARDS_COMPATIBLE_VERSION_NAMES, ) +@pytest.mark.usefixtures("remove_document_file") def test_backwards_compatibility( - existing_metadata_file, metadata: DataDocMetadata, remove_document_file + existing_metadata_file: str, + metadata: DataDocMetadata, ): # Parameterise with all known backwards compatible versions - with open(existing_metadata_file) as f: + with Path.open(existing_metadata_file) as f: file_metadata = json.loads(f.read()) in_file_values = [ v for v in file_metadata["dataset"].values() if v not in ["", None] ] read_in_values = json.loads(metadata.meta.dataset.json(exclude_none=True)).values() - pprint(f"{in_file_values = }") - pprint(f"{read_in_values = }") missing_values = [v for v in in_file_values if v not in read_in_values] if missing_values: + msg = f"Some values were not successfully read in! {missing_values = }" raise AssertionError( - f"Some values were not successfully read in! {missing_values = }" + msg, ) diff --git a/datadoc/tests/test_smoke.py b/datadoc/tests/test_smoke.py index 18a27f58..3fb3f0d3 100644 --- a/datadoc/tests/test_smoke.py +++ b/datadoc/tests/test_smoke.py @@ -1,6 +1,8 @@ -import datadoc.state as state +"""Smoke tests.""" + +from datadoc import state from datadoc.app import build_app -from datadoc.backend.DataDocMetadata import DataDocMetadata +from datadoc.backend.datadoc_metadata import DataDocMetadata from .utils import TEST_PARQUET_FILEPATH diff --git a/datadoc/tests/test_storage_adapter.py b/datadoc/tests/test_storage_adapter.py index d94fedab..bb1ffcda 100644 --- a/datadoc/tests/test_storage_adapter.py +++ b/datadoc/tests/test_storage_adapter.py @@ -1,26 +1,31 @@ +"""Tests for the StorageAdapter class.""" + import pathlib import pytest +from datadoc.backend.storage_adapter import GCSObject, LocalFile, StorageAdapter from datadoc.tests.utils import TEST_BUCKET_PARQUET_FILEPATH, TEST_PARQUET_FILEPATH -from ..backend.StorageAdapter import GCSObject, LocalFile, StorageAdapter - @pytest.mark.parametrize( ("file", "expected_class"), [("local_parquet_file", LocalFile), ("bucket_object_parquet_file", GCSObject)], ) -def test_factory(file: StorageAdapter, expected_class, request): +def test_factory( + file: StorageAdapter, + expected_class: StorageAdapter, + request: pytest.FixtureRequest, +): # Ugly pytest magic to get the actual fixture out - file = request.getfixturevalue(file) + file: StorageAdapter = request.getfixturevalue(file) assert isinstance(file, expected_class) @pytest.mark.parametrize("file", ["local_parquet_file", "bucket_object_parquet_file"]) -def test_open(file: StorageAdapter, request): +def test_open(file: str, request: pytest.FixtureRequest): # Ugly pytest magic to get the actual fixture out - file = request.getfixturevalue(file) + file: StorageAdapter = request.getfixturevalue(file) with file.open() as file_handle: assert file_handle.readable() @@ -38,9 +43,9 @@ def test_open(file: StorageAdapter, request): ), ], ) -def test_parent(file: StorageAdapter, expected_parent: str, request): +def test_parent(file: str, expected_parent: str, request: pytest.FixtureRequest): # Ugly pytest magic to get the actual fixture out - file = request.getfixturevalue(file) + file: StorageAdapter = request.getfixturevalue(file) assert file.parent() == expected_parent @@ -49,31 +54,39 @@ def test_parent(file: StorageAdapter, expected_parent: str, request): [ ( "local_parquet_file", - TEST_PARQUET_FILEPATH / "extra", + f"{TEST_PARQUET_FILEPATH}/extra", ), ( "bucket_object_parquet_file", - "/".join([TEST_BUCKET_PARQUET_FILEPATH, "extra"]), + f"{TEST_BUCKET_PARQUET_FILEPATH}/extra", ), ], ) -def test_joinpath(known_file: StorageAdapter, expected: str, request): +def test_joinpath( + known_file: str, + expected: str, + request: pytest.FixtureRequest, +): # Ugly pytest magic to get the actual fixture out - actual_file = request.getfixturevalue(known_file) + actual_file: StorageAdapter = request.getfixturevalue(known_file) actual_file.joinpath("extra") assert pathlib.Path(actual_file.location) == pathlib.Path(expected) @pytest.mark.parametrize( - "known_file", ["local_parquet_file", "bucket_object_parquet_file"] + "known_file", + ["local_parquet_file", "bucket_object_parquet_file"], ) -def test_exists(known_file: StorageAdapter, request): +def test_exists(known_file: str, request: pytest.FixtureRequest): # Ugly pytest magic to get the actual fixture out - actual_file = request.getfixturevalue(known_file) + actual_file: StorageAdapter = request.getfixturevalue(known_file) assert actual_file.exists() -def test_write_text_local_file(local_parquet_file: StorageAdapter, request): +def test_write_text_local_file( + local_parquet_file: StorageAdapter, + request: pytest.FixtureRequest, +): local_parquet_file.write_text("12345") mock = request.getfixturevalue("mock_pathlib_write_text") mock.assert_called_once_with("12345", encoding="utf-8") diff --git a/datadoc/tests/test_utils.py b/datadoc/tests/test_utils.py index fdaca0f4..e7fd855e 100644 --- a/datadoc/tests/test_utils.py +++ b/datadoc/tests/test_utils.py @@ -1,15 +1,18 @@ -from datadoc.tests.test_callbacks import BOKMÅL_NAME, LANGUAGE_OBJECT -from datadoc.utils import calculate_percentage, get_display_values, running_in_notebook +"""Tests for the utils module.""" + from datadoc_model.Enums import SupportedLanguages from datadoc_model.Model import DataDocVariable +from datadoc.tests.test_callbacks import BOKMÅL_NAME, LANGUAGE_OBJECT +from datadoc.utils import calculate_percentage, get_display_values, running_in_notebook + def test_not_running_in_notebook(): assert not running_in_notebook() def test_calculate_percentage(): - assert calculate_percentage(1, 3) == 33 + assert calculate_percentage(1, 3) == 33 # noqa: PLR2004 def test_get_display_values(): diff --git a/datadoc/tests/utils.py b/datadoc/tests/utils.py index 90834ce5..05ca1684 100644 --- a/datadoc/tests/utils.py +++ b/datadoc/tests/utils.py @@ -1,3 +1,5 @@ +"""Utility values and functions for tests.""" + from pathlib import Path TEST_BUCKET_PARQUET_FILEPATH = "gs://ssb-staging-dapla-felles-data-delt/datadoc/klargjorte_data/person_data_v1.parquet" diff --git a/datadoc/utils.py b/datadoc/utils.py index ebcec008..ee4c50a7 100644 --- a/datadoc/utils.py +++ b/datadoc/utils.py @@ -1,3 +1,5 @@ +"""General utilities.""" + import datetime from datadoc_model import Model @@ -5,9 +7,9 @@ def running_in_notebook() -> bool: - """Return True if running in Jupyter Notebook""" + """Return True if running in Jupyter Notebook.""" try: - return get_ipython().__class__.__name__ == "ZMQInteractiveShell" # type: ignore + return get_ipython().__class__.__name__ == "ZMQInteractiveShell" except NameError: # The get_ipython method is globally available in ipython interpreters # as used in Jupyter. However it is not available in other python @@ -17,26 +19,30 @@ def running_in_notebook() -> bool: def calculate_percentage(completed: int, total: int) -> int: - """Calculate percentage as a rounded integer""" + """Calculate percentage as a rounded integer.""" return round((completed / total) * 100) def get_display_values( - variable: Model.DataDocVariable, current_language: SupportedLanguages + variable: Model.DataDocVariable, + current_language: SupportedLanguages, ) -> dict: - """Return a dictionary representation of Model.DataDocVariable with strings in - the currently selected language""" + """Return a dictionary representation of Model.DataDocVariable with strings in the currently selected language.""" return_dict = {} for field_name, value in variable: if isinstance(value, Model.LanguageStrings): - value = value.dict()[current_language.value] - return_dict[field_name] = value + return_dict[field_name] = value.dict()[current_language.value] + else: + return_dict[field_name] = value return return_dict def pick_random_port() -> int: - """Pick a random free port number. The function will bind a socket to port 0, and - a random free port from 1024 to 65535 will be selected by the operating system.""" + """Pick a random free port number. + + The function will bind a socket to port 0, and a random free port from + 1024 to 65535 will be selected by the operating system. + """ import socket sock = socket.socket() @@ -45,10 +51,12 @@ def pick_random_port() -> int: def get_timestamp_now() -> datetime: - return datetime.datetime.now() + """Return a timestamp for the current moment.""" + return datetime.datetime.now(tz=datetime.timezone.utc) def get_app_version() -> str: + """Get the version of the Datadoc package.""" import pkg_resources return pkg_resources.get_distribution("ssb-datadoc").version diff --git a/datadoc/wsgi.py b/datadoc/wsgi.py index 603807f7..d687c9e7 100644 --- a/datadoc/wsgi.py +++ b/datadoc/wsgi.py @@ -1,3 +1,5 @@ +"""Entrypoint for Gunicorn.""" + from .app import get_app datadoc_app = get_app() diff --git a/poetry.lock b/poetry.lock index b8cebbf6..3899ce13 100644 --- a/poetry.lock +++ b/poetry.lock @@ -190,22 +190,23 @@ files = [ [[package]] name = "argon2-cffi" -version = "21.3.0" -description = "The secure Argon2 password hashing algorithm." +version = "23.1.0" +description = "Argon2 for Python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "argon2-cffi-21.3.0.tar.gz", hash = "sha256:d384164d944190a7dd7ef22c6aa3ff197da12962bd04b17f64d4e93d934dba5b"}, - {file = "argon2_cffi-21.3.0-py3-none-any.whl", hash = "sha256:8c976986f2c5c0e5000919e6de187906cfd81fb1c72bf9d88c01177e77da7f80"}, + {file = "argon2_cffi-23.1.0-py3-none-any.whl", hash = "sha256:c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea"}, + {file = "argon2_cffi-23.1.0.tar.gz", hash = "sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08"}, ] [package.dependencies] argon2-cffi-bindings = "*" [package.extras] -dev = ["cogapp", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "pre-commit", "pytest", "sphinx", "sphinx-notfound-page", "tomli"] -docs = ["furo", "sphinx", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pytest"] +dev = ["argon2-cffi[tests,typing]", "tox (>4)"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-copybutton", "sphinx-notfound-page"] +tests = ["hypothesis", "pytest"] +typing = ["mypy"] [[package]] name = "argon2-cffi-bindings" @@ -329,21 +330,6 @@ docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib- tests = ["attrs[tests-no-zope]", "zope-interface"] tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -[[package]] -name = "autoflake" -version = "1.7.8" -description = "Removes unused imports and unused variables" -optional = false -python-versions = ">=3.7" -files = [ - {file = "autoflake-1.7.8-py3-none-any.whl", hash = "sha256:46373ef69b6714f5064c923bb28bd797c4f8a9497f557d87fc36665c6d956b39"}, - {file = "autoflake-1.7.8.tar.gz", hash = "sha256:e7e46372dee46fa1c97acf310d99d922b63d369718a270809d7c278d34a194cf"}, -] - -[package.dependencies] -pyflakes = ">=1.1.0,<3" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} - [[package]] name = "babel" version = "2.12.1" @@ -992,17 +978,6 @@ files = [ {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, ] -[[package]] -name = "eradicate" -version = "2.3.0" -description = "Removes commented-out code." -optional = false -python-versions = "*" -files = [ - {file = "eradicate-2.3.0-py3-none-any.whl", hash = "sha256:2b29b3dd27171f209e4ddd8204b70c02f0682ae95eecb353f10e8d72b149c63e"}, - {file = "eradicate-2.3.0.tar.gz", hash = "sha256:06df115be3b87d0fc1c483db22a2ebb12bcf40585722810d809cc770f5031c37"}, -] - [[package]] name = "exceptiongroup" version = "1.1.3" @@ -1060,116 +1035,6 @@ files = [ docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] -[[package]] -name = "flake8" -version = "5.0.4" -description = "the modular source code checker: pep8 pyflakes and co" -optional = false -python-versions = ">=3.6.1" -files = [ - {file = "flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248"}, - {file = "flake8-5.0.4.tar.gz", hash = "sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db"}, -] - -[package.dependencies] -mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.9.0,<2.10.0" -pyflakes = ">=2.5.0,<2.6.0" - -[[package]] -name = "flake8-bugbear" -version = "23.3.12" -description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." -optional = false -python-versions = ">=3.7" -files = [ - {file = "flake8-bugbear-23.3.12.tar.gz", hash = "sha256:e3e7f74c8a49ad3794a7183353026dabd68c74030d5f46571f84c1fb0eb79363"}, - {file = "flake8_bugbear-23.3.12-py3-none-any.whl", hash = "sha256:beb5c7efcd7ccc2039ef66a77bb8db925e7be3531ff1cb4d0b7030d0e2113d72"}, -] - -[package.dependencies] -attrs = ">=19.2.0" -flake8 = ">=3.0.0" - -[package.extras] -dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit", "pytest", "tox"] - -[[package]] -name = "flake8-builtins" -version = "2.1.0" -description = "Check for python builtins being used as variables or parameters." -optional = false -python-versions = ">=3.7" -files = [ - {file = "flake8-builtins-2.1.0.tar.gz", hash = "sha256:12ff1ee96dd4e1f3141141ee6c45a5c7d3b3c440d0949e9b8d345c42b39c51d4"}, - {file = "flake8_builtins-2.1.0-py3-none-any.whl", hash = "sha256:469e8f03d6d0edf4b1e62b6d5a97dce4598592c8a13ec8f0952e7a185eba50a1"}, -] - -[package.dependencies] -flake8 = "*" - -[package.extras] -test = ["pytest"] - -[[package]] -name = "flake8-comprehensions" -version = "3.14.0" -description = "A flake8 plugin to help you write better list/set/dict comprehensions." -optional = false -python-versions = ">=3.8" -files = [ - {file = "flake8_comprehensions-3.14.0-py3-none-any.whl", hash = "sha256:7b9d07d94aa88e62099a6d1931ddf16c344d4157deedf90fe0d8ee2846f30e97"}, - {file = "flake8_comprehensions-3.14.0.tar.gz", hash = "sha256:81768c61bfc064e1a06222df08a2580d97de10cb388694becaf987c331c6c0cf"}, -] - -[package.dependencies] -flake8 = ">=3.0,<3.2.0 || >3.2.0" - -[[package]] -name = "flake8-debugger" -version = "4.1.2" -description = "ipdb/pdb statement checker plugin for flake8" -optional = false -python-versions = ">=3.7" -files = [ - {file = "flake8-debugger-4.1.2.tar.gz", hash = "sha256:52b002560941e36d9bf806fca2523dc7fb8560a295d5f1a6e15ac2ded7a73840"}, - {file = "flake8_debugger-4.1.2-py3-none-any.whl", hash = "sha256:0a5e55aeddcc81da631ad9c8c366e7318998f83ff00985a49e6b3ecf61e571bf"}, -] - -[package.dependencies] -flake8 = ">=3.0" -pycodestyle = "*" - -[[package]] -name = "flake8-eradicate" -version = "1.5.0" -description = "Flake8 plugin to find commented out code" -optional = false -python-versions = ">=3.8,<4.0" -files = [ - {file = "flake8_eradicate-1.5.0-py3-none-any.whl", hash = "sha256:18acc922ad7de623f5247c7d5595da068525ec5437dd53b22ec2259b96ce9d22"}, - {file = "flake8_eradicate-1.5.0.tar.gz", hash = "sha256:aee636cb9ecb5594a7cd92d67ad73eb69909e5cc7bd81710cf9d00970f3983a6"}, -] - -[package.dependencies] -attrs = "*" -eradicate = ">=2.0,<3.0" -flake8 = ">5" - -[[package]] -name = "flake8-logging-format" -version = "0.9.0" -description = "" -optional = false -python-versions = "*" -files = [ - {file = "flake8-logging-format-0.9.0.tar.gz", hash = "sha256:e830cc49091e4b8ab9ea3da69a3da074bd631ce9a7db300e5c89fb48ba4a6986"}, -] - -[package.extras] -lint = ["flake8"] -test = ["PyHamcrest", "pytest", "pytest-cov"] - [[package]] name = "flask" version = "2.2.5" @@ -1929,23 +1794,6 @@ files = [ [package.dependencies] arrow = ">=0.15.0" -[[package]] -name = "isort" -version = "5.12.0" -description = "A Python utility / library to sort Python imports." -optional = false -python-versions = ">=3.8.0" -files = [ - {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, - {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, -] - -[package.extras] -colors = ["colorama (>=0.4.3)"] -pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] -plugins = ["setuptools"] -requirements-deprecated-finder = ["pip-api", "pipreqs"] - [[package]] name = "itsdangerous" version = "2.1.2" @@ -2168,13 +2016,13 @@ jupyter-server = ">=1.1.2" [[package]] name = "jupyter-server" -version = "2.7.0" +version = "2.7.1" description = "The backend—i.e. core services, APIs, and REST endpoints—to Jupyter web applications." optional = false python-versions = ">=3.8" files = [ - {file = "jupyter_server-2.7.0-py3-none-any.whl", hash = "sha256:6a77912aff643e53fa14bdb2634884b52b784a4be77ce8e93f7283faed0f0849"}, - {file = "jupyter_server-2.7.0.tar.gz", hash = "sha256:36da0a266d31a41ac335a366c88933c17dfa5bb817a48f5c02c16d303bc9477f"}, + {file = "jupyter_server-2.7.1-py3-none-any.whl", hash = "sha256:59838bf20759354e2b4222fed702948a934cb21a503e21cd5b03706a456391d9"}, + {file = "jupyter_server-2.7.1.tar.gz", hash = "sha256:76b4ae0b568c331acc9aa904fc4a1194ef9bdefa69f232deb3f3cae802528c05"}, ] [package.dependencies] @@ -2192,7 +2040,7 @@ packaging = "*" prometheus-client = "*" pywinpty = {version = "*", markers = "os_name == \"nt\""} pyzmq = ">=24" -send2trash = "*" +send2trash = ">=1.8.2" terminado = ">=0.8.3" tornado = ">=6.2.0" traitlets = ">=5.6.0" @@ -2462,30 +2310,6 @@ babel = ["Babel"] lingua = ["lingua"] testing = ["pytest"] -[[package]] -name = "markdown-it-py" -version = "3.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = false -python-versions = ">=3.8" -files = [ - {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, - {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] -profiling = ["gprof2dot"] -rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - [[package]] name = "markupsafe" version = "2.1.3" @@ -2559,28 +2383,6 @@ files = [ [package.dependencies] traitlets = "*" -[[package]] -name = "mccabe" -version = "0.7.0" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = ">=3.6" -files = [ - {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, - {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = false -python-versions = ">=3.7" -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - [[package]] name = "mistune" version = "3.0.1" @@ -3060,20 +2862,6 @@ files = [ {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, ] -[[package]] -name = "pep8-naming" -version = "0.13.3" -description = "Check PEP-8 naming conventions, plugin for flake8" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pep8-naming-0.13.3.tar.gz", hash = "sha256:1705f046dfcd851378aac3be1cd1551c7c1e5ff363bacad707d43007877fa971"}, - {file = "pep8_naming-0.13.3-py3-none-any.whl", hash = "sha256:1a86b8c71a03337c97181917e2b472f0f5e4ccb06844a0d6f0a33522549e7a80"}, -] - -[package.dependencies] -flake8 = ">=5.0.0" - [[package]] name = "pexpect" version = "4.8.0" @@ -3360,17 +3148,6 @@ files = [ [package.dependencies] pyasn1 = ">=0.4.6,<0.6.0" -[[package]] -name = "pycodestyle" -version = "2.9.1" -description = "Python style guide checker" -optional = false -python-versions = ">=3.6" -files = [ - {file = "pycodestyle-2.9.1-py2.py3-none-any.whl", hash = "sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b"}, - {file = "pycodestyle-2.9.1.tar.gz", hash = "sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785"}, -] - [[package]] name = "pycparser" version = "2.21" @@ -3434,17 +3211,6 @@ typing-extensions = ">=4.2.0" dotenv = ["python-dotenv (>=0.10.4)"] email = ["email-validator (>=1.0.3)"] -[[package]] -name = "pyflakes" -version = "2.5.0" -description = "passive checker of Python programs" -optional = false -python-versions = ">=3.6" -files = [ - {file = "pyflakes-2.5.0-py2.py3-none-any.whl", hash = "sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2"}, - {file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"}, -] - [[package]] name = "pygments" version = "2.16.1" @@ -3494,21 +3260,6 @@ cryptography = ">=38.0.0,<40.0.0 || >40.0.0,<40.0.1 || >40.0.1,<42" docs = ["sphinx (!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"] test = ["flaky", "pretend", "pytest (>=3.0.1)"] -[[package]] -name = "pyproject-flake8" -version = "5.0.4" -description = "pyproject-flake8 (`pflake8`), a monkey patching wrapper to connect flake8 with pyproject.toml configuration" -optional = false -python-versions = "*" -files = [ - {file = "pyproject-flake8-5.0.4.tar.gz", hash = "sha256:cb69b064b39bb588b5c45a7b16c158f013dddb2ce41cf1896f4b8bc7839bd2ce"}, - {file = "pyproject_flake8-5.0.4-py2.py3-none-any.whl", hash = "sha256:b41bf2a8f8590220ae067ba4f1c4ab43b4efc65bb76fac18aa982dadea223c08"}, -] - -[package.dependencies] -flake8 = "5.0.4" -tomli = {version = "*", markers = "python_version < \"3.11\""} - [[package]] name = "pytest" version = "7.4.0" @@ -3619,20 +3370,6 @@ files = [ {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, ] -[[package]] -name = "pyupgrade" -version = "3.10.1" -description = "A tool to automatically upgrade syntax for newer versions." -optional = false -python-versions = ">=3.8.1" -files = [ - {file = "pyupgrade-3.10.1-py2.py3-none-any.whl", hash = "sha256:f565b4d26daa46ed522e98746834e77e444269103f8bc04413d77dad95169a24"}, - {file = "pyupgrade-3.10.1.tar.gz", hash = "sha256:1d8d138c2ccdd3c42b1419230ae036d5607dc69465a26feacc069642fc8d1b90"}, -] - -[package.dependencies] -tokenize-rt = ">=5.2.0" - [[package]] name = "pywin32" version = "306" @@ -3917,24 +3654,6 @@ files = [ {file = "rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055"}, ] -[[package]] -name = "rich" -version = "13.5.2" -description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "rich-13.5.2-py3-none-any.whl", hash = "sha256:146a90b3b6b47cac4a73c12866a499e9817426423f57c5a66949c086191a8808"}, - {file = "rich-13.5.2.tar.gz", hash = "sha256:fb9d6c0a0f643c99eed3875b5377a184132ba9be4d61516a55273d3554d75a39"}, -] - -[package.dependencies] -markdown-it-py = ">=2.2.0" -pygments = ">=2.13.0,<3.0.0" - -[package.extras] -jupyter = ["ipywidgets (>=7.5.1,<9)"] - [[package]] name = "rpds-py" version = "0.9.2" @@ -4118,6 +3837,32 @@ files = [ {file = "ruamel.yaml.clib-0.2.7.tar.gz", hash = "sha256:1f08fd5a2bea9c4180db71678e850b995d2a5f4537be0e94557668cf0f5f9497"}, ] +[[package]] +name = "ruff" +version = "0.0.284" +description = "An extremely fast Python linter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.0.284-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:8b949084941232e2c27f8d12c78c5a6a010927d712ecff17231ee1a8371c205b"}, + {file = "ruff-0.0.284-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:a3930d66b35e4dc96197422381dff2a4e965e9278b5533e71ae8474ef202fab0"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d1f7096038961d8bc3b956ee69d73826843eb5b39a5fa4ee717ed473ed69c95"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcaf85907fc905d838f46490ee15f04031927bbea44c478394b0bfdeadc27362"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3660b85a9d84162a055f1add334623ae2d8022a84dcd605d61c30a57b436c32"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0a3218458b140ea794da72b20ea09cbe13c4c1cdb7ac35e797370354628f4c05"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2fe880cff13fffd735387efbcad54ba0ff1272bceea07f86852a33ca71276f4"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1d098ea74d0ce31478765d1f8b4fbdbba2efc532397b5c5e8e5ea0c13d7e5ae"}, + {file = "ruff-0.0.284-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4c79ae3308e308b94635cd57a369d1e6f146d85019da2fbc63f55da183ee29b"}, + {file = "ruff-0.0.284-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f86b2b1e7033c00de45cc176cf26778650fb8804073a0495aca2f674797becbb"}, + {file = "ruff-0.0.284-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e37e086f4d623c05cd45a6fe5006e77a2b37d57773aad96b7802a6b8ecf9c910"}, + {file = "ruff-0.0.284-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d29dfbe314e1131aa53df213fdfea7ee874dd96ea0dd1471093d93b59498384d"}, + {file = "ruff-0.0.284-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:88295fd649d0aa1f1271441df75bf06266a199497afd239fd392abcfd75acd7e"}, + {file = "ruff-0.0.284-py3-none-win32.whl", hash = "sha256:735cd62fccc577032a367c31f6a9de7c1eb4c01fa9a2e60775067f44f3fc3091"}, + {file = "ruff-0.0.284-py3-none-win_amd64.whl", hash = "sha256:f67ed868d79fbcc61ad0fa034fe6eed2e8d438d32abce9c04b7c4c1464b2cf8e"}, + {file = "ruff-0.0.284-py3-none-win_arm64.whl", hash = "sha256:1292cfc764eeec3cde35b3a31eae3f661d86418b5e220f5d5dba1c27a6eccbb6"}, + {file = "ruff-0.0.284.tar.gz", hash = "sha256:ebd3cc55cd499d326aac17a331deaea29bea206e01c08862f9b5c6e93d77a491"}, +] + [[package]] name = "semver" version = "3.0.1" @@ -4368,28 +4113,6 @@ webencodings = ">=0.4" doc = ["sphinx", "sphinx_rtd_theme"] test = ["flake8", "isort", "pytest"] -[[package]] -name = "tokenize-rt" -version = "5.2.0" -description = "A wrapper around the stdlib `tokenize` which roundtrips." -optional = false -python-versions = ">=3.8" -files = [ - {file = "tokenize_rt-5.2.0-py2.py3-none-any.whl", hash = "sha256:b79d41a65cfec71285433511b50271b05da3584a1da144a0752e9c621a285289"}, - {file = "tokenize_rt-5.2.0.tar.gz", hash = "sha256:9fe80f8a5c1edad2d3ede0f37481cc0cc1538a2f442c9c2f9e4feacd2792d054"}, -] - -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] - [[package]] name = "tomli" version = "2.0.1" @@ -4436,23 +4159,6 @@ files = [ docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] test = ["argcomplete (>=2.0)", "pre-commit", "pytest", "pytest-mock"] -[[package]] -name = "tryceratops" -version = "2.3.2" -description = "Prevent Exception Handling AntiPatterns" -optional = false -python-versions = ">=3.8.1,<4.0" -files = [ - {file = "tryceratops-2.3.2-py3-none-any.whl", hash = "sha256:032fa3cf3659c9865a07b59057edf9efe9e38631e6b977fdae04064888cb62ba"}, - {file = "tryceratops-2.3.2.tar.gz", hash = "sha256:e9d77811d8f7d886c4ceaeadccd2675c6f2d794344775463faf1cb969e49d865"}, -] - -[package.dependencies] -click = ">=7" -rich = ">=10.14.0" -toml = ">=0.10.2" -typing-extensions = {version = ">=4.5.0", markers = "python_version < \"3.11\""} - [[package]] name = "typing-extensions" version = "4.7.1" @@ -4685,4 +4391,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = ">=3.10,<4.0" -content-hash = "594797b62cba585765bdd4b9626dd705ed806658f8ddf1c2b476b9dc75c96942" +content-hash = "107ebde311bed8a7c57559ae27fa5796faaa07902d550140f7b39e3432e3f240" diff --git a/pyproject.toml b/pyproject.toml index d19cfbc3..4de31d62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,23 +42,12 @@ pytest = ">=7.1.2" pytest-cov = ">=3.0.0" nbstripout = ">=0.5.0" bump2version = "*" -flake8-bugbear = "*" -flake8-builtins = "*" -flake8-comprehensions = "*" -flake8-debugger = "*" -flake8-eradicate = "*" -flake8-logging-format = "*" -isort = "*" python-kacl = "*" -pyupgrade = "*" -tryceratops = "*" -pep8-naming = "*" pre-commit = "*" -autoflake = "*" pytest-mock = "*" ipython = "*" poethepoet = "*" -pyproject-flake8 = "5.0.4" +ruff = "^0.0.284" [build-system] requires = ["poetry-core>=1.0.0"] @@ -73,12 +62,8 @@ jupyter = "jupyter lab" install-kernel = "python -m ipykernel install --user --name datadoc" datadoc = "python datadoc/app.py" -[tool.isort] -profile = "black" -src_paths = ["datadoc"] - [tool.black] -target-version = ["py37", "py38", "py39"] +target-version = ["py310", "py311"] include = '\.pyi?$' [tool.coverage.run] @@ -88,47 +73,7 @@ omit = [ "datadoc/frontend/callbacks/register.py", ] -[tool.flake8] -ignore = [ - # Line break occurred before a binary operator (W503) - # https://github.com/psf/black/issues/52 - "W503", - # Line too long (E501) - # 1. black does not format comments - # https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html#comments - # 2. long links in doc strings are an issue - "E501", - # flake8-builtins - # the likelihood of running into an issue when shadowing a buildin - # with a class attribute is very low - "A003", - # flake8-bugbear - # fastapi recommends to use `Depend()` as an argument default. - # Unfortuantely, exceptions are hardcoded in bugbear. - # https://github.com/PyCQA/flake8-bugbear/issues/62 - "B008", - # I like using f-strings for logging - "G004", -] -# pep8-naming -classmethod-decorators = [ - "classmethod", # built-in - "validator", # pydantic - "root_validator", # pydantic -] - -enable-extensions = "G" # flake8-logging-format - -per-file-ignores = [ - # star imports in `__init__.py` files are ok - "*/__init__.py:F401", - # this complexity simplifies other areas - "datadoc/frontend/callbacks/register_callbacks.py:C901", -] - -# Enables maccabe complexity checks -max-complexity = 10 - +[tool.ruff] exclude = [ ".git", "__pycache__", @@ -140,3 +85,25 @@ exclude = [ ".tox", ".ipynb_checkpoints", ] +ignore = ["A003", "B008", "E501"] +select = ["ALL"] + +[tool.ruff.mccabe] +max-complexity = 10 + +[tool.ruff.pep8-naming] +classmethod-decorators = ["classmethod", "validator", "root_validator"] + +[tool.ruff.per-file-ignores] +"*/__init__.py" = ["F401"] +"datadoc/frontend/callbacks/register_callbacks.py" = ["C901"] +"datadoc/tests/*" = [ + # asserts are encouraged in pytest + "S101", + # return annotations don't add value for test functions + "ANN201", + # docstrings are overkill for test functions + "D103", +] +# This filename is a convention for Gunicorn +"datadoc/gunicorn.conf.py" = ["N999"]