07.01 — Arborescence

07.01 — Arborescence#

src/
├── main.py                                                     # entry point (cf. README)
│
├── api/                                                        # clients HTTP externes
│   ├── retrieve_dashboard_otp_code.py                          # filter + sort + pick OTP
│   ├── get_otp_history.py                                      # HTTP GET /api/otp-history
│   └── constants/endpoints.py                                  # URLs des endpoints tests-workers
│
├── caches/                                                     # cache L1 in-memory
│   ├── l1.py                                                   # dogpile.cache.memory TTL 30m
│   └── reserve_free_cache_key.py                               # UUID + threading.Lock
│
├── constants/                                                  # constantes
│   ├── pages/
│   │   ├── homepage.py                                         # HOMEPAGE_URL
│   │   ├── dashboard.py                                        # DASHBOARD_URL + ids des inputs
│   │   ├── random_loaders.py
│   │   ├── sacred_upload.py
│   │   ├── corsicamon.py
│   │   ├── chaotic_form.py
│   │   ├── madness.py
│   │   ├── random_error_page.py
│   │   └── donkey_sausage_eater_detector.py
│   └── sys/
│       ├── redis_keys.py                                       # OTP_SEND_LOCK_KEY, ...
│       └── transient_errors.py                                 # (WebDriverException, HttpError...)
│
├── lib/
│   ├── connectors/test_steps/actions/                          # fonctions (TPOM) -> TPOM
│   │   ├── homepage.py
│   │   ├── dashboard_login.py                                  # ~10 connectors (with/without retries)
│   │   ├── dashboard_welcome.py
│   │   ├── dashboard_protected_page.py
│   │   ├── sacred_upload.py
│   │   ├── corsicamon_enter_api_key.py
│   │   ├── corsicamon_main.py
│   │   ├── chaotic_form.py
│   │   ├── random_error.py
│   │   ├── random_loaders.py
│   │   ├── madness.py
│   │   ├── this_is_bastia.py
│   │   ├── cors_errors.py
│   │   ├── bsod.py
│   │   ├── dsed.py
│   │   └── ids_bypassed.py
│   ├── custom_errors/
│   │   ├── http.py                                             # HttpErrorPageReachedError
│   │   └── transient_error.py                                  # TransientError
│   └── ext/                                                    # extensions / adapters
│       ├── ocarina/
│       │   ├── adapters/
│       │   │   ├── agnostic/
│       │   │   │   ├── act.py                                  # ⭐ adapter act (hook on_failure)
│       │   │   │   ├── env_getters.py                          # ⭐ adapter EnvGetters typé
│       │   │   │   └── match_page.py                           # ⭐ adapter match_page
│       │   │   └── selenium/
│       │   │       ├── test_suite.py                           # ⭐ adapter TestSuite
│       │   │       ├── test_campaign.py                        # ⭐ adapter TestCampaign
│       │   │       ├── cli_getters.py                          # getters typés depuis le CliStore
│       │   │       ├── logs.py                                 # create_just_log_error, etc.
│       │   │       └── screenshotter.py                        # take_screenshot helper
│       │   └── regex/error_page.py                             # ERROR_PAGE_REGEX
│       ├── redis/
│       │   └── client.py                                       # Singleton client Redis
│       └── selenium/
│           ├── humanize/                                       # ⭐ HumanizedDriver + keyboard
│           │   ├── proxy.py
│           │   └── keyboard.py
│           ├── pages/verify_elements_presence.py
│           └── watchers/catch_me_if_you_can_watcher.py         # ⭐ Watcher callback
│
├── pages/                                                      # POMs (Selenium + SeleniumTitleMixin)
│   ├── homepage.py
│   ├── random_loaders.py
│   ├── chaotic_form.py
│   ├── random_error.py
│   ├── sacred_upload/
│   │   ├── sacred_upload.py
│   │   └── fixtures/                                           # fichiers à uploader
│   ├── dashboard/
│   │   ├── login.py                                            # ⭐ avec OTP, retries, redis locks
│   │   ├── welcome_page.py
│   │   └── protected_page.py
│   ├── corsicamon/
│   │   ├── enter_api_key.py
│   │   └── main.py
│   ├── madness/
│   │   ├── base.py
│   │   ├── matchers.py                                         # has_cors, has_this_is_bastia
│   │   ├── cors.py
│   │   └── this_is_bastia.py
│   └── donkey_sausage_detector/
│       └── ids_bypassed.py
│
└── tests/
    ├── cycles/e2e.py                                           # ⭐ TestCycle (smoke + main)
    ├── campaigns/
    │   ├── global_smoke_tests.py                               # smoke
    │   ├── corsicamon.py                                       # main (+ smoke variant)
    │   ├── dashboard_login.py                                  # main
    │   ├── randomness.py                                       # main
    │   └── sacred_upload.py                                    # main
    ├── suites/
    │   ├── global_smoke_tests.py
    │   ├── randomness.py
    │   ├── dashboard/
    │   │   ├── access/
    │   │   │   ├── happy_paths.py
    │   │   │   └── unhappy_paths.py
    │   │   └── data_driven/multi_login.py
    │   ├── sacred_upload/{happy_paths,unhappy_paths}.py
    │   └── corsicamon/{smoke_tests,happy_paths,unhappy_paths}.py
    └── scenarios/
        ├── homepage/verify_homepage.py
        ├── dashboard/
        │   ├── access/{happy_paths,unhappy_paths}.py
        │   ├── back_to_igoristan.py
        │   └── data_driven/{multi_login,datasets/multi_login}.py
        ├── sacred_upload/{upload_files,just_go_back_to_igoristan}.py
        ├── corsicamon/{enter_api_key,new_draw,add_corsicamon,back_to_igoristan}.py
        └── randomness/
            ├── level_1/{random_error_page,random_loaders_page}.py
            ├── level_2/{dsed,madness}.py
            ├── level_3/chaotic_form.py
            └── level_4/walkthrough.py

Sémantique des dossiers#

DossierRôleConvention
pages/POMs (héritent SeleniumTitleMixin, POMBase)Une classe par page, fluent (return self)
lib/connectors/test_steps/actions/Connectors (TPOM) -> TPOMFonctions pures ; si paramètres, closures
lib/custom_errors/Exceptions projetSous-classes d’Exception
lib/ext/ocarina/adapters/Adapters au-dessus d’OcarinaCf. 02-adapters.md
lib/ext/redis/Wrapper Singleton RedisPour les locks distribués
lib/ext/selenium/Extensions Selenium spécifiques au projetHumanizedDriver, Watcher callback
caches/Cache L1 in-memorydogpile.cache.memory + UUID reservation
constants/ConstantesURLs, redis keys, transient errors
api/Clients HTTP externesOTP retrieval, OTP history
tests/Hiérarchie ISTQBcycles / campaigns / suites / scenarios

lib/ vs pages/#

  1. pages/ : POMs, encapsulent l’état d’une page (locators, méthodes d’action).
  2. lib/connectors/test_steps/actions/ : connectors, fonctions qui réfèrent aux POMs.
act(on_homepage, open_homepage)
#                ^^^^^^^^^^^^^
#                connector défini dans lib/connectors/test_steps/actions/homepage.py
def open_homepage(p: Homepage) -> Homepage:
    return p.open()

Le CLAUDE.md du projet IA en fait une discipline :

07.02 — Les 5 adapters

07.02 — Les 5 adapters#

Tout projet Ocarina commence par écrire cinq adapters au-dessus du framework.
Le projet ocarina-example les fournit en référence.

Listing#

AdapterStatutFichierBut
actrequislib/ext/ocarina/adapters/agnostic/act.pyHook on_failure spécifique au projet
match_pagefacultatiflib/ext/ocarina/adapters/agnostic/match_page.pyPolitique d’exceptions partagée
EnvGettersfacultatiflib/ext/ocarina/adapters/agnostic/env_getters.pyAccès typé aux env vars
TestSuiterequislib/ext/ocarina/adapters/selenium/test_suite.pyFaçade : fige transient_errors, max_retries, autoscreen
TestCampaignrequislib/ext/ocarina/adapters/selenium/test_campaign.pyFaçade : max_workers depuis CLI

act#

# lib/ext/ocarina/adapters/agnostic/act.py
from contextlib import suppress
from ocarina.dsl.testing_with_railway.constructors.create_act import create_act
from ocarina.railway.result import Fail
from lib.custom_errors.http import HttpErrorPageReachedError
from lib.ext.ocarina.regex.error_page import ERROR_PAGE_REGEX


def act(pom: TPOM, action: Callable[[TPOM], TPOM]) -> ActionStart[TPOM]:
    def failure_hook(pom: TPOM, exc: Exception) -> Fail:
        with suppress(Exception):
            title = pom.get_current_title()
            is_http_error_page = title and ERROR_PAGE_REGEX.match(title.strip())
            if is_http_error_page:
                http_error = HttpErrorPageReachedError(f"HTTP error page: {title}")
                http_error.__cause__ = exc
                return Fail(error=http_error)
        return Fail(error=exc)

    return create_act(pom, action, on_failure=failure_hook)
  1. get_current_title() : utilise la méthode obligatoire de POMBase (cf. ../02-ocarina/08-pom-base.md).
  2. ERROR_PAGE_REGEX : regex projet qui détecte un titre type page d’erreur HTTP (par exemple ^\d{3}\s.* pour 500 Internal Server Error).
  3. HttpErrorPageReachedError : exception projet ; sous-classe d’Exception, sera dans transient_errors.

C’est cet adapter qui rend le rejeu automatique pour les pages d’erreur.

07.03 — Scénarios Dashboard login

07.03 — Scénarios Dashboard login#

Trois familles : happy paths, unhappy paths, data-driven multi-login. Tous exercent le useAuth à 10% de raté et la coordination OTP.

dashboard_login#

# src/tests/campaigns/dashboard_login.py
def create_igoristan_login_campaign(*, drivers_pool: SeleniumWebDriversPool) -> TestCampaign:
    return TestCampaign(
        name="Dashboard login",
        suites=[
            create_igoristan_login_happy_paths_test_suite(drivers_pool=drivers_pool),
            create_igoristan_login_unhappy_paths_test_suite(drivers_pool=drivers_pool),
            create_igoristan_login_data_driven_test_suite(drivers_pool=drivers_pool),
        ],
    )

Happy paths#

# src/tests/suites/dashboard/access/happy_paths.py
def create_igoristan_login_happy_paths_test_suite(*, drivers_pool) -> TestSuite:
    return TestSuite(
        name="Login happy paths",
        tests=[
            test_dashboard_login_without_otp_happy_path,
            test_dashboard_login_with_otp_happy_path,
            test_dashboard_login_page_back_to_igoristan_button,
        ],
        drivers_pool=drivers_pool,
    )

Test 1 : login sans OTP#

def dashboard_login_without_otp_happy_path(driver, logger):
    dashboard_creds = create_env_getters().get_credentials("dashboard")
    on_dashboard_login_page = DashboardLoginPage(driver=driver)
    on_dashboard_welcome_page = DashboardWelcomePage(driver=driver)

    just_log_error = create_just_log_error(logger=logger)
    log_error_with_current_url = create_log_error_with_current_url(logger=logger, driver=driver)
    just_log_success = create_just_log_success(logger=logger)
    log_success_with_current_url_and_take_screenshot = create_log_success_with_current_url_and_take_screenshot(...)

    retries_amount = max(get_max_workers(), 10)

    return [
        drive_page(
            act(on_dashboard_login_page, open_dashboard_login_page)
                .failure(just_log_error("Failed to open the dashboard login page..."))
                .success(just_log_success("Opened the dashboard login page!")),
            act(on_dashboard_login_page, verify_dashboard_login_page)
                .failure(log_error_with_current_url("Failed to verify..."))
                .success(log_success_with_current_url_and_take_screenshot("Verified!")),
            act(on_dashboard_login_page,
                login_without_otp_and_with_retries(dashboard_creds, retries_amount, logger=logger))
                .failure(just_log_error("Failed to connect to the dashboard without OTP..."))
                .success(just_log_success("Connected to the dashboard!")),
        ),
        drive_page(
            act(on_dashboard_welcome_page, verify_dashboard_welcome_page)
                .failure(log_error_with_current_url("Failed to verify..."))
                .success(log_success_with_current_url_and_take_screenshot("Verified!")),
        ),
    ]
  1. retries_amount = max(get_max_workers(), 10) : on rejoue le login interne (au POM) au moins 10 fois, à cause des 10% d’échec aléatoire.
  2. login_without_otp_and_with_retries(creds, retries_amount, logger=logger) : closure qui capture creds + nb retries + logger. Le connector retourne Callable[[DashboardLoginPage], DashboardLoginPage].
  3. 2 drive_page : login + welcome page. Chacun ouvre, vérifie, screenshot.
  4. Log factories partout : aucune lambda inline.
  5. SeleniumTitleMixin côté POM : get_current_title() est utilisé par le hook on_failure de act.

Test 2 : login avec OTP#

def dashboard_login_with_otp_happy_path(driver, logger):
    # ... setup pareil ...

    cache = in_memory_cache_with_30m_ttl
    fresh_cache_key_for_username = reserve_free_cache_key(cache)
    fresh_cache_key_for_otp_send_button_click_date = reserve_free_cache_key(cache)

    return [
        drive_page(
            act(on_dashboard_login_page, open_dashboard_login_page)...,
            act(on_dashboard_login_page, verify_dashboard_login_page)...,
            act(on_dashboard_login_page,
                start_to_login_with_otp_and_with_retries(
                    dashboard_creds, retries_amount,
                    cache=cache, logger=logger,
                    username_cache_key=fresh_cache_key_for_username,
                    otp_send_button_click_date_cache_key=fresh_cache_key_for_otp_send_button_click_date,
                ))...,
            act(on_dashboard_login_page, verify_otp_screen)...,
            act(on_dashboard_login_page,
                type_otp_with_retries(
                    retries_amount,
                    cache=cache, logger=logger,
                    username_cache_key=fresh_cache_key_for_username,
                    otp_send_button_click_date_cache_key=fresh_cache_key_for_otp_send_button_click_date,
                ))...,
        ),
        drive_page(
            act(on_dashboard_welcome_page, verify_dashboard_welcome_page)...,
            act(on_dashboard_welcome_page, click_on_go_to_nested_page_btn)...,
        ),
        drive_page(
            act(on_dashboard_protected_page, verify_dashboard_protected_page)...,
        ),
    ]

Trois drive_page. Cinq act dans le premier. Cache L1 + clés réservées pour partager des valeurs entre acts (cf. 08-caches-locks.md).

07.04 — Scénarios Corsicamon

07.04 — Scénarios Corsicamon#

La page « Pokédex corse (Corsicadex) » de l’Igoristan. Saisie d’API key, tirage au sort avec 1/5 d’échec, transient_errors pour Error('lol'), et retries internes au POM face aux états « Corsicamons chargés / erreur réseau ».

Campagne#

# src/tests/campaigns/corsicamon.py
def create_igoristan_corsicamon_campaign(*, drivers_pool) -> TestCampaign:
    return TestCampaign(
        name="Corsicamon",
        suites=[
            create_corsicamon_happy_paths_test_suite(drivers_pool=drivers_pool),
            create_corsicamon_unhappy_paths_test_suite(drivers_pool=drivers_pool),
        ],
    )


def create_igoristan_corsicamon_smoke_campaign(*, drivers_pool) -> TestCampaign:
    return TestCampaign(
        name="Corsicamon - Smoke",
        suites=[create_corsicamon_smoke_tests_suite(drivers_pool=drivers_pool)],
    )

Smoke test#

# tests/scenarios/corsicamon/enter_api_key.py
def scenario_enter_api_key_smoke(driver, logger):
    page = CorsicamonEnterApiKeyPage(driver=driver)
    ...
    return [
        drive_page(
            act(page, open_corsicamon_page)...,
            act(page, verify_enter_api_key_screen)...,
        ),
    ]


test_enter_api_key_smoke = create_selenium_test(
    name="Corsicamon - Smoke - enter API key screen visible",
    test_scenario=lambda driver, logger: Scenario(
        test_chain=scenario_enter_api_key_smoke(driver, logger)
    ),
)

Vérifie juste que la page d’entrée d’API key s’affiche. Si elle ne s’affiche pas → smoke fail → main campaigns skippées.

07.05 — Scénarios randomness (4 levels)

07.05 — Scénarios randomness (4 levels)#

Quatre niveaux de chaos progressifs. On commence simple, on monte en complexité, on stresse de plus en plus Ocarina.

Campagne#

# src/tests/campaigns/randomness.py
def create_igoristan_randomness_campaign(*, drivers_pool) -> TestCampaign:
    return TestCampaign(
        name="Randomness",
        suites=[create_randomness_test_suite(drivers_pool=drivers_pool)],
    )

Suite#

# src/tests/suites/randomness.py
def create_randomness_test_suite(*, drivers_pool) -> TestSuite:
    return TestSuite(
        name="Randomness",
        tests=[
            test_random_error_page,                     # level 1
            test_random_loaders_page,                   # level 1
            test_dsed,                                  # level 2
            test_madness,                               # level 2
            test_chaotic_form,                          # level 3
            test_walkthrough,                           # level 4
        ],
        drivers_pool=drivers_pool,
    )

Levels#

LevelMécanique exercéeDifficulté
1DSL de base (drive_page, act)facile
2match_pagewhenmoyen
3HumanizedDriver + Watcherdifficile
4Walkthrough multi-pages chaotiquesdifficile

Level 1 — Random Error Page#

# tests/scenarios/randomness/level_1/random_error_page.py
def scenario_random_error_page(driver, logger):
    page = RandomErrorPage(driver=driver)
    return [
        drive_page(
            act(page, open_random_error_page)...,
            act(page, verify_random_error_page)...,
        ),
    ]


test_random_error_page = create_selenium_test(
    name="Random Error Page - smoke",
    test_scenario=lambda driver, logger: Scenario(test_chain=scenario_random_error_page(driver, logger)),
)

Test volontairement flaky, exerce le hook on_failure.

07.06 — Scénarios sacred upload

07.06 — Scénarios sacred upload#

La page /igoristan/sacred-upload permet de glisser-déposer un fichier. Exerce le pattern d’upload de fichiers côté Ocarina.

Campagne#

# src/tests/campaigns/sacred_upload.py
def create_igoristan_sacred_upload_campaign(*, drivers_pool) -> TestCampaign:
    return TestCampaign(
        name="Sacred upload",
        suites=[
            create_sacred_upload_happy_paths_test_suite(drivers_pool=drivers_pool),
            create_sacred_upload_unhappy_paths_test_suite(drivers_pool=drivers_pool),
        ],
    )

Scénario : upload files#

# tests/scenarios/sacred_upload/upload_files.py
def scenario_upload_files(driver, logger):
    page = SacredUploadPage(driver=driver)
    fixture_dir = Path(__file__).parent.parent.parent.parent / "pages" / "sacred_upload" / "fixtures"
    file_to_upload = fixture_dir / "sample.png"

    return [
        drive_page(
            act(page, open_sacred_upload_page)...,
            act(page, verify_sacred_upload_page)...,
            act(page, upload_file(file_to_upload))...,
            act(page, verify_file_uploaded(file_to_upload.name))...,
        ),
    ]


test_upload_files = create_selenium_test(
    name="Sacred upload - upload a file",
    test_scenario=lambda driver, logger: Scenario(test_chain=scenario_upload_files(driver, logger)),
)

<input type="file">#

class SacredUploadPage(SeleniumTitleMixin, POMBase):
    _file_input = (By.CSS_SELECTOR, "input[type='file']")
    _uploaded_preview = (By.CSS_SELECTOR, ".upload-preview")

    def upload_file(self, file_path: Path) -> SacredUploadPage:
        file_input = self._driver.find_element(*self._file_input)
        file_input.send_keys(str(file_path.resolve()))     # ← Selenium pattern: send_keys sur input[type=file]
        return self

    def verify_file_uploaded(self, filename: str) -> SacredUploadPage:
        WebDriverWait(self._driver, get_timeout()).until(
            ec.text_to_be_present_in_element(self._uploaded_preview, filename)
        )
        return self

Le pattern Selenium standard : send_keys(<path absolu>) sur un input[type=file] même si l’input est invisible. Selenium contourne la sécurité du navigateur pour les inputs file uniquement.

07.07 — HumanizedDriver

07.07 — HumanizedDriver#

Fichier source : src/lib/ext/selenium/humanize/proxy.py

Proxy pattern : wrappe un WebDriver Selenium et intercepte uniquement find_element* pour retourner des WebElement qui font des send_keys humanisés (frappe lente avec typos, hésitations, corrections).

Code#

class HumanizedDriver(WebDriver):
    def __init__(self, driver: WebDriver, **keyboard_config: Unpack[KeyboardConfig]) -> None:
        object.__init__(self)
        self._driver = driver
        self._config = keyboard_config

    def find_element(self, by: str | RelativeBy = "id", value: str | None = None) -> _HumanizedWebElement:
        element = self._driver.find_element(by, value)
        return _HumanizedWebElement(element, self._config)

    def find_elements(self, by: str | RelativeBy = "id", value: str | None = None) -> list[WebElement]:
        elements = self._driver.find_elements(by, value)
        return [_HumanizedWebElement(el, self._config) for el in elements]

    def __getattr__(self, name: str):
        return getattr(self._driver, name)

1. Hérite de WebDriver#

class HumanizedDriver(WebDriver):

HumanizedDriver passe le type check isinstance(x, WebDriver).

07.08 — Cache L1 + clés réservées + locks Redis distribués

07.08 — Cache L1 + clés réservées + locks Redis distribués#

Mécanique de coordination interne : un cache in-memory pour partager des valeurs au sein d’un test, un lock Redis pour sérialiser un click entre plusieurs workers.

Cache L1#

# src/caches/l1.py
from dogpile.cache import make_region

in_memory_cache_with_30m_ttl = make_region().configure(
    "dogpile.cache.memory", expiration_time=30 * 60
)

Un seul cache, global, dogpile.cache.memory backend, TTL 30 minutes.

Choix « memory » → tout vit en RAM, pas de Redis pour ce cache local.

07.09 — catch_me_if_you_can

07.09 — catch_me_if_you_can#

Détecte les éléments parasites qui pop sur la page pendant que le test tourne, et les trace.

Callback#

# src/lib/ext/selenium/watchers/catch_me_if_you_can_watcher.py
def catch_me_if_you_can_cb(watcher: SeleniumWatcher) -> None:
    elements = watcher.driver.execute_script(
        "return Array.from(document.querySelectorAll('.catch-me-if-you-can'));"
    )

    if not elements:
        return

    raw = watcher.driver.execute_script(
        """
        return arguments[0].map(el => ({
            tag:       el.tagName.toLowerCase(),
            text:      el.innerText.trim(),
            id:        el.id,
            cls:       el.className,
            name:      el.getAttribute('name') || '',
            testid:    el.getAttribute('data-testid') || '',
        }));
        """,
        elements,
    )

    for attrs in raw:
        fingerprint = ":".join(
            filter(
                None,
                [
                    attrs["tag"],
                    attrs["text"],
                    attrs["id"],
                    attrs["cls"],
                    attrs["name"],
                    attrs["testid"],
                ],
            )
        )

        if fingerprint in watcher.cache:
            continue

        watcher.cache.add(fingerprint)
        watcher.report(
            f"catch-me-if-you-can element detected: <{attrs['tag']}> {attrs['text']!r}",
            label="CATCH_ME_IF_YOU_CAN",
        )

Mécaniques#

1. Javascript pour bypass implicit wait#

elements = watcher.driver.execute_script(
    "return Array.from(document.querySelectorAll('.catch-me-if-you-can'));"
)

Pas de find_elements(By.CSS_SELECTOR, ...).

07.10 — EnvGetters

07.10 — EnvGetters#

Accesseur typé aux variables d’environnement. Évite les typos, refuse les clés inconnues à la compilation, apporte l’autocomplétion dans l’IDE.

Code#

# src/lib/ext/ocarina/adapters/agnostic/env_getters.py
from typing import Literal
from types import MappingProxyType
from ocarina.opinionated.infra.env import EnvGetters, Effects

type _CredsKeys = Literal["dashboard"]
type _ValuesKeys = Literal["igor_api_key", "redis_url"]


def _load_env() -> None:
    from dotenv import load_dotenv
    load_dotenv()


_DEFAULT_EFFECTS = (_load_env,)


class _EnvGetters(EnvGetters[_CredsKeys, _ValuesKeys]):
    def __init__(self, *, effects: Effects) -> None:
        for effect in effects:
            effect()
        super().__init__(
            credentials={
                "dashboard": MappingProxyType({
                    "login": os.environ["DASH_USERNAME"],
                    "password": os.environ["DASH_PASSWORD"],
                }),
            },
            values={
                "igor_api_key": os.environ["IGOR_API_KEY"],
                "redis_url": os.environ["REDIS_URL"],
            },
        )


def create_env_getters(*, effects: Effects | None = None) -> _EnvGetters:
    if effects is None:
        effects = _DEFAULT_EFFECTS
    return _EnvGetters(effects=effects)

_CredsKeys et _ValuesKeys#

type _CredsKeys = Literal["dashboard"]
type _ValuesKeys = Literal["igor_api_key", "redis_url"]
  1. Credentials : paires login/password (dict immutable via MappingProxyType).
  2. Values : valeurs brutes.

MappingProxyType#

"dashboard": MappingProxyType({
    "login": os.environ["DASH_USERNAME"],
    "password": os.environ["DASH_PASSWORD"],
}),

MappingProxyType est un wrapper read-only autour d’un dict :