diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index f567792..a5c97a4 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -12,7 +12,8 @@ build:
- pip install uv
- uv sync --extra docs
build:
- - uv run sphinx-build -b html docs $READTHEDOCS_OUTPUT/html
+ html:
+ - uv run sphinx-build -b html docs $READTHEDOCS_OUTPUT/html
sphinx:
configuration: docs/conf.py
diff --git a/README.md b/README.md
index 26fa0a9..a5bc365 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# mitreattack-python
+[](https://pypi.org/project/mitreattack-python/) [](https://www.python.org/downloads/release/python-3110/) [](https://github.com/mitre-attack/mitreattack-python/blob/main/LICENSE) [](https://mitreattack-python.readthedocs.io/) [](https://github.com/mitre-attack/mitreattack-python/actions/workflows/lint-and-test.yml) [](https://github.com/mitre-attack/mitreattack-python/actions/workflows/release-and-publish.yml)
+
This repository contains a library of Python tools and utilities for working with ATT&CK data.
For more information, see the [full documentation](https://mitreattack-python.readthedocs.io/) on ReadTheDocs.
diff --git a/mitreattack/attackToExcel/README.md b/mitreattack/attackToExcel/README.md
index f86ee61..a775cb8 100644
--- a/mitreattack/attackToExcel/README.md
+++ b/mitreattack/attackToExcel/README.md
@@ -46,6 +46,11 @@ Build Excel files for selected ATT&CK domains from a release:
attack-to-excel from-release --version v19.0 --domains mobile-attack --domains ics-attack
```
+`attack-to-excel` refuses to run when generated output directories already
+contain Excel files. Pass `--overwrite` to `from-stix` or `from-release` to
+replace existing Excel files. Pass `-v` or `--verbose` to show debug logs,
+including sheet-level write messages.
+
### Module
Example execution targeting a specific domain and version:
@@ -75,9 +80,9 @@ overview of the available methods follows.
|:------------|:----------|:------|
|get_stix_data|`domain`: the domain of ATT&CK to fetch data from
`version`: optional parameter indicating which version to fetch data from (such as "v8.1"). If omitted retrieves the most recent version of ATT&CK.
`remote`: optional parameter that provides a URL of a remote ATT&CK Workbench instance to grab data from.| Retrieves the ATT&CK STIX data for the specified version and returns it as a MemoryStore object|
|build_dataframes| `src`: MemoryStore or other stix2 DataSource object holding domain data
`domain`: domain of ATT&CK that `src` corresponds to| Builds a Pandas DataFrame collection as a dictionary, with keys for each type, based on the ATT&CK data provided|
-|write_excel| `dataframes`: pandas DataFrame dictionary (generated by build_dataframes)
`domain`: domain of ATT&CK that `dataframes` corresponds to
`version`: optional parameter indicating which version of ATT&CK is in use
`output_dir`: optional parameter specifying output directory| Writes out DataFrame based ATT&CK data to excel files|
-|export| `domain`: the domain of ATT&CK to download
`version`: optional parameter specifying which version of ATT&CK to download
`output_dir`: optional parameter specifying output directory| Downloads ATT&CK data from MITRE/CTI and exports it to Excel spreadsheets |
-|export_release| `version`: optional ATT&CK release version
`stix_version`: STIX release tree, such as "2.0" or "2.1"
`output_dir`: parent output directory
`stix_base_dir`: optional directory containing release STIX files
`domains`: optional list of domains
`versioned_output_dir`: preserve domain-version output folders| Exports a full ATT&CK release to Excel spreadsheets, downloading missing STIX files temporarily when needed |
+|write_excel| `dataframes`: pandas DataFrame dictionary (generated by build_dataframes)
`domain`: domain of ATT&CK that `dataframes` corresponds to
`version`: optional parameter indicating which version of ATT&CK is in use
`output_dir`: optional parameter specifying output directory
`overwrite`: optional parameter allowing existing Excel files to be replaced| Writes out DataFrame based ATT&CK data to excel files|
+|export| `domain`: the domain of ATT&CK to download
`version`: optional parameter specifying which version of ATT&CK to download
`output_dir`: optional parameter specifying output directory
`overwrite`: optional parameter allowing existing Excel files to be replaced| Downloads ATT&CK data from MITRE/CTI and exports it to Excel spreadsheets |
+|export_release| `version`: optional ATT&CK release version
`stix_version`: STIX release tree, such as "2.0" or "2.1"
`output_dir`: parent output directory
`stix_base_dir`: optional directory containing release STIX files
`domains`: optional list of domains
`versioned_output_dir`: preserve domain-version output folders
`overwrite`: optional parameter allowing existing Excel files to be replaced| Exports a full ATT&CK release to Excel spreadsheets, downloading missing STIX files temporarily when needed |
### stixToDf
diff --git a/mitreattack/attackToExcel/attackToExcel.py b/mitreattack/attackToExcel/attackToExcel.py
index b69c0eb..149ed77 100644
--- a/mitreattack/attackToExcel/attackToExcel.py
+++ b/mitreattack/attackToExcel/attackToExcel.py
@@ -2,11 +2,13 @@
import os
import re
+import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional
+import click
import pandas as pd
import requests
import typer
@@ -90,13 +92,72 @@ def _release_stix_file(release_dir: Path, domain: str) -> Path:
return release_dir / f"{domain}.json"
-def _move_versioned_exports_to_domain_dir(output_dir: Path, domain: str, version: str):
+def _domain_version_string(domain: str, version: Optional[str]) -> str:
+ """Return the folder and filename prefix used for one domain export."""
+ return f"{domain}-{version}" if version else domain
+
+
+def _excel_output_dir(output_dir: Path, domain: str, version: Optional[str]) -> Path:
+ """Return the directory that one domain export writes Excel files into."""
+ return output_dir / _domain_version_string(domain, version)
+
+
+def _release_staging_output_dir(output_dir: Path) -> Path:
+ """Return the parent directory for staged release Excel files."""
+ return output_dir / "tmp" / "staged-excel-files"
+
+
+def _existing_excel_files(directory: Path) -> List[Path]:
+ """Return existing Excel files directly under a generated output directory."""
+ if not directory.is_dir():
+ return []
+ return sorted(path for path in directory.glob("*.xlsx") if path.is_file())
+
+
+def _raise_if_excel_files_exist(paths: List[Path]):
+ """Refuse to continue when generated output would overwrite existing Excel files."""
+ if not paths:
+ return
+
+ files_text = "\n".join(f" - {path}" for path in paths)
+ raise FileExistsError(
+ "Refusing to overwrite existing Excel file(s). "
+ "Move or delete these files, choose a different output directory, or pass --overwrite to replace them:\n"
+ f"{files_text}"
+ )
+
+
+def _log_excel_overwrite(path: Path | str, overwrite: bool):
+ """Log when an existing Excel file is about to be replaced."""
+ excel_path = Path(path)
+ if overwrite and excel_path.is_file():
+ logger.info(f"Overwriting existing Excel file: {excel_path}")
+
+
+def _log_excel_overwrites(paths: List[Path]):
+ """Log a preflight summary of existing Excel files that will be replaced."""
+ if not paths:
+ return
+
+ logger.info("Existing Excel files will be overwritten:")
+ for path in paths:
+ logger.info(f"Overwriting existing Excel file: {path}")
+
+
+def _move_versioned_exports_to_domain_dir(
+ output_dir: Path,
+ domain: str,
+ version: Optional[str],
+ overwrite: bool = False,
+ staging_output_dir: Optional[Path] = None,
+) -> List[Path]:
"""Move versioned Excel exports into the unversioned domain folder."""
- versioned_dir = output_dir / f"{domain}-{version}"
+ versioned_dir = _excel_output_dir(staging_output_dir or output_dir, domain, version)
domain_dir = output_dir / domain
+ moved_files = []
if not versioned_dir.is_dir():
- return
+ return moved_files
domain_dir.mkdir(parents=True, exist_ok=True)
for source_path in versioned_dir.iterdir():
@@ -105,10 +166,15 @@ def _move_versioned_exports_to_domain_dir(output_dir: Path, domain: str, version
target_path = domain_dir / source_path.name
if target_path.exists():
+ if not overwrite:
+ _raise_if_excel_files_exist([target_path])
+ logger.debug(f"Replacing existing Excel file: {target_path}")
target_path.unlink()
source_path.replace(target_path)
+ moved_files.append(target_path)
versioned_dir.rmdir()
+ return moved_files
def _download_missing_release_domains(
@@ -137,6 +203,7 @@ def export_release(
stix_base_dir: Optional[str] = None,
domains: Optional[List[str]] = None,
versioned_output_dir: bool = False,
+ overwrite: bool = False,
):
"""Export one ATT&CK release to Excel for one or more domains."""
if stix_version not in VALID_STIX_VERSIONS:
@@ -160,12 +227,24 @@ def export_release(
local_stix_files = {domain: _release_stix_file(local_release_dir, domain) for domain in release_domains}
missing_domains = [domain for domain, stix_file in local_stix_files.items() if not stix_file.is_file()]
+ existing_release_excel_files = _existing_release_excel_files(
+ output_dir=release_output_dir,
+ domains=release_domains,
+ version=attack_version,
+ versioned_output_dir=versioned_output_dir,
+ )
+ if overwrite:
+ _log_excel_overwrites(existing_release_excel_files)
+ else:
+ _raise_if_excel_files_exist(existing_release_excel_files)
+
if not missing_domains:
_export_release_domains(
version=attack_version,
output_dir=release_output_dir,
stix_files=local_stix_files,
versioned_output_dir=versioned_output_dir,
+ overwrite=overwrite,
)
return
@@ -194,23 +273,67 @@ def export_release(
output_dir=release_output_dir,
stix_files=stix_files,
versioned_output_dir=versioned_output_dir,
+ overwrite=overwrite,
)
+def _existing_release_excel_files(
+ *,
+ output_dir: Path,
+ domains: List[str],
+ version: Optional[str],
+ versioned_output_dir: bool,
+) -> List[Path]:
+ """Return existing Excel files that a release export could overwrite."""
+ existing_files = []
+ for domain in domains:
+ candidate_dirs = [_excel_output_dir(output_dir, domain, version)]
+ if not versioned_output_dir:
+ candidate_dirs.append(output_dir / domain)
+
+ for candidate_dir in candidate_dirs:
+ existing_files.extend(_existing_excel_files(candidate_dir))
+
+ return sorted(set(existing_files))
+
+
def _export_release_domains(
*,
version: Optional[str],
output_dir: Path,
stix_files: Dict[str, Path],
versioned_output_dir: bool,
+ overwrite: bool,
):
"""Export resolved release STIX files to Excel."""
for domain, stix_file in stix_files.items():
logger.info(f"Exporting {domain} to Excel from {stix_file}")
- export(domain=domain, version=version, output_dir=str(output_dir), stix_file=str(stix_file))
+ domain_output_dir = output_dir if versioned_output_dir else _release_staging_output_dir(output_dir)
+ if not versioned_output_dir:
+ logger.info(
+ f"Writing staged Excel files for {domain} to {_excel_output_dir(domain_output_dir, domain, version)}"
+ )
+
+ export(
+ domain=domain,
+ version=version,
+ output_dir=str(domain_output_dir),
+ stix_file=str(stix_file),
+ overwrite=overwrite,
+ log_written_files=versioned_output_dir,
+ )
if not versioned_output_dir:
- _move_versioned_exports_to_domain_dir(output_dir=output_dir, domain=domain, version=version)
+ logger.info(f"Moving staged Excel files for {domain} to {output_dir / domain}")
+ moved_files = _move_versioned_exports_to_domain_dir(
+ output_dir=output_dir,
+ domain=domain,
+ version=version,
+ overwrite=overwrite,
+ staging_output_dir=domain_output_dir,
+ )
+ for moved_file in moved_files:
+ logger.info(f"Excel file written: {moved_file}")
def get_stix_data(
@@ -362,7 +485,13 @@ def build_ds_an_lg_relationships(dataframes: Dict) -> Dict[str, pd.DataFrame]:
def write_excel(
- dataframes: Dict, domain: str, src: MemoryStore, version: Optional[str] = None, output_dir: str = "."
+ dataframes: Dict,
+ domain: str,
+ src: MemoryStore,
+ version: Optional[str] = None,
+ output_dir: str = ".",
+ overwrite: bool = False,
+ log_written_files: bool = True,
) -> List:
"""Given a set of dataframes from build_dataframes, write the ATT&CK dataset to output directory.
@@ -381,6 +510,10 @@ def write_excel(
output_dir : str, optional
The directory to write the excel files to.
If omitted writes to a subfolder of the current directory depending on specified domain and version, by default "."
+ overwrite : bool, optional
+ Whether to replace existing Excel files in the generated output directory, by default False
+ log_written_files : bool, optional
+ Whether to log each written Excel file path, by default True
Returns
-------
@@ -391,15 +524,15 @@ def write_excel(
# master list of files that have been written
written_files = []
# set up output directory
- if version:
- domain_version_string = f"{domain}-{version}"
- else:
- domain_version_string = domain
- output_directory = os.path.join(output_dir, domain_version_string)
- if not os.path.exists(output_directory):
- os.makedirs(output_directory)
+ domain_version_string = _domain_version_string(domain, version)
+ output_directory_path = _excel_output_dir(Path(output_dir), domain, version)
+ if not overwrite:
+ _raise_if_excel_files_exist(_existing_excel_files(output_directory_path))
+ output_directory_path.mkdir(parents=True, exist_ok=True)
+ output_directory = str(output_directory_path)
# master dataset file
master_fp = os.path.join(output_directory, f"{domain_version_string}.xlsx")
+ _log_excel_overwrite(master_fp, overwrite=overwrite)
ds_an_ls_df = stixToDf.detectionStrategiesAnalyticsLogSourcesDf(src)
add_ds_an_ls_to = {"detectionstrategies", "analytics", "datacomponents"}
@@ -418,6 +551,7 @@ def write_excel(
continue
# write the dataframes for the object type into named sheets
+ _log_excel_overwrite(fp, overwrite=overwrite)
with pd.ExcelWriter(fp) as object_writer:
for sheet_name in object_data:
logger.debug(f"Writing sheet to {fp}: {sheet_name}")
@@ -441,6 +575,7 @@ def write_excel(
object_data[object_type].to_excel(master_writer, sheet_name=object_type, index=False)
else: # handle matrix special formatting
+ _log_excel_overwrite(fp, overwrite=overwrite)
with pd.ExcelWriter(fp, engine="xlsxwriter") as matrix_writer:
# Combine both matrix types
combined = object_data[0] + object_data[1]
@@ -526,8 +661,9 @@ def write_excel(
written_files.append(master_fp)
- for thefile in written_files:
- logger.info(f"Excel file created: {thefile}")
+ if log_written_files:
+ for thefile in written_files:
+ logger.info(f"Excel file written: {thefile}")
return written_files
@@ -538,6 +674,8 @@ def export(
remote: Optional[str] = None,
stix_file: Optional[str] = None,
mem_store: Optional[MemoryStore] = None,
+ overwrite: bool = False,
+ log_written_files: bool = True,
):
"""Download ATT&CK data from MITRE/CTI and convert it to Excel spreadsheets.
@@ -564,6 +702,10 @@ def export(
A STIX bundle containing ATT&CK data for a domain already loaded into memory.
Mutually exclusive with `remote` and `stix_file`.
By default None
+ overwrite : bool, optional
+ Whether to replace existing Excel files in the generated output directory, by default False
+ log_written_files : bool, optional
+ Whether to log each written Excel file path, by default True
Raises
------
@@ -582,6 +724,9 @@ def export(
get_stix_from_github = remote is None and stix_file is None and mem_store is None
+ if not overwrite:
+ _raise_if_excel_files_exist(_existing_excel_files(_excel_output_dir(Path(output_dir), domain, version)))
+
if get_stix_from_github or remote or stix_file:
mem_store = get_stix_data(domain=domain, version=version, remote=remote, stix_file=stix_file)
@@ -598,11 +743,27 @@ def export(
major_version = int(match.group(1))
if major_version < 18:
dataframes = build_dataframes_pre_v18(src=mem_store, domain=domain)
- write_excel(dataframes=dataframes, domain=domain, src=mem_store, version=version, output_dir=output_dir)
+ write_excel(
+ dataframes=dataframes,
+ domain=domain,
+ src=mem_store,
+ version=version,
+ output_dir=output_dir,
+ overwrite=overwrite,
+ log_written_files=log_written_files,
+ )
return
dataframes = build_dataframes(src=mem_store, domain=domain)
- write_excel(dataframes=dataframes, domain=domain, src=mem_store, version=version, output_dir=output_dir)
+ write_excel(
+ dataframes=dataframes,
+ domain=domain,
+ src=mem_store,
+ version=version,
+ output_dir=output_dir,
+ overwrite=overwrite,
+ log_written_files=log_written_files,
+ )
def _validate_cli_value(value: str, allowed_values: tuple[str, ...], label: str) -> str:
@@ -653,20 +814,40 @@ def from_stix_cli(
help="Path to a local STIX file containing ATT&CK data for a domain.",
),
] = None,
+ overwrite: Annotated[
+ bool,
+ typer.Option(
+ "--overwrite",
+ help="Replace existing Excel files in the generated output directory.",
+ ),
+ ] = False,
+ verbose: Annotated[
+ bool,
+ typer.Option(
+ "--verbose",
+ "-v",
+ help="Show debug log messages.",
+ ),
+ ] = False,
):
"""Convert one ATT&CK domain STIX bundle to Excel."""
+ _configure_cli_logging(verbose=verbose)
domain = _validate_cli_value(domain, ATTACK_DOMAINS, "ATT&CK domain")
if remote and stix_file:
raise typer.BadParameter("--remote and --stix-file are mutually exclusive")
- export(
- domain=domain,
- version=version,
- output_dir=output,
- remote=remote,
- stix_file=stix_file,
- )
+ try:
+ export(
+ domain=domain,
+ version=version,
+ output_dir=output,
+ remote=remote,
+ stix_file=stix_file,
+ overwrite=overwrite,
+ )
+ except FileExistsError as error:
+ raise click.ClickException(str(error)) from error
@app.command("from-release")
@@ -713,21 +894,47 @@ def from_release_cli(
help="Preserve domain-version output folders.",
),
] = False,
+ overwrite: Annotated[
+ bool,
+ typer.Option(
+ "--overwrite",
+ help="Replace existing Excel files in generated output directories.",
+ ),
+ ] = False,
+ verbose: Annotated[
+ bool,
+ typer.Option(
+ "--verbose",
+ "-v",
+ help="Show debug log messages.",
+ ),
+ ] = False,
):
"""Convert ATT&CK release domain bundles to Excel."""
+ _configure_cli_logging(verbose=verbose)
stix_version = _validate_cli_value(stix_version, VALID_STIX_VERSIONS, "STIX version")
selected_domains = [
_validate_cli_value(selected_domain, ATTACK_DOMAINS, "ATT&CK domain") for selected_domain in domains or []
]
- export_release(
- version=version,
- stix_version=stix_version,
- output_dir=output,
- stix_base_dir=stix_base_dir,
- domains=selected_domains or None,
- versioned_output_dir=versioned_output_dir,
- )
+ try:
+ export_release(
+ version=version,
+ stix_version=stix_version,
+ output_dir=output,
+ stix_base_dir=stix_base_dir,
+ domains=selected_domains or None,
+ versioned_output_dir=versioned_output_dir,
+ overwrite=overwrite,
+ )
+ except FileExistsError as error:
+ raise click.ClickException(str(error)) from error
+
+
+def _configure_cli_logging(verbose: bool = False):
+ """Configure attack-to-excel CLI output for user-facing progress logs."""
+ logger.remove()
+ logger.add(sys.stderr, level="DEBUG" if verbose else "INFO")
def main(argv=None):
diff --git a/pyproject.toml b/pyproject.toml
index fae300a..49b0031 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,6 +13,7 @@ classifiers = [
"Operating System :: OS Independent",
]
dependencies = [
+ "click>=8.1.8",
"colour>=0.1.5",
"deepdiff>=6.6.0",
"drawsvg>=2.4.0",
diff --git a/tests/test_cli.py b/tests/test_cli.py
index aefa30a..414bc77 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -277,9 +277,62 @@ def fake_export(**kwargs):
"output_dir": str(tmp_path),
"remote": None,
"stix_file": None,
+ "overwrite": False,
}
+def test_attack_to_excel_cli_from_stix_passes_overwrite(monkeypatch, tmp_path: Path, attack_to_excel_runner: CliRunner):
+ """from-stix should pass overwrite requests to export."""
+ calls = {}
+
+ def fake_export(**kwargs):
+ calls["export"] = kwargs
+
+ monkeypatch.setattr(attackToExcel, "export", fake_export)
+
+ result = attack_to_excel_runner.invoke(attackToExcel.app, ["from-stix", "--output", str(tmp_path), "--overwrite"])
+
+ assert result.exit_code == 0
+ assert calls["export"]["overwrite"] is True
+
+
+def test_attack_to_excel_cli_from_stix_configures_verbose_logging(
+ monkeypatch, tmp_path: Path, attack_to_excel_runner: CliRunner
+):
+ """from-stix should enable debug logging when verbose is requested."""
+ calls = {}
+
+ def fake_configure_cli_logging(**kwargs):
+ calls["logging"] = kwargs
+
+ def fake_export(**kwargs):
+ calls["export"] = kwargs
+
+ monkeypatch.setattr(attackToExcel, "_configure_cli_logging", fake_configure_cli_logging)
+ monkeypatch.setattr(attackToExcel, "export", fake_export)
+
+ result = attack_to_excel_runner.invoke(attackToExcel.app, ["from-stix", "--output", str(tmp_path), "--verbose"])
+
+ assert result.exit_code == 0
+ assert calls["logging"] == {"verbose": True}
+
+
+def test_attack_to_excel_cli_from_stix_reports_existing_excel_file(monkeypatch, attack_to_excel_runner: CliRunner):
+ """from-stix should report existing Excel files without a traceback."""
+
+ def fake_export(**kwargs):
+ raise FileExistsError("Refusing to overwrite existing Excel file(s). Pass --overwrite to replace them.")
+
+ monkeypatch.setattr(attackToExcel, "export", fake_export)
+
+ result = attack_to_excel_runner.invoke(attackToExcel.app, ["from-stix"])
+
+ assert result.exit_code != 0
+ output = unstyle(result.output)
+ assert "Refusing to overwrite existing Excel file" in output
+ assert "--overwrite" in output
+
+
def test_attack_to_excel_cli_from_stix_rejects_multiple_sources(attack_to_excel_runner: CliRunner):
"""from-stix should reject ambiguous STIX source options."""
result = attack_to_excel_runner.invoke(
@@ -321,9 +374,72 @@ def fake_export_release(**kwargs):
"stix_base_dir": None,
"domains": None,
"versioned_output_dir": False,
+ "overwrite": False,
}
+def test_attack_to_excel_cli_from_release_passes_overwrite(
+ monkeypatch, tmp_path: Path, attack_to_excel_runner: CliRunner
+):
+ """from-release should pass overwrite requests to release export."""
+ calls = {}
+
+ def fake_export_release(**kwargs):
+ calls["export_release"] = kwargs
+
+ monkeypatch.setattr(attackToExcel, "export_release", fake_export_release)
+
+ result = attack_to_excel_runner.invoke(
+ attackToExcel.app,
+ [
+ "from-release",
+ "--output",
+ str(tmp_path),
+ "--overwrite",
+ ],
+ )
+
+ assert result.exit_code == 0
+ assert calls["export_release"]["overwrite"] is True
+
+
+def test_attack_to_excel_cli_from_release_configures_verbose_logging(
+ monkeypatch, tmp_path: Path, attack_to_excel_runner: CliRunner
+):
+ """from-release should enable debug logging when verbose is requested."""
+ calls = {}
+
+ def fake_configure_cli_logging(**kwargs):
+ calls["logging"] = kwargs
+
+ def fake_export_release(**kwargs):
+ calls["export_release"] = kwargs
+
+ monkeypatch.setattr(attackToExcel, "_configure_cli_logging", fake_configure_cli_logging)
+ monkeypatch.setattr(attackToExcel, "export_release", fake_export_release)
+
+ result = attack_to_excel_runner.invoke(attackToExcel.app, ["from-release", "--output", str(tmp_path), "-v"])
+
+ assert result.exit_code == 0
+ assert calls["logging"] == {"verbose": True}
+
+
+def test_attack_to_excel_cli_from_release_reports_existing_excel_file(monkeypatch, attack_to_excel_runner: CliRunner):
+ """from-release should report existing Excel files without a traceback."""
+
+ def fake_export_release(**kwargs):
+ raise FileExistsError("Refusing to overwrite existing Excel file(s). Pass --overwrite to replace them.")
+
+ monkeypatch.setattr(attackToExcel, "export_release", fake_export_release)
+
+ result = attack_to_excel_runner.invoke(attackToExcel.app, ["from-release"])
+
+ assert result.exit_code != 0
+ output = unstyle(result.output)
+ assert "Refusing to overwrite existing Excel file" in output
+ assert "--overwrite" in output
+
+
def test_attack_to_excel_cli_from_release_selected_domains(
monkeypatch, tmp_path: Path, attack_to_excel_runner: CliRunner
):
@@ -400,6 +516,8 @@ def test_attack_to_excel_cli_help_lists_subcommands(attack_to_excel_runner: CliR
assert "--domain" in from_stix_output
assert "--remote" in from_stix_output
assert "--stix-file" in from_stix_output
+ assert "--overwrite" in from_stix_output
+ assert "--verbose" in from_stix_output
from_release_help = attack_to_excel_runner.invoke(attackToExcel.app, ["from-release", "--help"])
from_release_output = unstyle(from_release_help.output)
@@ -407,6 +525,8 @@ def test_attack_to_excel_cli_help_lists_subcommands(attack_to_excel_runner: CliR
assert "--domains" in from_release_output
assert "--stix-version" in from_release_output
assert "--stix-base-dir" in from_release_output
+ assert "--overwrite" in from_release_output
+ assert "--verbose" in from_release_output
def test_attack_to_excel_cli_no_args_shows_help(attack_to_excel_runner: CliRunner):
@@ -418,6 +538,38 @@ def test_attack_to_excel_cli_no_args_shows_help(attack_to_excel_runner: CliRunne
assert "from-release" in result.output
+def test_attack_to_excel_configures_info_logging_by_default(monkeypatch):
+ """The installed attack-to-excel entrypoint should hide debug logs by default."""
+ calls = {}
+
+ def fake_add(*args, **kwargs):
+ calls["add"] = {"args": args, "kwargs": kwargs}
+
+ monkeypatch.setattr(attackToExcel.logger, "remove", lambda: calls.setdefault("remove", True))
+ monkeypatch.setattr(attackToExcel.logger, "add", fake_add)
+
+ attackToExcel._configure_cli_logging(verbose=False)
+
+ assert calls["remove"] is True
+ assert calls["add"]["kwargs"]["level"] == "INFO"
+
+
+def test_attack_to_excel_configures_debug_logging_when_verbose(monkeypatch):
+ """Verbose CLI runs should include debug logs."""
+ calls = {}
+
+ def fake_add(*args, **kwargs):
+ calls["add"] = {"args": args, "kwargs": kwargs}
+
+ monkeypatch.setattr(attackToExcel.logger, "remove", lambda: calls.setdefault("remove", True))
+ monkeypatch.setattr(attackToExcel.logger, "add", fake_add)
+
+ attackToExcel._configure_cli_logging(verbose=True)
+
+ assert calls["remove"] is True
+ assert calls["add"]["kwargs"]["level"] == "DEBUG"
+
+
@pytest.mark.skip("layerGenerator_cli does not support ICS domain yet")
def test_generate_batch_group(tmp_path: Path, stix_file_ics_latest: str):
"""Test CLI group batch generation."""
diff --git a/tests/test_to_excel.py b/tests/test_to_excel.py
index 40edb1d..b470457 100644
--- a/tests/test_to_excel.py
+++ b/tests/test_to_excel.py
@@ -34,6 +34,64 @@ def _sheet_rows(path: Path, sheet_name: str):
workbook.close()
+def _write_stix_files(stix_base_dir: Path, domains):
+ stix_base_dir.mkdir(parents=True, exist_ok=True)
+ for domain in domains:
+ (stix_base_dir / f"{domain}.json").write_text("{}", encoding="utf-8")
+
+
+def _release_output_dir(output_dir: Path, version: str | None = None) -> Path:
+ return output_dir / version if version else output_dir
+
+
+def _staging_output_dir(output_dir: Path, version: str | None = None) -> Path:
+ return _release_output_dir(output_dir, version) / "tmp" / "staged-excel-files"
+
+
+def _domain_export_dir(
+ output_dir: Path, domain: str, version: str | None = None, *, versioned_output_dir: bool = False
+):
+ release_output_dir = _release_output_dir(output_dir, version)
+ domain_dir_name = f"{domain}-{version}" if version and versioned_output_dir else domain
+ return release_output_dir / domain_dir_name
+
+
+def _write_existing_release_excel(
+ output_dir: Path,
+ domain: str,
+ version: str | None = None,
+ *,
+ versioned_output_dir: bool = False,
+):
+ domain_dir = _domain_export_dir(
+ output_dir,
+ domain,
+ version,
+ versioned_output_dir=versioned_output_dir,
+ )
+ domain_dir.mkdir(parents=True, exist_ok=True)
+ workbook_name = f"{domain}-{version}.xlsx" if version else f"{domain}.xlsx"
+ existing_file = domain_dir / workbook_name
+ existing_file.write_text("existing", encoding="utf-8")
+ return existing_file
+
+
+def _make_fake_release_export(*, calls=None, log_marker=None):
+ def fake_export(**kwargs):
+ if calls is not None:
+ calls.append(kwargs)
+ if log_marker is not None:
+ log_marker()
+
+ output_dir = Path(kwargs["output_dir"])
+ domain_version_string = f"{kwargs['domain']}-{kwargs['version']}" if kwargs["version"] else kwargs["domain"]
+ versioned_dir = output_dir / domain_version_string
+ versioned_dir.mkdir(parents=True)
+ (versioned_dir / f"{domain_version_string}.xlsx").write_text("new", encoding="utf-8")
+
+ return fake_export
+
+
@dataclass
class FakeMergeRange:
"""Small stand-in for matrix merge range objects."""
@@ -84,6 +142,8 @@ def fake_write_excel(**kwargs):
"src": mem_store,
"version": None,
"output_dir": str(tmp_path),
+ "overwrite": False,
+ "log_written_files": True,
}
@@ -119,6 +179,61 @@ def fake_write_excel(**kwargs):
assert "build_dataframes" not in calls
assert calls["write_excel"]["version"] == "v17.0"
assert calls["write_excel"]["dataframes"] is dataframes
+ assert calls["write_excel"]["overwrite"] is False
+ assert calls["write_excel"]["log_written_files"] is True
+
+
+def test_export_refuses_existing_excel_file_before_building_dataframes(
+ monkeypatch,
+ tmp_path: Path,
+ attack_memstore_factory,
+ sample_technique_object,
+):
+ """Export should refuse existing Excel files before building or writing dataframes."""
+ output_directory = tmp_path / "enterprise-attack"
+ output_directory.mkdir()
+ existing_file = output_directory / "enterprise-attack-techniques.xlsx"
+ existing_file.write_text("existing", encoding="utf-8")
+ mem_store = attack_memstore_factory([sample_technique_object])
+ calls = []
+
+ def fake_build_dataframes(**kwargs):
+ calls.append(kwargs)
+ return {"techniques": _object_data("techniques")}
+
+ monkeypatch.setattr(attackToExcel, "build_dataframes", fake_build_dataframes)
+
+ with pytest.raises(FileExistsError, match="Refusing to overwrite existing Excel file"):
+ attackToExcel.export(domain="enterprise-attack", output_dir=str(tmp_path), mem_store=mem_store)
+
+ assert calls == []
+
+
+def test_export_overwrite_allows_existing_excel_file(
+ monkeypatch,
+ tmp_path: Path,
+ attack_memstore_factory,
+ sample_technique_object,
+):
+ """Export should build and write when overwrite is explicitly requested."""
+ output_directory = tmp_path / "enterprise-attack"
+ output_directory.mkdir()
+ (output_directory / "enterprise-attack-techniques.xlsx").write_text("existing", encoding="utf-8")
+ mem_store = attack_memstore_factory([sample_technique_object])
+ calls = {}
+
+ def fake_build_dataframes(**kwargs):
+ return {"techniques": _object_data("techniques")}
+
+ def fake_write_excel(**kwargs):
+ calls["write_excel"] = kwargs
+
+ monkeypatch.setattr(attackToExcel, "build_dataframes", fake_build_dataframes)
+ monkeypatch.setattr(attackToExcel, "write_excel", fake_write_excel)
+
+ attackToExcel.export(domain="enterprise-attack", output_dir=str(tmp_path), mem_store=mem_store, overwrite=True)
+
+ assert calls["write_excel"]["overwrite"] is True
def test_export_rejects_multiple_stix_sources(attack_memstore_factory, sample_technique_object):
@@ -138,9 +253,7 @@ def test_normalize_attack_version_adds_missing_prefix():
def test_export_release_uses_existing_local_stix_files(tmp_path: Path, monkeypatch):
"""Release export should use existing local STIX files without downloading."""
stix_base_dir = tmp_path / "attack-releases" / "stix-2.0" / "v19.0"
- stix_base_dir.mkdir(parents=True)
- for domain in ["enterprise-attack", "mobile-attack"]:
- (stix_base_dir / f"{domain}.json").write_text("{}", encoding="utf-8")
+ _write_stix_files(stix_base_dir, ["enterprise-attack", "mobile-attack"])
calls = {}
@@ -164,15 +277,14 @@ def fake_export(**kwargs):
assert [call["domain"] for call in calls["exports"]] == ["enterprise-attack", "mobile-attack"]
assert calls["exports"][0]["stix_file"] == str(stix_base_dir / "enterprise-attack.json")
assert calls["exports"][0]["version"] == "v19.0"
- assert calls["exports"][0]["output_dir"] == str(tmp_path / "output" / "v19.0")
+ assert calls["exports"][0]["output_dir"] == str(_staging_output_dir(tmp_path / "output", "v19.0"))
+ assert calls["exports"][0]["overwrite"] is False
def test_export_release_with_explicit_local_stix_base_dir_without_version_is_unversioned(tmp_path: Path, monkeypatch):
"""Explicit local STIX bundle directories should not be labelled as ATT&CK releases unless a version is given."""
stix_base_dir = tmp_path / "attack-releases" / "stix-2.0" / "attackwb"
- stix_base_dir.mkdir(parents=True)
- for domain in ["enterprise-attack", "mobile-attack"]:
- (stix_base_dir / f"{domain}.json").write_text("{}", encoding="utf-8")
+ _write_stix_files(stix_base_dir, ["enterprise-attack", "mobile-attack"])
calls = {}
@@ -189,9 +301,10 @@ def fake_export(**kwargs):
assert [call["domain"] for call in calls["exports"]] == ["enterprise-attack", "mobile-attack"]
assert calls["exports"][0]["version"] is None
- assert calls["exports"][0]["output_dir"] == str(tmp_path / "output" / "attackwb")
+ assert calls["exports"][0]["output_dir"] == str(_staging_output_dir(tmp_path / "output" / "attackwb"))
+ assert calls["exports"][0]["overwrite"] is False
assert calls["exports"][1]["version"] is None
- assert calls["exports"][1]["output_dir"] == str(tmp_path / "output" / "attackwb")
+ assert calls["exports"][1]["output_dir"] == str(_staging_output_dir(tmp_path / "output" / "attackwb"))
def test_export_release_downloads_only_missing_domains_to_temporary_directory(tmp_path: Path, monkeypatch):
@@ -234,28 +347,213 @@ def fake_export(**kwargs):
def test_export_release_moves_versioned_outputs_to_domain_directory(tmp_path: Path, monkeypatch):
"""Default release export should flatten domain-version folders into domain folders."""
+ stix_base_dir = tmp_path / "stix"
+ _write_stix_files(stix_base_dir, ["enterprise-attack"])
+
+ monkeypatch.setattr(attackToExcel, "export", _make_fake_release_export())
+
+ attackToExcel.export_release(
+ version="v19.0",
+ stix_base_dir=str(stix_base_dir),
+ output_dir=str(tmp_path / "output"),
+ domains=["enterprise-attack"],
+ )
+
+ assert not (tmp_path / "output" / "v19.0" / "enterprise-attack-v19.0").exists()
+ assert (tmp_path / "output" / "v19.0" / "enterprise-attack" / "enterprise-attack-v19.0.xlsx").exists()
+
+
+@pytest.mark.parametrize("versioned_output_dir", [False, True])
+def test_export_release_refuses_existing_excel_file_before_exporting(
+ tmp_path: Path,
+ monkeypatch,
+ versioned_output_dir: bool,
+):
+ """Release export should check all selected domain output folders before exporting anything."""
+ output_dir = tmp_path / "output"
+ _write_existing_release_excel(
+ output_dir,
+ "mobile-attack",
+ "v19.0",
+ versioned_output_dir=versioned_output_dir,
+ )
+
+ stix_base_dir = tmp_path / "stix"
+ _write_stix_files(stix_base_dir, ["enterprise-attack", "mobile-attack"])
+
+ calls = []
def fake_export(**kwargs):
- output_dir = Path(kwargs["output_dir"])
- versioned_dir = output_dir / f"{kwargs['domain']}-{kwargs['version']}"
- versioned_dir.mkdir(parents=True)
- (versioned_dir / f"{kwargs['domain']}-{kwargs['version']}.xlsx").write_text("excel", encoding="utf-8")
+ calls.append(kwargs)
+
+ monkeypatch.setattr(attackToExcel, "export", fake_export)
+
+ with pytest.raises(FileExistsError, match="Refusing to overwrite existing Excel file"):
+ attackToExcel.export_release(
+ version="v19.0",
+ stix_base_dir=str(stix_base_dir),
+ output_dir=str(output_dir),
+ domains=["enterprise-attack", "mobile-attack"],
+ versioned_output_dir=versioned_output_dir,
+ )
+
+ assert calls == []
+
+
+@pytest.mark.parametrize("versioned_output_dir", [False, True])
+def test_export_release_overwrite_allows_existing_excel_file(
+ tmp_path: Path,
+ monkeypatch,
+ versioned_output_dir: bool,
+):
+ """Release export should proceed when overwrite is explicitly requested."""
+ output_dir = tmp_path / "output"
+ _write_existing_release_excel(
+ output_dir,
+ "mobile-attack",
+ "v19.0",
+ versioned_output_dir=versioned_output_dir,
+ )
stix_base_dir = tmp_path / "stix"
- stix_base_dir.mkdir()
- (stix_base_dir / "enterprise-attack.json").write_text("{}", encoding="utf-8")
+ _write_stix_files(stix_base_dir, ["mobile-attack"])
+
+ calls = []
+
+ def fake_export(**kwargs):
+ calls.append(kwargs)
monkeypatch.setattr(attackToExcel, "export", fake_export)
attackToExcel.export_release(
version="v19.0",
stix_base_dir=str(stix_base_dir),
- output_dir=str(tmp_path / "output"),
+ output_dir=str(output_dir),
+ domains=["mobile-attack"],
+ versioned_output_dir=versioned_output_dir,
+ overwrite=True,
+ )
+
+ assert calls[0]["overwrite"] is True
+ assert calls[0]["log_written_files"] is versioned_output_dir
+
+
+def test_export_release_logs_each_flattened_file_overwrite(tmp_path: Path, monkeypatch):
+ """Release export should log target overwrites before staging files."""
+ output_dir = tmp_path / "output"
+ existing_file = _write_existing_release_excel(output_dir, "enterprise-attack", "v19.0")
+
+ stix_base_dir = tmp_path / "stix"
+ _write_stix_files(stix_base_dir, ["enterprise-attack"])
+ log_messages = []
+
+ monkeypatch.setattr(
+ attackToExcel,
+ "export",
+ _make_fake_release_export(log_marker=lambda: log_messages.append("fake export started")),
+ )
+ monkeypatch.setattr(attackToExcel.logger, "info", log_messages.append)
+
+ attackToExcel.export_release(
+ version="v19.0",
+ stix_base_dir=str(stix_base_dir),
+ output_dir=str(output_dir),
domains=["enterprise-attack"],
+ overwrite=True,
)
- assert not (tmp_path / "output" / "v19.0" / "enterprise-attack-v19.0").exists()
- assert (tmp_path / "output" / "v19.0" / "enterprise-attack" / "enterprise-attack-v19.0.xlsx").exists()
+ overwrite_message = f"Overwriting existing Excel file: {existing_file}"
+ assert "Existing Excel files will be overwritten:" in log_messages
+ assert overwrite_message in log_messages
+ assert log_messages.index(overwrite_message) < log_messages.index("fake export started")
+
+
+def test_export_release_logs_staging_and_final_move_directories(tmp_path: Path, monkeypatch):
+ """Default release export should identify staged writes and final output moves."""
+ output_dir = tmp_path / "output"
+ stix_base_dir = tmp_path / "stix"
+ _write_stix_files(stix_base_dir, ["enterprise-attack"])
+ log_messages = []
+
+ monkeypatch.setattr(attackToExcel, "export", _make_fake_release_export())
+ monkeypatch.setattr(attackToExcel.logger, "info", log_messages.append)
+
+ attackToExcel.export_release(
+ version="v19.0",
+ stix_base_dir=str(stix_base_dir),
+ output_dir=str(output_dir),
+ domains=["enterprise-attack"],
+ overwrite=True,
+ )
+
+ staging_dir = _staging_output_dir(output_dir, "v19.0") / "enterprise-attack-v19.0"
+ assert f"Writing staged Excel files for enterprise-attack to {staging_dir}" in log_messages
+ assert f"Moving staged Excel files for enterprise-attack to {output_dir / 'v19.0' / 'enterprise-attack'}" in (
+ log_messages
+ )
+
+
+def test_export_release_logs_written_files_after_final_move(tmp_path: Path, monkeypatch):
+ """Release export should report final output paths after staged files are moved."""
+ output_dir = tmp_path / "output"
+ stix_base_dir = tmp_path / "stix"
+ _write_stix_files(stix_base_dir, ["enterprise-attack"])
+ log_messages = []
+
+ monkeypatch.setattr(attackToExcel, "export", _make_fake_release_export())
+ monkeypatch.setattr(attackToExcel.logger, "info", log_messages.append)
+
+ attackToExcel.export_release(
+ version="v19.0",
+ stix_base_dir=str(stix_base_dir),
+ output_dir=str(output_dir),
+ domains=["enterprise-attack"],
+ )
+
+ staged_file = _staging_output_dir(output_dir, "v19.0") / "enterprise-attack-v19.0" / "enterprise-attack-v19.0.xlsx"
+ final_file = output_dir / "v19.0" / "enterprise-attack" / "enterprise-attack-v19.0.xlsx"
+ assert f"Excel file written: {final_file}" in log_messages
+ assert f"Excel file written: {staged_file}" not in log_messages
+
+
+def test_export_release_uses_tmp_staging_directory(tmp_path: Path, monkeypatch):
+ """Default release export should stage workbooks under tmp/staged-excel-files."""
+ output_dir = tmp_path / "output"
+ stix_base_dir = tmp_path / "stix"
+ _write_stix_files(stix_base_dir, ["enterprise-attack"])
+ calls = []
+
+ monkeypatch.setattr(attackToExcel, "export", _make_fake_release_export(calls=calls))
+
+ attackToExcel.export_release(
+ version="v19.0",
+ stix_base_dir=str(stix_base_dir),
+ output_dir=str(output_dir),
+ domains=["enterprise-attack"],
+ )
+
+ staging_output_dir = _staging_output_dir(output_dir, "v19.0")
+ assert calls[0]["output_dir"] == str(staging_output_dir)
+ assert not (staging_output_dir / "enterprise-attack-v19.0").exists()
+ assert (output_dir / "v19.0" / "enterprise-attack" / "enterprise-attack-v19.0.xlsx").exists()
+
+
+def test_export_release_moves_unversioned_staged_outputs_to_domain_directory(tmp_path: Path, monkeypatch):
+ """Unversioned release exports should move staged domain folders to final domain folders."""
+ output_dir = tmp_path / "output" / "attackwb"
+ stix_base_dir = tmp_path / "stix"
+ _write_stix_files(stix_base_dir, ["enterprise-attack"])
+
+ monkeypatch.setattr(attackToExcel, "export", _make_fake_release_export())
+
+ attackToExcel.export_release(
+ stix_base_dir=str(stix_base_dir),
+ output_dir=str(output_dir),
+ domains=["enterprise-attack"],
+ )
+
+ assert not (_staging_output_dir(output_dir) / "enterprise-attack").exists()
+ assert (output_dir / "enterprise-attack" / "enterprise-attack.xlsx").exists()
def test_export_release_rejects_invalid_domain():
@@ -289,6 +587,78 @@ def test_write_excel_creates_expected_workbooks(monkeypatch, tmp_path: Path, att
assert _sheet_names(output_folder / "enterprise-attack.xlsx") == ["techniques", "groups", "citations"]
+def test_write_excel_logs_each_file_overwrite(monkeypatch, tmp_path: Path, attack_memstore_factory):
+ """write_excel should log each generated workbook replaced by overwrite."""
+ monkeypatch.setattr(attackToExcel.stixToDf, "detectionStrategiesAnalyticsLogSourcesDf", lambda src: pd.DataFrame())
+ mem_store = attack_memstore_factory([])
+ output_folder = tmp_path / "enterprise-attack"
+ output_folder.mkdir()
+ existing_files = [
+ output_folder / "enterprise-attack.xlsx",
+ output_folder / "enterprise-attack-techniques.xlsx",
+ output_folder / "enterprise-attack-groups.xlsx",
+ ]
+ for existing_file in existing_files:
+ existing_file.write_text("existing", encoding="utf-8")
+
+ log_messages = []
+ monkeypatch.setattr(attackToExcel.logger, "info", log_messages.append)
+
+ attackToExcel.write_excel(
+ dataframes={
+ "techniques": _object_data("techniques"),
+ "groups": _object_data("groups"),
+ },
+ domain="enterprise-attack",
+ src=mem_store,
+ output_dir=str(tmp_path),
+ overwrite=True,
+ )
+
+ for existing_file in existing_files:
+ assert f"Overwriting existing Excel file: {existing_file}" in log_messages
+
+
+def test_write_excel_logs_files_written(monkeypatch, tmp_path: Path, attack_memstore_factory):
+ """write_excel should use wording that applies to created and overwritten workbooks."""
+ monkeypatch.setattr(attackToExcel.stixToDf, "detectionStrategiesAnalyticsLogSourcesDf", lambda src: pd.DataFrame())
+ mem_store = attack_memstore_factory([])
+ log_messages = []
+
+ monkeypatch.setattr(attackToExcel.logger, "info", log_messages.append)
+
+ attackToExcel.write_excel(
+ dataframes={"techniques": _object_data("techniques")},
+ domain="enterprise-attack",
+ src=mem_store,
+ output_dir=str(tmp_path),
+ )
+
+ output_folder = tmp_path / "enterprise-attack"
+ assert f"Excel file written: {output_folder / 'enterprise-attack-techniques.xlsx'}" in log_messages
+ assert f"Excel file written: {output_folder / 'enterprise-attack.xlsx'}" in log_messages
+ assert not any("Excel file created:" in message for message in log_messages)
+
+
+def test_write_excel_can_suppress_written_file_logs(monkeypatch, tmp_path: Path, attack_memstore_factory):
+ """Release staging can suppress staged-path written logs."""
+ monkeypatch.setattr(attackToExcel.stixToDf, "detectionStrategiesAnalyticsLogSourcesDf", lambda src: pd.DataFrame())
+ mem_store = attack_memstore_factory([])
+ log_messages = []
+
+ monkeypatch.setattr(attackToExcel.logger, "info", log_messages.append)
+
+ attackToExcel.write_excel(
+ dataframes={"techniques": _object_data("techniques")},
+ domain="enterprise-attack",
+ src=mem_store,
+ output_dir=str(tmp_path),
+ log_written_files=False,
+ )
+
+ assert not any("Excel file written:" in message for message in log_messages)
+
+
def test_write_excel_skips_empty_object_data(monkeypatch, tmp_path: Path, attack_memstore_factory):
"""Empty object data should not produce an object workbook."""
monkeypatch.setattr(attackToExcel.stixToDf, "detectionStrategiesAnalyticsLogSourcesDf", lambda src: pd.DataFrame())
diff --git a/uv.lock b/uv.lock
index 6642b7e..7d000a9 100644
--- a/uv.lock
+++ b/uv.lock
@@ -641,6 +641,7 @@ name = "mitreattack-python"
version = "6.0.0"
source = { editable = "." }
dependencies = [
+ { name = "click" },
{ name = "colour" },
{ name = "deepdiff" },
{ name = "drawsvg" },
@@ -687,6 +688,7 @@ docs = [
[package.metadata]
requires-dist = [
{ name = "check-wheel-contents", marker = "extra == 'dev'", specifier = ">=0.6.1" },
+ { name = "click", specifier = ">=8.1.8" },
{ name = "colour", specifier = ">=0.1.5" },
{ name = "commitizen", marker = "extra == 'dev'", specifier = ">=4.9.1" },
{ name = "deepdiff", specifier = ">=6.6.0" },