diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index dce783f..2a736b4 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -7,11 +7,9 @@ on: pull_request: schedule: - cron: "0 8 * * *" - concurrency: group: check-${{ github.ref }} cancel-in-progress: true - jobs: test: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1646f9d..4da2892 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -2,10 +2,8 @@ name: Release to PyPI on: push: tags: ["*"] - env: dists-artifact-name: python-package-distributions - jobs: build: runs-on: ubuntu-latest @@ -26,7 +24,6 @@ jobs: with: name: ${{ env.dists-artifact-name }} path: dist/* - release: needs: - build diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eaf6302..382012e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,13 +28,18 @@ repos: - id: ruff-format - id: ruff args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"] - - repo: https://github.com/rbubley/mirrors-prettier - rev: "v3.8.1" + - repo: https://github.com/hukkin/mdformat + rev: "1.0.0" hooks: - - id: prettier + - id: mdformat additional_dependencies: - - prettier@3.8.1 - - "@prettier/plugin-xml@3.4.2" + - mdformat-config>=0.2.1 + - mdformat-gfm>=1 + - mdformat-toc>=0.5 + - repo: https://github.com/google/yamlfmt + rev: "v0.21.0" + hooks: + - id: yamlfmt - repo: meta hooks: - id: check-hooks-apply diff --git a/README.md b/README.md index 593749d..2c7d166 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,28 @@ A `pytest` plugin that sets environment variables from `pyproject.toml`, `pytest.toml`, `.pytest.toml`, or `pytest.ini` configuration files. It can also load variables from `.env` files. + + +- [Installation](#installation) +- [Quick start](#quick-start) +- [How-to guides](#how-to-guides) + - [Load variables from `.env` files](#load-variables-from-env-files) + - [Control variable behavior](#control-variable-behavior) + - [Set different environments for test suites](#set-different-environments-for-test-suites) +- [Reference](#reference) + - [TOML configuration format](#toml-configuration-format) + - [INI configuration format](#ini-configuration-format) + - [`.env` file format](#env-file-format) + - [CLI options](#cli-options) + - [`--envfile PATH`](#--envfile-path) + - [`--pytest-env-verbose`](#--pytest-env-verbose) +- [Explanation](#explanation) + - [Precedence](#precedence) + - [File discovery](#file-discovery) + - [Choosing a configuration format](#choosing-a-configuration-format) + + + ## Installation ```shell @@ -35,36 +57,16 @@ def test_database_connection(): assert os.environ["DEBUG"] == "true" ``` -## How-to guides - -### Set different environments for test suites +To see exactly what pytest-env sets, pass `--pytest-env-verbose`: -Create a subdirectory config to override parent settings: - -``` -project/ -├── pyproject.toml # [tool.pytest_env] DB_HOST = "prod-db" -└── tests_integration/ - ├── pytest.toml # [pytest_env] DB_HOST = "test-db" - └── test_api.py ``` - -Running `pytest tests_integration/` uses the subdirectory configuration. - -### Switch environments at runtime - -Use the `--envfile` CLI option to override or extend your configuration: - -```shell -# Override all configured env files with a different one. -pytest --envfile .env.local - -# Add an additional env file to those already configured. -pytest --envfile +.env.override +$ pytest --pytest-env-verbose +pytest-env: + SET DATABASE_URL=postgresql://localhost/test_db (from /project/pyproject.toml) + SET DEBUG=true (from /project/pyproject.toml) ``` -Override mode loads only the specified file. Extend mode (prefix with `+`) loads configuration files first, then the CLI -file. Variables in the CLI file take precedence. +## How-to guides ### Load variables from `.env` files @@ -83,48 +85,53 @@ SECRET_KEY='my-secret-key' DEBUG="true" ``` -Files are loaded before inline variables, so inline configuration takes precedence. - -### Expand variables using other environment variables - -Reference existing environment variables in values: +Files are loaded before inline variables, so inline configuration takes precedence. To switch `.env` files at runtime +without changing configuration, use the `--envfile` CLI option: -```toml -[tool.pytest_env] -RUN_PATH = { value = "/run/path/{USER}", transform = true } +```shell +pytest --envfile .env.local # ignore configured env_files, load only this file +pytest --envfile +.env.override # load configured env_files first, then this file on top ``` -The `{USER}` placeholder expands to the current user's name. +### Control variable behavior -### Set conditional defaults - -Only set a variable if it does not already exist: +Variables set as plain values are assigned directly. For more control, use inline tables with the `transform`, +`skip_if_set`, and `unset` keys: ```toml [tool.pytest_env] +SIMPLE = "value" +RUN_PATH = { value = "/run/path/{USER}", transform = true } HOME = { value = "~/tmp", skip_if_set = true } +TEMP_VAR = { unset = true } ``` -This leaves `HOME` unchanged if already set, otherwise sets it to `~/tmp`. +`transform` expands `{VAR}` placeholders using existing environment variables. `skip_if_set` leaves the variable +unchanged when it already exists. `unset` removes it entirely (different from setting to empty string). -### Remove variables from the environment +### Set different environments for test suites -Unset a variable completely (different from setting to empty string): +Create a subdirectory config to override parent settings: -```toml -[tool.pytest_env] -DATABASE_URL = { unset = true } +``` +project/ +├── pyproject.toml # [tool.pytest_env] DB_HOST = "prod-db" +└── tests_integration/ + ├── pytest.toml # [pytest_env] DB_HOST = "test-db" + └── test_api.py ``` +Running `pytest tests_integration/` uses the subdirectory configuration. The plugin walks up the directory tree and +stops at the first file containing a `pytest_env` section, so subdirectory configs naturally override parent configs. + ## Reference ### TOML configuration format -Define environment variables under `[tool.pytest_env]` in `pyproject.toml`, or `[pytest_env]` in `pytest.toml` or +Define environment variables under `[tool.pytest_env]` in `pyproject.toml`, or `[pytest_env]` in `pytest.toml` / `.pytest.toml`: ```toml -# pyproject.toml [tool.pytest_env] SIMPLE_VAR = "value" NUMBER_VAR = 42 @@ -133,10 +140,8 @@ CONDITIONAL = { value = "default", skip_if_set = true } REMOVED = { unset = true } ``` -Each key is the environment variable name. Values can be: - -- **Plain values**: Cast to string and set directly. -- **Inline tables**: Objects with the following keys: +Each key is the environment variable name. Values can be plain values (cast to string) or inline tables with the +following keys: | Key | Type | Description | | ------------- | ------ | ---------------------------------------------------------------------------- | @@ -171,13 +176,13 @@ env = [ Prefix flags modify behavior. Flags are case-insensitive and can be combined in any order (e.g., `R:D:KEY=VALUE`): -| Flag | Description | -| ---- | ------------------------------------------------------------------- | -| `D:` | Default — only set if the variable is not already defined. | -| `R:` | Raw — skip `{VAR}` expansion (INI expands by default, unlike TOML). | -| `U:` | Unset — remove the variable from the environment entirely. | +| Flag | Description | +| ---- | -------------------------------------------------------------------- | +| `D:` | Default -- only set if the variable is not already defined. | +| `R:` | Raw -- skip `{VAR}` expansion (INI expands by default, unlike TOML). | +| `U:` | Unset -- remove the variable from the environment entirely. | -**Note**: In INI format, variable expansion is enabled by default. In TOML format, it requires `transform = true`. +In INI format variable expansion is enabled by default. In TOML format it requires `transform = true`. ### `.env` file format @@ -195,13 +200,8 @@ env_files = .env.test ``` -Files are parsed by [python-dotenv](https://github.com/theskumar/python-dotenv) and support: - -- `KEY=VALUE` lines -- `#` comments -- `export` prefix -- Quoted values with escape sequences in double quotes -- `${VAR:-default}` expansion +Files are parsed by [python-dotenv](https://github.com/theskumar/python-dotenv) and support `KEY=VALUE` lines, `#` +comments, `export` prefix, quoted values with escape sequences in double quotes, and `${VAR:-default}` expansion. Example `.env` file: @@ -213,83 +213,71 @@ MESSAGE="hello\nworld" API_KEY=${FALLBACK_KEY:-default_key} ``` -Missing `.env` files are silently skipped. Paths are resolved relative to the project root. +Missing `.env` files from configuration are silently skipped. Paths are resolved relative to the project root. -### CLI option: `--envfile` +### CLI options -Override or extend configuration-based `env_files` at runtime: +#### `--envfile PATH` -```shell -pytest --envfile PATH # Override mode -pytest --envfile +PATH # Extend mode -``` +Override or extend configuration-based `env_files` at runtime. -**Override mode** (`--envfile PATH`): Loads only the specified file, ignoring all `env_files` from configuration. +**Override mode** (`--envfile PATH`): loads only the specified file, ignoring all `env_files` from configuration. -**Extend mode** (`--envfile +PATH`): Loads configuration files first in their normal order, then loads the CLI file. +**Extend mode** (`--envfile +PATH`): loads configuration files first in their normal order, then loads the CLI file. Variables from the CLI file override those from configuration files. Unlike configuration-based `env_files`, CLI-specified files must exist. Missing files raise `FileNotFoundError`. Paths are resolved relative to the project root. -## Explanation +#### `--pytest-env-verbose` -### Configuration precedence +Print all environment variable assignments in the test session header. Each line shows the action (`SET`, `SKIP`, or +`UNSET`), the variable name with its final value, and the source file: -When multiple configuration sources define the same variable, the following precedence rules apply (highest to lowest): +``` +pytest-env: + SET DATABASE_URL=postgres://localhost/test (from /path/to/.env) + SET DEBUG=true (from /path/to/pyproject.toml) + SKIP HOME=/Users/me (from /path/to/pyproject.toml) + UNSET TEMP_VAR (from /path/to/pyproject.toml) +``` -1. Inline variables in configuration files (TOML or INI format) -1. Variables from `.env` files loaded via `env_files` -1. Variables already present in the environment (unless `skip_if_set = false` or no `D:` flag) +Useful for debugging when multiple env files, inline configuration, and CLI options interact. -When using `--envfile`, CLI files take precedence over configuration-based `env_files`, but inline variables still win. +## Explanation -### Configuration format precedence +### Precedence -When multiple configuration formats are present: +When multiple sources define the same variable, precedence applies in this order (highest to lowest): -1. TOML native format (`[pytest_env]` or `[tool.pytest_env]`) takes precedence over INI format. -1. Among TOML files, the first file with a `pytest_env` section is used, checked in order: `pytest.toml`, - `.pytest.toml`, `pyproject.toml`. -1. If no TOML file contains `pytest_env`, the plugin falls back to INI-style `env` configuration. +1. Inline variables in configuration files (TOML or INI format). +1. Variables from `.env` files loaded via `env_files`. When using `--envfile`, CLI files take precedence over + configuration-based `env_files`. +1. Variables already present in the environment (preserved when `skip_if_set = true` or `D:` flag is used). + +When multiple configuration formats are present, TOML native format (`[pytest_env]` / `[tool.pytest_env]`) takes +precedence over INI format. Among TOML files, the first file with a `pytest_env` section wins, checked in order: +`pytest.toml`, `.pytest.toml`, `pyproject.toml`. If no TOML file contains `pytest_env`, the plugin falls back to +INI-style `env` configuration. ### File discovery The plugin walks up the directory tree starting from pytest's resolved configuration directory. For each directory, it checks `pytest.toml`, `.pytest.toml`, and `pyproject.toml` in order, stopping at the first file containing a -`pytest_env` section. - -This means subdirectory configurations take precedence over parent configurations, allowing you to have different -settings for integration tests versus unit tests. - -### When to use TOML vs INI format - -Use the **TOML native format** (`[pytest_env]`) when: - -- You need fine-grained control over expansion and conditional setting. -- Your configuration is complex with multiple inline tables. -- You prefer explicit `transform = true` for variable expansion. - -Use the **INI format** (`env` key) when: - -- You want simple `KEY=VALUE` pairs with minimal syntax. -- You prefer expansion by default (add `R:` to disable). -- You are migrating from an existing INI-based setup. - -Both formats are fully supported and can coexist (TOML takes precedence if both are present). - -### When to use `.env` files vs inline configuration - -Use **`.env` files** when: +`pytest_env` section. This means subdirectory configurations take precedence over parent configurations, allowing +different settings for integration tests versus unit tests. -- You have many environment variables that would clutter your config file. -- You want to share environment configuration with other tools (e.g., Docker, shell scripts). -- You need different `.env` files for different environments (dev, staging, prod). +### Choosing a configuration format -Use **inline configuration** when: +**TOML native format** (`[pytest_env]`) is best when you need fine-grained control over expansion and conditional +setting, or when your configuration uses multiple inline tables. Variable expansion requires explicit +`transform = true`. -- You have a small number of test-specific variables. -- You want variables to be version-controlled alongside test configuration. -- You need features like `transform`, `skip_if_set`, or `unset` that `.env` files do not support. +**INI format** (`env` key) is best for simple `KEY=VALUE` pairs with minimal syntax. Variable expansion is on by default +(use `R:` to disable). Both formats are fully supported and can coexist -- TOML takes precedence if both are present. -You can combine both approaches. Inline variables always take precedence over `.env` files. +**`.env` files** work well when you have many variables that would clutter your config file, want to share environment +configuration with other tools (Docker, shell scripts), or need different files for different environments. **Inline +configuration** is better for a small number of test-specific variables that should be version-controlled, or when you +need `transform`, `skip_if_set`, or `unset`. You can combine both -- inline variables always take precedence over `.env` +files. diff --git a/src/pytest_env/plugin.py b/src/pytest_env/plugin.py index baa67e8..1777d12 100644 --- a/src/pytest_env/plugin.py +++ b/src/pytest_env/plugin.py @@ -14,6 +14,8 @@ from collections.abc import Generator, Iterator from pathlib import Path +_env_actions_key = pytest.StashKey[list[str]]() + if sys.version_info >= (3, 11): # pragma: >=3.11 cover import tomllib else: # pragma: <3.11 cover @@ -32,6 +34,13 @@ def pytest_addoption(parser: pytest.Parser) -> None: default=None, help="path to .env file to load (prefix with + to extend config files, otherwise replaces them)", ) + parser.addoption( + "--pytest-env-verbose", + action="store_true", + dest="pytest_env_verbose", + default=False, + help="print environment variable assignments made by pytest-env", + ) @dataclass @@ -52,21 +61,68 @@ def pytest_load_initial_conftests( parser: pytest.Parser, # noqa: ARG001 ) -> None: """Load environment variables from configuration files.""" + verbose = getattr(early_config.known_args_namespace, "pytest_env_verbose", False) + actions: list[tuple[str, str, str, str]] = [] + env_files_list: list[str] = [] if toml_config := _find_toml_config(early_config): env_files_list, _ = _load_toml_config(toml_config) + _apply_env_files(early_config, env_files_list, actions if verbose else None) + _apply_entries(early_config, actions if verbose else None) + + if verbose and actions: + early_config.stash[_env_actions_key] = _format_actions(actions) + + +def _apply_env_files( + early_config: pytest.Config, + env_files_list: list[str], + actions: list[tuple[str, str, str, str]] | None, +) -> None: for env_file in _load_env_files(early_config, env_files_list): for key, value in dotenv_values(env_file).items(): if value is not None: os.environ[key] = value + if actions is not None: + actions.append(("SET", key, value, str(env_file))) + + +def _apply_entries( + early_config: pytest.Config, + actions: list[tuple[str, str, str, str]] | None, +) -> None: + source = _config_source(early_config) if actions is not None else "" for entry in _load_values(early_config): if entry.unset: os.environ.pop(entry.key, None) + if actions is not None: + actions.append(("UNSET", entry.key, "", source)) elif entry.skip_if_set and entry.key in os.environ: - continue + if actions is not None: + actions.append(("SKIP", entry.key, os.environ[entry.key], source)) else: - os.environ[entry.key] = entry.value.format(**os.environ) if entry.transform else entry.value + final = entry.value.format(**os.environ) if entry.transform else entry.value + os.environ[entry.key] = final + if actions is not None: + actions.append(("SET", entry.key, final, source)) + + +def pytest_report_header(config: pytest.Config) -> list[str] | None: + """Display environment variable assignments in test session header.""" + if _env_actions_key in config.stash: + return config.stash[_env_actions_key] + return None + + +def _format_actions(actions: list[tuple[str, str, str, str]]) -> list[str]: + lines = ["pytest-env:"] + for action, key, value, source in actions: + if action == "UNSET": + lines.append(f" {action:<5} {key} (from {source})") + else: + lines.append(f" {action:<5} {key}={value} (from {source})") + return lines def _find_toml_config(early_config: pytest.Config) -> Path | None: @@ -87,6 +143,15 @@ def _find_toml_config(early_config: pytest.Config) -> Path | None: return None +def _config_source(early_config: pytest.Config) -> str: + """Describe the configuration source for verbose output.""" + if toml_path := _find_toml_config(early_config): + return str(toml_path) + if early_config.inipath: + return str(early_config.inipath) + return "config" # pragma: no cover + + def _load_toml_config(config_path: Path) -> tuple[list[str], list[Entry]]: """Load env_files and entries from TOML config file.""" with config_path.open("rb") as file_handler: diff --git a/tests/test_verbose.py b/tests/test_verbose.py new file mode 100644 index 0000000..9ddca94 --- /dev/null +++ b/tests/test_verbose.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import os +from pathlib import Path +from textwrap import dedent +from typing import TYPE_CHECKING +from unittest import mock + +if TYPE_CHECKING: + import pytest + + +def test_verbose_shows_set_from_ini(pytester: pytest.Pytester) -> None: + (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") + (pytester.path / "pytest.ini").write_text("[pytest]\nenv = MAGIC=alpha", encoding="utf-8") + + new_env = { + "_TEST_ENV": repr({"MAGIC": "alpha"}), + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_PLUGINS": "pytest_env.plugin", + } + with mock.patch.dict(os.environ, new_env, clear=True): + result = pytester.runpytest("--pytest-env-verbose") + + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines(["*pytest-env:*", "*SET*MAGIC=alpha*"]) + + +def test_verbose_shows_set_from_toml(pytester: pytest.Pytester) -> None: + (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") + (pytester.path / "pyproject.toml").write_text( + '[tool.pytest_env]\nMY_VAR = "hello"', + encoding="utf-8", + ) + + new_env = { + "_TEST_ENV": repr({"MY_VAR": "hello"}), + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_PLUGINS": "pytest_env.plugin", + } + with mock.patch.dict(os.environ, new_env, clear=True): + result = pytester.runpytest("--pytest-env-verbose") + + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines(["*pytest-env:*", "*SET*MY_VAR=hello*(from*pyproject.toml*"]) + + +def test_verbose_shows_set_from_env_file(pytester: pytest.Pytester) -> None: + (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") + (pytester.path / ".env").write_text("FROM_FILE=value", encoding="utf-8") + (pytester.path / "pyproject.toml").write_text( + '[tool.pytest_env]\nenv_files = [".env"]', + encoding="utf-8", + ) + + new_env = { + "_TEST_ENV": repr({"FROM_FILE": "value"}), + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_PLUGINS": "pytest_env.plugin", + } + with mock.patch.dict(os.environ, new_env, clear=True): + result = pytester.runpytest("--pytest-env-verbose") + + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines(["*SET*FROM_FILE=value*(from*.env*"]) + + +def test_verbose_shows_skip(pytester: pytest.Pytester) -> None: + (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") + (pytester.path / "pytest.ini").write_text("[pytest]\nenv = D:EXISTING=new", encoding="utf-8") + + new_env = { + "EXISTING": "original", + "_TEST_ENV": repr({"EXISTING": "original"}), + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_PLUGINS": "pytest_env.plugin", + } + with mock.patch.dict(os.environ, new_env, clear=True): + result = pytester.runpytest("--pytest-env-verbose") + + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines(["*SKIP*EXISTING=original*"]) + + +def test_verbose_shows_unset(pytester: pytest.Pytester) -> None: + (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") + (pytester.path / "pytest.ini").write_text("[pytest]\nenv = U:TO_REMOVE", encoding="utf-8") + + new_env = { + "TO_REMOVE": "gone", + "_TEST_ENV": repr({"TO_REMOVE": None}), + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_PLUGINS": "pytest_env.plugin", + } + with mock.patch.dict(os.environ, new_env, clear=True): + result = pytester.runpytest("--pytest-env-verbose") + + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines(["*UNSET*TO_REMOVE*"]) + + +def test_verbose_shows_transform(pytester: pytest.Pytester) -> None: + (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") + (pytester.path / "pyproject.toml").write_text( + dedent("""\ + [tool.pytest_env] + GREETING = {value = "hello_{PLANET}", transform = true} + """), + encoding="utf-8", + ) + + new_env = { + "PLANET": "world", + "_TEST_ENV": repr({"GREETING": "hello_world"}), + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_PLUGINS": "pytest_env.plugin", + } + with mock.patch.dict(os.environ, new_env, clear=True): + result = pytester.runpytest("--pytest-env-verbose") + + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines(["*SET*GREETING=hello_world*"]) + + +def test_no_verbose_output_without_flag(pytester: pytest.Pytester) -> None: + (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") + (pytester.path / "pytest.ini").write_text("[pytest]\nenv = MAGIC=alpha", encoding="utf-8") + + new_env = { + "_TEST_ENV": repr({"MAGIC": "alpha"}), + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_PLUGINS": "pytest_env.plugin", + } + with mock.patch.dict(os.environ, new_env, clear=True): + result = pytester.runpytest() + + result.assert_outcomes(passed=1) + result.stdout.no_fnmatch_line("*pytest-env:*") + + +def test_verbose_multiple_sources(pytester: pytest.Pytester) -> None: + (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") + (pytester.path / ".env").write_text("FROM_FILE=file_val", encoding="utf-8") + (pytester.path / "pyproject.toml").write_text( + dedent("""\ + [tool.pytest_env] + env_files = [".env"] + INLINE = "inline_val" + """), + encoding="utf-8", + ) + + new_env = { + "_TEST_ENV": repr({"FROM_FILE": "file_val", "INLINE": "inline_val"}), + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_PLUGINS": "pytest_env.plugin", + } + with mock.patch.dict(os.environ, new_env, clear=True): + result = pytester.runpytest("--pytest-env-verbose") + + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines([ + "*SET*FROM_FILE=file_val*(from*.env*", + "*SET*INLINE=inline_val*(from*pyproject.toml*", + ])