From fc9fe7ef90c3b0957edac13db734b927433d1e49 Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Fri, 28 Nov 2025 13:17:25 +0100 Subject: [PATCH 01/21] Add simple workspace creation script --- tools/workspace/create_workspace.py | 110 +++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) mode change 100644 => 100755 tools/workspace/create_workspace.py diff --git a/tools/workspace/create_workspace.py b/tools/workspace/create_workspace.py old mode 100644 new mode 100755 index 7931b30..7ce510d --- a/tools/workspace/create_workspace.py +++ b/tools/workspace/create_workspace.py @@ -1 +1,109 @@ -# script that sets up the workspace for the DetectMateLibrary project +import argparse +import shutil +from pathlib import Path + +# resolve paths relative to this file +BASE_DIR = Path(__file__).resolve().parent.parent # tools/ +PROJECT_ROOT = BASE_DIR.parent # root of project +TEMPLATE_DIR = BASE_DIR / "workspace" / "templates" + + +def copy_file(src: Path, dst: Path): + """Copy file while ensuring destination directory exists.""" + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(src, dst) + + +def create_readme(target_dir: Path, name: str, type_: str): + """Generate a template README.md""" + content = f"""# {name} + +This is a custom **{type_}** workspace generated with: +mate create --type {type_} --name {name} --dir {target_dir} + +## Structure + +- `{name}.py`: Your custom {type_} implementation. +- `LICENSE.md`: Copied from the main project. +- `.gitignore`: Copied from the main project. +- `.pre-commit-config.yaml`: Copied from the main project. + +## Next Steps + +Implement your {type_} logic inside `{name}.py` +and integrate it with the main system. +""" + with open(target_dir / "README.md", "w") as f: + f.write(content) + + +def create_workspace(type_: str, name: str, target_dir: Path): + """Main workspace creation logic.""" + + print(f"Creating workspace at: {target_dir}") + target_dir.mkdir(parents=True, exist_ok=True) + + # Copy template code + template_file = TEMPLATE_DIR / f"Custom{type_.capitalize()}.py" + if not template_file.exists(): + raise FileNotFoundError(f"Template not found: {template_file}") + + target_code_file = target_dir / f"{name}.py" + copy_file(template_file, target_code_file) + print(f"- Added template code: {target_code_file}") + + # Copy root files + root_files = ["LICENSE.md", ".gitignore", ".pre-commit-config.yaml"] + + for file_name in root_files: + src = PROJECT_ROOT / file_name + dst = target_dir / file_name + if src.exists(): + copy_file(src, dst) + print(f"- Copied {file_name}") + else: + print(f"! Warning: {file_name} not found in project root.") + + # Create README + create_readme(target_dir, name, type_) + print(f"- Created README.md") + + print("\nWorkspace created successfully!") + + +def main(): + parser = argparse.ArgumentParser(description="Create a new workspace") + + subparsers = parser.add_subparsers(dest="command") + create_cmd = subparsers.add_parser("create", help="Create a workspace") + + create_cmd.add_argument( + "--type", + required=True, + choices=["parser", "detector"], + help="Type of component to generate", + ) + + create_cmd.add_argument( + "--name", + required=True, + help="Name for the generated file (e.g., customParser)", + ) + + create_cmd.add_argument( + "--dir", + required=True, + help="Directory where the workspace will be created", + ) + + args = parser.parse_args() + + if args.command == "create": + target_dir = Path(args.dir).expanduser().resolve() + create_workspace(args.type, args.name, target_dir) + else: + parser.print_help() + + +if __name__ == "__main__": + main() From ec4cba9c03977a4c99c5d02beb9721153a852e27 Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Fri, 28 Nov 2025 14:21:33 +0100 Subject: [PATCH 02/21] Move workspace creation dir to src --- src/tools/__init__.py | 0 src/tools/workspace/__init__.py | 0 .../tools}/workspace/create_workspace.py | 2 +- .../workspace/templates/CustomDetector.py | 41 +++++++++++++++++++ src/tools/workspace/templates/CustomParser.py | 33 +++++++++++++++ tools/workspace/templates/CustomDetector.py | 1 - tools/workspace/templates/CustomParser.py | 1 - 7 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 src/tools/__init__.py create mode 100644 src/tools/workspace/__init__.py rename {tools => src/tools}/workspace/create_workspace.py (98%) create mode 100644 src/tools/workspace/templates/CustomDetector.py create mode 100644 src/tools/workspace/templates/CustomParser.py delete mode 100644 tools/workspace/templates/CustomDetector.py delete mode 100644 tools/workspace/templates/CustomParser.py diff --git a/src/tools/__init__.py b/src/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tools/workspace/__init__.py b/src/tools/workspace/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/workspace/create_workspace.py b/src/tools/workspace/create_workspace.py similarity index 98% rename from tools/workspace/create_workspace.py rename to src/tools/workspace/create_workspace.py index 7ce510d..e2e5839 100755 --- a/tools/workspace/create_workspace.py +++ b/src/tools/workspace/create_workspace.py @@ -4,7 +4,7 @@ # resolve paths relative to this file BASE_DIR = Path(__file__).resolve().parent.parent # tools/ -PROJECT_ROOT = BASE_DIR.parent # root of project +PROJECT_ROOT = BASE_DIR.parent.parent # root of project TEMPLATE_DIR = BASE_DIR / "workspace" / "templates" diff --git a/src/tools/workspace/templates/CustomDetector.py b/src/tools/workspace/templates/CustomDetector.py new file mode 100644 index 0000000..1887c73 --- /dev/null +++ b/src/tools/workspace/templates/CustomDetector.py @@ -0,0 +1,41 @@ +from detectmatelibrary.common.detector import CoreDetector, CoreDetectorConfig +from detectmatelibrary.utils.data_buffer import BufferMode +from detectmatelibrary import schemas + +from typing import List, Any + + +class DummyDetectorConfig(CoreDetectorConfig): + """Configuration for DummyDetector.""" + method_type: str = "dummy_detector" + + +class DummyDetector(CoreDetector): + """A dummy detector for testing purposes.""" + + def __init__( + self, + name: str = "DummyDetector", + config: DummyDetectorConfig | dict[str, Any] = DummyDetectorConfig() + ) -> None: + + if isinstance(config, dict): + config = DummyDetectorConfig.from_dict(config, name) + super().__init__(name=name, buffer_mode=BufferMode.NO_BUF, config=config) + self._call_count = 0 + + def detect( + self, + input_: List[schemas.ParserSchema] | schemas.ParserSchema, + output_: schemas.DetectorSchema + ) -> bool | None: + output_.description = "Dummy detection process" + + # Alternating pattern: True, False, True, False, etc + self._call_count += 1 + pattern = [True, False] + result = pattern[self._call_count % len(pattern)] + if result: + output_.score = 1.0 + output_.alertsObtain["type"] = "Anomaly detected by DummyDetector" + return result diff --git a/src/tools/workspace/templates/CustomParser.py b/src/tools/workspace/templates/CustomParser.py new file mode 100644 index 0000000..2945ec0 --- /dev/null +++ b/src/tools/workspace/templates/CustomParser.py @@ -0,0 +1,33 @@ +from detectmatelibrary.common.parser import CoreParser, CoreParserConfig +from detectmatelibrary import schemas + +from typing import Any + + +class DummyParserConfig(CoreParserConfig): + """Configuration for DummyParser.""" + method_type: str = "dummy_parser" + + +class DummyParser(CoreParser): + """A dummy parser for testing purposes.""" + + def __init__( + self, + name: str = "DummyParser", + config: DummyParserConfig | dict[str, Any] = DummyParserConfig() + ) -> None: + + if isinstance(config, dict): + config = DummyParserConfig.from_dict(config, name) + super().__init__(name=name, config=config) + + def parse( + self, + input_: schemas.LogSchema, + output_: schemas.ParserSchema + ) -> None: + + output_.EventID = 2 + output_.variables.extend(["dummy_variable"]) + output_.template = "This is a dummy template" diff --git a/tools/workspace/templates/CustomDetector.py b/tools/workspace/templates/CustomDetector.py deleted file mode 100644 index 0f5dc4a..0000000 --- a/tools/workspace/templates/CustomDetector.py +++ /dev/null @@ -1 +0,0 @@ -# template for a custom detector diff --git a/tools/workspace/templates/CustomParser.py b/tools/workspace/templates/CustomParser.py deleted file mode 100644 index facad0a..0000000 --- a/tools/workspace/templates/CustomParser.py +++ /dev/null @@ -1 +0,0 @@ -# template for a custom parser From 681c23e6f6564c01509418f4471f35d7600da5f7 Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Fri, 28 Nov 2025 14:23:21 +0100 Subject: [PATCH 03/21] Add mate as a cli command in pyproject.toml --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fa2f9b1..7322744 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ dependencies = [ "regex>=2025.11.3", ] - [project.optional-dependencies] # add dependencies in this section with: uv add --optional dev # install with all the dev dependencies: uv pip install -e .[dev] @@ -29,3 +28,6 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["src"] + +[project.scripts] +mate = "tools.workspace.create_workspace:main" From 759572e861b54895b2b377755e73eba10a093586 Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Mon, 1 Dec 2025 14:41:29 +0100 Subject: [PATCH 04/21] Refactor workspace generator --- src/tools/workspace/create_workspace.py | 94 +++++++++++++++---------- src/tools/workspace/utils.py | 46 ++++++++++++ 2 files changed, 104 insertions(+), 36 deletions(-) mode change 100755 => 100644 src/tools/workspace/create_workspace.py create mode 100644 src/tools/workspace/utils.py diff --git a/src/tools/workspace/create_workspace.py b/src/tools/workspace/create_workspace.py old mode 100755 new mode 100644 index e2e5839..aefdc14 --- a/src/tools/workspace/create_workspace.py +++ b/src/tools/workspace/create_workspace.py @@ -1,63 +1,86 @@ import argparse import shutil +import sys from pathlib import Path +from .utils import create_readme + # resolve paths relative to this file BASE_DIR = Path(__file__).resolve().parent.parent # tools/ PROJECT_ROOT = BASE_DIR.parent.parent # root of project TEMPLATE_DIR = BASE_DIR / "workspace" / "templates" +META_FILES = ["LICENSE.md", ".gitignore", ".pre-commit-config.yaml"] + -def copy_file(src: Path, dst: Path): +def copy_file(src: Path, dst: Path) -> None: """Copy file while ensuring destination directory exists.""" dst.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(src, dst) + shutil.copy2(src, dst) -def create_readme(target_dir: Path, name: str, type_: str): - """Generate a template README.md""" - content = f"""# {name} +def camelize(name: str) -> str: + """Convert names like "custom_parser" or "customParser" to "CustomParser". -This is a custom **{type_}** workspace generated with: -mate create --type {type_} --name {name} --dir {target_dir} + (Best practice from new version.) + """ + if "_" in name or "-" in name: + parts = name.replace("-", "_").split("_") + return "".join(p.capitalize() for p in parts if p) + return name[0].upper() + name[1:] -## Structure -- `{name}.py`: Your custom {type_} implementation. -- `LICENSE.md`: Copied from the main project. -- `.gitignore`: Copied from the main project. -- `.pre-commit-config.yaml`: Copied from the main project. +def create_workspace(type_: str, name: str, target_dir: Path) -> None: + """Main workspace creation logic. -## Next Steps + - `target_dir` is the workspace root (from --dir) + - Code lives in a subpackage: // + - Meta files (LICENSE, .gitignore, etc.) + README.md live in workspace root + """ -Implement your {type_} logic inside `{name}.py` -and integrate it with the main system. -""" - with open(target_dir / "README.md", "w") as f: - f.write(content) + # Workspace root + workspace_root = Path(target_dir).expanduser().resolve() + workspace_root.mkdir(parents=True, exist_ok=True) + # Package directory inside the workspace + pkg_dir = workspace_root / name -def create_workspace(type_: str, name: str, target_dir: Path): - """Main workspace creation logic.""" + # Fail if the package directory already exists + if pkg_dir.exists(): + print(f"ERROR: Target directory already exists: {pkg_dir}", file=sys.stderr) + sys.exit(1) - print(f"Creating workspace at: {target_dir}") - target_dir.mkdir(parents=True, exist_ok=True) + print(f"Creating workspace at: {pkg_dir}") + pkg_dir.mkdir(parents=True, exist_ok=False) - # Copy template code + # Template selection template_file = TEMPLATE_DIR / f"Custom{type_.capitalize()}.py" + target_code_file = pkg_dir / f"{name}.py" + if not template_file.exists(): - raise FileNotFoundError(f"Template not found: {template_file}") + print(f"WARNING: Template not found: {template_file}. Creating empty file.", + file=sys.stderr) + target_code_file.touch() + else: + template_content = template_file.read_text() - target_code_file = target_dir / f"{name}.py" - copy_file(template_file, target_code_file) - print(f"- Added template code: {target_code_file}") + # Replace default class name inside template + original_class = f"Custom{type_.capitalize()}" + new_class = camelize(name) + template_content = template_content.replace(original_class, new_class) - # Copy root files - root_files = ["LICENSE.md", ".gitignore", ".pre-commit-config.yaml"] + target_code_file.write_text(template_content) - for file_name in root_files: + print(f"- Added implementation file: {target_code_file}") + + # Make the package importable + (pkg_dir / "__init__.py").touch() + + # Copy meta/root files + for file_name in META_FILES: src = PROJECT_ROOT / file_name - dst = target_dir / file_name + dst = workspace_root / file_name + if src.exists(): copy_file(src, dst) print(f"- Copied {file_name}") @@ -65,16 +88,15 @@ def create_workspace(type_: str, name: str, target_dir: Path): print(f"! Warning: {file_name} not found in project root.") # Create README - create_readme(target_dir, name, type_) - print(f"- Created README.md") - + create_readme(name, type_, target_code_file, workspace_root) + print(f"- Created README.md in {workspace_root}") print("\nWorkspace created successfully!") -def main(): +def main() -> None: parser = argparse.ArgumentParser(description="Create a new workspace") - subparsers = parser.add_subparsers(dest="command") + subparsers = parser.add_subparsers(dest="command", required=True) create_cmd = subparsers.add_parser("create", help="Create a workspace") create_cmd.add_argument( diff --git a/src/tools/workspace/utils.py b/src/tools/workspace/utils.py new file mode 100644 index 0000000..bacccd0 --- /dev/null +++ b/src/tools/workspace/utils.py @@ -0,0 +1,46 @@ +import textwrap +from pathlib import Path + + +def create_readme(name: str, ws_type: str, target_impl: Path, target_dir: Path) -> None: + """Create a README.md file for the generated workspace. + + Parameters: + name (str): Name of the workspace. + ws_type (str): Type of workspace (parser/detector). + target_impl (Path): Path to the main implementation file. + target_dir (Path): Directory where the README will be created. + """ + + readme_text = textwrap.dedent( + f""" + # {name} + + This is a generated **{ws_type}** workspace created with: + + ```bash + mate create --type {ws_type} --name {name} --dir {target_dir.parent} + ``` + + ## Contents + + - `{target_impl.name}`: starting point for your `{ws_type}` implementation. + - `LICENSE.md`: copied from the main project. + - `.pre-commit-config.yaml`: pre-commit hook configuration from the main project. + - `.gitignore`: standard ignore rules from the main project. + - `__init__.py`: makes this directory importable as a package. + + ## Next steps + + 1. Open `{target_impl.name}` and implement your custom {ws_type}. + 2. (Optional) Install pre-commit hooks: + + ```bash + pre-commit install + ``` + + 3. Integrate this workspace with the rest of your project. + """ + ).strip() + "\n" + + (target_dir / "README.md").write_text(readme_text) From e22fc3042417364ae58fba8ce3804d23a5c264fe Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Mon, 1 Dec 2025 16:06:47 +0100 Subject: [PATCH 05/21] Add workspace creation tests --- tests/test_workspace/test_create_workspace.py | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 tests/test_workspace/test_create_workspace.py diff --git a/tests/test_workspace/test_create_workspace.py b/tests/test_workspace/test_create_workspace.py new file mode 100644 index 0000000..288d766 --- /dev/null +++ b/tests/test_workspace/test_create_workspace.py @@ -0,0 +1,92 @@ +import subprocess +import pytest + +# Path to the CLI entry point +CLI = ["mate"] # installed as console script + + +@pytest.fixture +def temp_dir(tmp_path): + # Creates an isolated directory for each test (workspace root) + return tmp_path + + +def test_create_parser_workspace(temp_dir): + ws_name = "myParser" + workspace_root = temp_dir + pkg_dir = workspace_root / ws_name + + # Run the CLI tool + subprocess.check_call([ + *CLI, + "create", + "--type", "parser", + "--name", ws_name, + "--dir", str(workspace_root), + ]) + + # Workspace root exists + assert workspace_root.exists() + + # Package directory exists + assert pkg_dir.exists() + + # Meta files live in workspace root + assert (workspace_root / "LICENSE.md").exists() + assert (workspace_root / ".gitignore").exists() + assert (workspace_root / ".pre-commit-config.yaml").exists() + assert (workspace_root / "README.md").exists() + + # Python files live in package directory + py_files = list(pkg_dir.glob("*.py")) + assert len(py_files) == 2 # __init__.py + myParser.py + assert (pkg_dir / f"{ws_name}.py").exists() + assert (pkg_dir / "__init__.py").exists() + + +def test_create_detector_workspace(temp_dir): + ws_name = "myDetector" + workspace_root = temp_dir + pkg_dir = workspace_root / ws_name + + subprocess.check_call([ + *CLI, + "create", + "--type", "detector", + "--name", ws_name, + "--dir", str(workspace_root), + ]) + + assert workspace_root.exists() + assert pkg_dir.exists() + + assert (workspace_root / "LICENSE.md").exists() + assert (workspace_root / ".gitignore").exists() + assert (workspace_root / ".pre-commit-config.yaml").exists() + assert (workspace_root / "README.md").exists() + + py_files = list(pkg_dir.glob("*.py")) + assert len(py_files) == 2 # __init__.py + myDetector.py + assert (pkg_dir / f"{ws_name}.py").exists() + assert (pkg_dir / "__init__.py").exists() + + +def test_fail_if_dir_exists(temp_dir): + ws_name = "existing" + workspace_root = temp_dir + pkg_dir = workspace_root / ws_name + + # Pre-create the package directory to force failure + pkg_dir.mkdir(parents=True, exist_ok=True) + + # Should fail because the package directory already exists + result = subprocess.run([ + *CLI, + "create", + "--type", "parser", + "--name", ws_name, + "--dir", str(workspace_root), + ], capture_output=True, text=True) + + assert result.returncode != 0 + assert "already exists" in result.stderr From d79e7b00119445c509a521c8b847ca9d3bf19755 Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Tue, 2 Dec 2025 14:23:06 +0100 Subject: [PATCH 06/21] Add step to create pyproject.toml for workspace --- src/tools/workspace/create_workspace.py | 14 +++++---- src/tools/workspace/utils.py | 41 ++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/tools/workspace/create_workspace.py b/src/tools/workspace/create_workspace.py index aefdc14..5ff18c6 100644 --- a/src/tools/workspace/create_workspace.py +++ b/src/tools/workspace/create_workspace.py @@ -3,7 +3,7 @@ import sys from pathlib import Path -from .utils import create_readme +from .utils import create_readme, create_pyproject # resolve paths relative to this file BASE_DIR = Path(__file__).resolve().parent.parent # tools/ @@ -20,10 +20,8 @@ def copy_file(src: Path, dst: Path) -> None: def camelize(name: str) -> str: - """Convert names like "custom_parser" or "customParser" to "CustomParser". - - (Best practice from new version.) - """ + """Convert names like "custom_parser" or "customParser" to + "CustomParser".""" if "_" in name or "-" in name: parts = name.replace("-", "_").split("_") return "".join(p.capitalize() for p in parts if p) @@ -35,7 +33,7 @@ def create_workspace(type_: str, name: str, target_dir: Path) -> None: - `target_dir` is the workspace root (from --dir) - Code lives in a subpackage: // - - Meta files (LICENSE, .gitignore, etc.) + README.md live in workspace root + - Meta files (LICENSE, .gitignore, etc.) + README.md + pyproject.toml live in workspace root """ # Workspace root @@ -87,6 +85,10 @@ def create_workspace(type_: str, name: str, target_dir: Path) -> None: else: print(f"! Warning: {file_name} not found in project root.") + # Create pyproject.toml + create_pyproject(name, type_, workspace_root) + print(f"- Created pyproject.toml in {workspace_root}") + # Create README create_readme(name, type_, target_code_file, workspace_root) print(f"- Created README.md in {workspace_root}") diff --git a/src/tools/workspace/utils.py b/src/tools/workspace/utils.py index bacccd0..11c7492 100644 --- a/src/tools/workspace/utils.py +++ b/src/tools/workspace/utils.py @@ -1,4 +1,5 @@ import textwrap +import re from pathlib import Path @@ -28,6 +29,7 @@ def create_readme(name: str, ws_type: str, target_impl: Path, target_dir: Path) - `LICENSE.md`: copied from the main project. - `.pre-commit-config.yaml`: pre-commit hook configuration from the main project. - `.gitignore`: standard ignore rules from the main project. + - `pyproject.toml`: Python project metadata and dependencies for this workspace. - `__init__.py`: makes this directory importable as a package. ## Next steps @@ -39,8 +41,45 @@ def create_readme(name: str, ws_type: str, target_impl: Path, target_dir: Path) pre-commit install ``` - 3. Integrate this workspace with the rest of your project. + 3. Add the libraries your workspace needs to `pyproject.toml` under `dependencies`. + 4. Integrate this workspace with the rest of your project. """ ).strip() + "\n" (target_dir / "README.md").write_text(readme_text) + + +def create_pyproject(name: str, ws_type: str, target_dir: Path) -> None: + """Create a minimal pyproject.toml file for the generated workspace. + + - Uses the --name argument (normalized) as the project name. + - Leaves a dependencies list ready for you to fill with the necessary libraries. + """ + + # Normalize name for project name (PEP 621 style) + project_name = re.sub(r"[-_.]+", "-", name).lower() + + pyproject_text = textwrap.dedent( + f""" + [project] + name = "{project_name}" + version = "0.1.0" + description = "Generated {ws_type} workspace '{name}'" + readme = "README.md" + requires-python = ">=3.12" + + # Add the libraries your workspace needs below + dependencies = [ + ] + + [build-system] + requires = ["setuptools>=64", "wheel"] + build-backend = "setuptools.build_meta" + + [tool.setuptools] + # Treat the '{name}' directory as the package + packages = ["{name}"] + """ + ).strip() + "\n" + + (target_dir / "pyproject.toml").write_text(pyproject_text) From 452a91affafe36e5f2d6a561160ace398b3a4e6c Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Tue, 2 Dec 2025 14:53:41 +0100 Subject: [PATCH 07/21] Add prek as optional dependency and update readme --- src/tools/workspace/utils.py | 93 ++++++++++++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 9 deletions(-) diff --git a/src/tools/workspace/utils.py b/src/tools/workspace/utils.py index 11c7492..3242cb3 100644 --- a/src/tools/workspace/utils.py +++ b/src/tools/workspace/utils.py @@ -23,26 +23,94 @@ def create_readme(name: str, ws_type: str, target_impl: Path, target_dir: Path) mate create --type {ws_type} --name {name} --dir {target_dir.parent} ``` + The directory containing this `README.md` is referred to as the *workspace root* below. + ## Contents - `{target_impl.name}`: starting point for your `{ws_type}` implementation. - `LICENSE.md`: copied from the main project. - `.pre-commit-config.yaml`: pre-commit hook configuration from the main project. - `.gitignore`: standard ignore rules from the main project. - - `pyproject.toml`: Python project metadata and dependencies for this workspace. + - `pyproject.toml`: Python project metadata, dependencies, and dev extras. - `__init__.py`: makes this directory importable as a package. - ## Next steps + ## Recommended setup (uv + prek) + + We recommend using [`uv`](https://github.com/astral-sh/uv) to manage the environment + and dependencies, and [`prek`](https://github.com/j178/prek) to manage Git + pre-commit hooks. `prek` is configured via the existing `.pre-commit-config.yaml` + and can be installed as part of the `dev` extras. + + ### 1. Create and activate a virtual environment with uv + + From the workspace root (the directory containing this `README.md`): + + ```bash + cd + + # Create a virtual environment (if you don't have one yet) + uv venv + + # Activate it + source .venv/bin/activate # Linux/macOS + # .venv\\Scripts\\activate # Windows (PowerShell / CMD) + ``` - 1. Open `{target_impl.name}` and implement your custom {ws_type}. - 2. (Optional) Install pre-commit hooks: + ### 2. Install the project and dev dependencies (including prek) - ```bash - pre-commit install - ``` + ```bash + uv pip install -e .[dev] + ``` + + To add new dev-only dependencies later: + + ```bash + uv add --optional dev + ``` + + ### 3. Install and run Git hooks with prek + + With the virtual environment activated: + + ```bash + # Install Git hooks from .pre-commit-config.yaml using prek + prek install - 3. Add the libraries your workspace needs to `pyproject.toml` under `dependencies`. - 4. Integrate this workspace with the rest of your project. + # (Optional but recommended) Run all hooks once on the full codebase + prek run --all-files + ``` + + After this, hooks will run automatically on each commit. + + ## Alternative setup (pip instead of uv) + + If you prefer plain `pip`, you can set things up like this instead: + + ```bash + cd + + # Create a virtual environment + python -m venv .venv + + # Activate it + source .venv/bin/activate # Linux/macOS + # .venv\\Scripts\\activate # Windows (PowerShell / CMD) + + # Install the project in editable mode with dev dependencies + pip install -e .[dev] + ``` + + With `pip`, `prek` will still be available from the virtual environment, + and you can use the same commands to install hooks: + + ```bash + prek install + prek run --all-files + ``` + + ## Next steps + + Open `{target_impl.name}` and implement your custom {ws_type}. """ ).strip() + "\n" @@ -72,6 +140,13 @@ def create_pyproject(name: str, ws_type: str, target_dir: Path) -> None: dependencies = [ ] + [project.optional-dependencies] + # Add dependencies in this section with: uv add --optional dev + # Install with all the dev dependencies: uv pip install -e .[dev] + dev = [ + "prek>=0.2.8", + ] + [build-system] requires = ["setuptools>=64", "wheel"] build-backend = "setuptools.build_meta" From 148720c189f7a09f5a0f8f50704b36e2cc732dec Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Tue, 2 Dec 2025 15:07:26 +0100 Subject: [PATCH 08/21] Update templates --- .../workspace/templates/CustomDetector.py | 37 +++++++++++++------ src/tools/workspace/templates/CustomParser.py | 34 +++++++++++------ 2 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/tools/workspace/templates/CustomDetector.py b/src/tools/workspace/templates/CustomDetector.py index 1887c73..2f32d37 100644 --- a/src/tools/workspace/templates/CustomDetector.py +++ b/src/tools/workspace/templates/CustomDetector.py @@ -1,34 +1,48 @@ +from typing import Any, List + from detectmatelibrary.common.detector import CoreDetector, CoreDetectorConfig from detectmatelibrary.utils.data_buffer import BufferMode from detectmatelibrary import schemas -from typing import List, Any +class CustomDetectorConfig(CoreDetectorConfig): + """Configuration for CustomDetector.""" + # You can change this to whatever method_type you need + method_type: str = "custom_detector" -class DummyDetectorConfig(CoreDetectorConfig): - """Configuration for DummyDetector.""" - method_type: str = "dummy_detector" +class CustomDetector(CoreDetector): + """Template detector implementation based on CoreDetector. -class DummyDetector(CoreDetector): - """A dummy detector for testing purposes.""" + Replace this docstring with a description of what your detector does + and how it should be used. + """ def __init__( self, - name: str = "DummyDetector", - config: DummyDetectorConfig | dict[str, Any] = DummyDetectorConfig() + name: str = "CustomDetector", + config: CustomDetectorConfig | dict[str, Any] = CustomDetectorConfig(), ) -> None: + # Allow passing either a config instance or a plain dict if isinstance(config, dict): - config = DummyDetectorConfig.from_dict(config, name) + config = CustomDetectorConfig.from_dict(config, name) + super().__init__(name=name, buffer_mode=BufferMode.NO_BUF, config=config) self._call_count = 0 def detect( self, input_: List[schemas.ParserSchema] | schemas.ParserSchema, - output_: schemas.DetectorSchema + output_: schemas.DetectorSchema, ) -> bool | None: + """Run detection on parser output and populate the detector schema. + + :param input_: One or many ParserSchema instances + :param output_: DetectorSchema instance to be mutated in-place + :return: Detection result (True/False) or None + """ + output_.description = "Dummy detection process" # Alternating pattern: True, False, True, False, etc @@ -37,5 +51,6 @@ def detect( result = pattern[self._call_count % len(pattern)] if result: output_.score = 1.0 - output_.alertsObtain["type"] = "Anomaly detected by DummyDetector" + output_.alertsObtain["type"] = "Anomaly detected by CustomDetector" + return result diff --git a/src/tools/workspace/templates/CustomParser.py b/src/tools/workspace/templates/CustomParser.py index 2945ec0..6d41bed 100644 --- a/src/tools/workspace/templates/CustomParser.py +++ b/src/tools/workspace/templates/CustomParser.py @@ -1,33 +1,45 @@ +from typing import Any + from detectmatelibrary.common.parser import CoreParser, CoreParserConfig from detectmatelibrary import schemas -from typing import Any +class CustomParserConfig(CoreParserConfig): + """Configuration for CustomParser.""" + # You can change this to whatever method_type you need + method_type: str = "custom_parser" -class DummyParserConfig(CoreParserConfig): - """Configuration for DummyParser.""" - method_type: str = "dummy_parser" +class CustomParser(CoreParser): + """Template parser implementation based on CoreParser. -class DummyParser(CoreParser): - """A dummy parser for testing purposes.""" + Replace this docstring with a description of what your parser does. + """ def __init__( self, - name: str = "DummyParser", - config: DummyParserConfig | dict[str, Any] = DummyParserConfig() + name: str = "CustomParser", + config: CustomParserConfig | dict[str, Any] = CustomParserConfig(), ) -> None: - + # Allow passing either a config instance or a plain dict if isinstance(config, dict): - config = DummyParserConfig.from_dict(config, name) + config = CustomParserConfig.from_dict(config, name) + super().__init__(name=name, config=config) def parse( self, input_: schemas.LogSchema, - output_: schemas.ParserSchema + output_: schemas.ParserSchema, ) -> None: + """Parse a single log entry and populate the output schema. + + :param input_: Input log schema instance + :param output_: Parser output schema instance to be mutated in- + place + """ + # Dummy implementation example (replace with real logic) output_.EventID = 2 output_.variables.extend(["dummy_variable"]) output_.template = "This is a dummy template" From 221b9abb8766e435edeb7209bd5cd385a9993737 Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Wed, 3 Dec 2025 12:53:04 +0100 Subject: [PATCH 09/21] Update readme with usage of workspace creation --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 67d2b04..4c24fe2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Main library to run the different components in DetectMate. The library contains the next components: -* **Readers**: insert logs into the system. +* **Readers**: insert logs into the system. * **Parsers**: parse the logs receive from the reader. * **Detectors**: return alerts if anomalies are detected. * **Schemas**: standard data classes use in DetectMate. @@ -26,13 +26,16 @@ uv run prek install ``` ### Step 2: Install Protobuf dependencies -To installed in linux do: + +To install in Linux do: + ```bash sudo apt install -y protobuf-compiler protoc --version ``` -This dependency is only need if a proto files is modifiy. To compile the proto file do: -``` + +This dependency is only needed if a proto file is modified. To compile the proto file do: +```bash protoc --proto_path=src/schemas/ --python_out=src/schemas/ src/schemas/schemas.proto ``` @@ -48,3 +51,46 @@ Run the tests with coverage (add --cov-report=html to generate an HTML report): ```bash uv run pytest --cov=. --cov-report=term-missing ``` + +## Workspace generator (`mate create`) + +DetectMateLibrary includes a small CLI helper to bootstrap standalone workspaces +for custom parsers and detectors. This is useful if you want to develop and test +components in isolation while still using the same library and schemas. + +### Usage + +The CLI entry point is `mate` with a `create` command: + +```bash +mate create --type --name --dir +``` + +| Option | Description | +|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `--type` | Component type to generate:
- `parser`: CoreParser-based template
- `detector`: CoreDetector-based template | +| `--name` | Name of the component and package:
- Creates package dir: `//`
- Creates main file: `.py`
- Derives class names: `` and `Config` | +| `--dir` | Directory where the workspace will be created | + + +### What gets generated + +For example: + +```bash +mate create --type parser --name custom_parser --dir ./workspaces/custom_parser +``` + +will create: + +```text +workspaces/custom_parser/ # workspace root +├── custom_parser/ # Python package +│ ├── __init__.py +│ └── custom_parser.py # CoreParser-based template +├── LICENSE.md # copied from main project +├── .gitignore # copied from main project +├── .pre-commit-config.yaml # copied from main project +├── pyproject.toml # minimal project + dev extras +└── README.md # setup instructions +``` From e6952aa2ddfa3e31a2678f39307b0c329bbd07fd Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Wed, 3 Dec 2025 15:05:02 +0100 Subject: [PATCH 10/21] add comments --- src/tools/workspace/templates/CustomDetector.py | 6 +++--- src/tools/workspace/templates/CustomParser.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tools/workspace/templates/CustomDetector.py b/src/tools/workspace/templates/CustomDetector.py index 2f32d37..65d3a5d 100644 --- a/src/tools/workspace/templates/CustomDetector.py +++ b/src/tools/workspace/templates/CustomDetector.py @@ -43,14 +43,14 @@ def detect( :return: Detection result (True/False) or None """ - output_.description = "Dummy detection process" + output_["description"] = "Dummy detection process" # Description of the detection # Alternating pattern: True, False, True, False, etc self._call_count += 1 pattern = [True, False] result = pattern[self._call_count % len(pattern)] if result: - output_.score = 1.0 - output_.alertsObtain["type"] = "Anomaly detected by CustomDetector" + output_["score"] = 1.0 # Score of the detector + output_["alertsObtain"]["type"] = "Anomaly detected by CustomDetector" # Aditional info return result diff --git a/src/tools/workspace/templates/CustomParser.py b/src/tools/workspace/templates/CustomParser.py index 6d41bed..38fa2cb 100644 --- a/src/tools/workspace/templates/CustomParser.py +++ b/src/tools/workspace/templates/CustomParser.py @@ -40,6 +40,6 @@ def parse( """ # Dummy implementation example (replace with real logic) - output_.EventID = 2 - output_.variables.extend(["dummy_variable"]) - output_.template = "This is a dummy template" + output_["EventID"] = 2 # Number of the log template + output_["variables"].extend(["dummy_variable"]) # Variables found in the log + output_["template"] = "This is a dummy template" # Log template From 7fd409896854bd07f86e8cede6e89e8915a033be Mon Sep 17 00:00:00 2001 From: "angre.garcia-gomez@ait.ac.at" Date: Wed, 3 Dec 2025 15:13:38 +0100 Subject: [PATCH 11/21] remove deprecatedwarnings --- src/detectmatelibrary/utils/time_format_handler.py | 2 +- tests/test_utils/test_log_format_utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/detectmatelibrary/utils/time_format_handler.py b/src/detectmatelibrary/utils/time_format_handler.py index 129b4b5..5f2fcd0 100644 --- a/src/detectmatelibrary/utils/time_format_handler.py +++ b/src/detectmatelibrary/utils/time_format_handler.py @@ -40,7 +40,7 @@ def _parse_with_format(self, time_str: str, fmt: str) -> str | None: try: # handle syslog-like formats that lack a year by prepending the current year if fmt == "%b %d %H:%M:%S" and re.match(r"^[A-Za-z]{3} \d{1,2} ", time_str): - year = datetime.utcnow().year + year = datetime.now().year dt = datetime.strptime(f"{year} {time_str}", f"%Y {fmt}") else: dt = datetime.strptime(time_str, fmt) diff --git a/tests/test_utils/test_log_format_utils.py b/tests/test_utils/test_log_format_utils.py index 6913f0b..b79d6b8 100644 --- a/tests/test_utils/test_log_format_utils.py +++ b/tests/test_utils/test_log_format_utils.py @@ -39,7 +39,7 @@ def test_epoch_seconds_and_millis(): def test_syslog_without_year_assumes_current_year(): # Expect that a string like 'Nov 11 12:13:14' is parsed with current year s = "Nov 11 12:13:14" - year = datetime.utcnow().year + year = datetime.now().year dt = datetime.strptime(f"{year} {s}", "%Y %b %d %H:%M:%S") dt = dt.replace(tzinfo=timezone.utc) assert tfh.parse_timestamp(s) == str(int(dt.timestamp())) From 4cf1c5a50b366277cf2fdf576027a5ef2efa68f1 Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Wed, 3 Dec 2025 17:16:12 +0100 Subject: [PATCH 12/21] Normalize package name to lowercase --- src/tools/workspace/create_workspace.py | 4 ++-- src/tools/workspace/utils.py | 13 +++++++++---- tests/test_workspace/test_create_workspace.py | 4 ++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/tools/workspace/create_workspace.py b/src/tools/workspace/create_workspace.py index 5ff18c6..b522e84 100644 --- a/src/tools/workspace/create_workspace.py +++ b/src/tools/workspace/create_workspace.py @@ -3,7 +3,7 @@ import sys from pathlib import Path -from .utils import create_readme, create_pyproject +from .utils import create_readme, create_pyproject, normalize # resolve paths relative to this file BASE_DIR = Path(__file__).resolve().parent.parent # tools/ @@ -41,7 +41,7 @@ def create_workspace(type_: str, name: str, target_dir: Path) -> None: workspace_root.mkdir(parents=True, exist_ok=True) # Package directory inside the workspace - pkg_dir = workspace_root / name + pkg_dir = workspace_root / normalize(name) # Fail if the package directory already exists if pkg_dir.exists(): diff --git a/src/tools/workspace/utils.py b/src/tools/workspace/utils.py index 3242cb3..3fa9896 100644 --- a/src/tools/workspace/utils.py +++ b/src/tools/workspace/utils.py @@ -3,6 +3,11 @@ from pathlib import Path +def normalize(name: str) -> str: + """Normalize name for project name (PEP 621 style).""" + return re.sub(r"[-_.]+", "-", name).lower() + + def create_readme(name: str, ws_type: str, target_impl: Path, target_dir: Path) -> None: """Create a README.md file for the generated workspace. @@ -124,8 +129,7 @@ def create_pyproject(name: str, ws_type: str, target_dir: Path) -> None: - Leaves a dependencies list ready for you to fill with the necessary libraries. """ - # Normalize name for project name (PEP 621 style) - project_name = re.sub(r"[-_.]+", "-", name).lower() + project_name = normalize(name) pyproject_text = textwrap.dedent( f""" @@ -144,6 +148,7 @@ def create_pyproject(name: str, ws_type: str, target_dir: Path) -> None: # Add dependencies in this section with: uv add --optional dev # Install with all the dev dependencies: uv pip install -e .[dev] dev = [ + "detectmateservice @ git+https://github.com/ait-detectmate/DetectMateService.git "prek>=0.2.8", ] @@ -152,8 +157,8 @@ def create_pyproject(name: str, ws_type: str, target_dir: Path) -> None: build-backend = "setuptools.build_meta" [tool.setuptools] - # Treat the '{name}' directory as the package - packages = ["{name}"] + # Treat the '{project_name}' directory as the package + packages = ["{project_name}"] """ ).strip() + "\n" diff --git a/tests/test_workspace/test_create_workspace.py b/tests/test_workspace/test_create_workspace.py index 288d766..724a800 100644 --- a/tests/test_workspace/test_create_workspace.py +++ b/tests/test_workspace/test_create_workspace.py @@ -14,7 +14,7 @@ def temp_dir(tmp_path): def test_create_parser_workspace(temp_dir): ws_name = "myParser" workspace_root = temp_dir - pkg_dir = workspace_root / ws_name + pkg_dir = workspace_root / "myparser" # Run the CLI tool subprocess.check_call([ @@ -47,7 +47,7 @@ def test_create_parser_workspace(temp_dir): def test_create_detector_workspace(temp_dir): ws_name = "myDetector" workspace_root = temp_dir - pkg_dir = workspace_root / ws_name + pkg_dir = workspace_root / "mydetector" subprocess.check_call([ *CLI, From c71059bd14e7424dc920e61960dd8a7b8eb2a2d8 Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Wed, 3 Dec 2025 17:52:17 +0100 Subject: [PATCH 13/21] Add DetectMateService as optional dependency with usage description --- src/tools/workspace/utils.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/tools/workspace/utils.py b/src/tools/workspace/utils.py index 3fa9896..914a1c1 100644 --- a/src/tools/workspace/utils.py +++ b/src/tools/workspace/utils.py @@ -17,6 +17,8 @@ def create_readme(name: str, ws_type: str, target_impl: Path, target_dir: Path) target_impl (Path): Path to the main implementation file. target_dir (Path): Directory where the README will be created. """ + # Import path to the implementation module, e.g. "MyCoolThing.MyCoolThing" + impl_module = f"{target_dir.name}.{target_impl.stem}" readme_text = textwrap.dedent( f""" @@ -116,6 +118,33 @@ def create_readme(name: str, ws_type: str, target_impl: Path, target_dir: Path) ## Next steps Open `{target_impl.name}` and implement your custom {ws_type}. + + ## (Optional) Run it as a Service + + You can run your {ws_type} as a Service using the DetectMateService, which is added as + an optional dependency in the `dev` extras. + + For this, create a settings file (e.g., `service_settings.yaml`) in the workspace root, + which could look like this: + + ```yaml + component_name: {name} + component_type: {impl_module}.{name} + component_config_class: {impl_module}.{name}Config + log_level: DEBUG + log_dir: ./logs + manager_addr: ipc:///tmp/{name.lower()}_cmd.ipc + engine_addr: ipc:///tmp/{name.lower()}_engine.ipc + ``` + + Then start your {ws_type} service with: + + ```bash + detectmate start --settings service_settings.yaml + ``` + + Make sure you run this command from within the virtual environment where you installed + this workspace (e.g. after `uv venv && source .venv/bin/activate`). """ ).strip() + "\n" From b0de5d9bcd309c7590a784cd4d65fb9d7c7d6543 Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Thu, 4 Dec 2025 14:11:34 +0100 Subject: [PATCH 14/21] Update bandit hook so it ignores any file starting with `test_` --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ff966b7..b651b0e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,7 +58,7 @@ repos: rev: 1.8.6 hooks: - id: bandit - exclude: ^tests/ + exclude: (^tests/|.*/test_.*|^test_.*) # Unused code detection - repo: https://github.com/jendrikseipp/vulture From 0bea01dbd4def276c6ed5220ee540b3f11ad5865 Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Thu, 4 Dec 2025 14:13:05 +0100 Subject: [PATCH 15/21] Create tests folder with custom component test file --- src/tools/workspace/create_workspace.py | 34 +++++++++++++++-- .../templates/test_CustomDetector.py | 38 +++++++++++++++++++ .../workspace/templates/test_CustomParser.py | 33 ++++++++++++++++ src/tools/workspace/utils.py | 3 +- 4 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 src/tools/workspace/templates/test_CustomDetector.py create mode 100644 src/tools/workspace/templates/test_CustomParser.py diff --git a/src/tools/workspace/create_workspace.py b/src/tools/workspace/create_workspace.py index b522e84..5796da4 100644 --- a/src/tools/workspace/create_workspace.py +++ b/src/tools/workspace/create_workspace.py @@ -28,6 +28,32 @@ def camelize(name: str) -> str: return name[0].upper() + name[1:] +def create_tests(type_: str, name: str, workspace_root: Path, pkg_name: str) -> None: + """Create a tests/ directory with a basic pytest file for the component.""" + + tests_dir = workspace_root / "tests" + tests_dir.mkdir(parents=True, exist_ok=True) + template_file = TEMPLATE_DIR / f"test_Custom{type_.capitalize()}.py" + test_file = tests_dir / f"test_{name}.py" + + if not template_file.exists(): + print(f"WARNING: Test template {template_file} not found. Creating empty test file.", file=sys.stderr) + test_file.touch() + return + + template_content = template_file.read_text() + original_class = f"Custom{type_.capitalize()}" + new_class = camelize(name) + + # Replace placeholders + content = template_content.replace(original_class, new_class) # CustomParser/Detector -> actual class + content = content.replace("custom_component", pkg_name) # custom_component -> package directory + content = content.replace("custom_module", name) # custom_module -> module (file) name + + test_file.write_text(content) + print(f"- Added tests file: {test_file}") + + def create_workspace(type_: str, name: str, target_dir: Path) -> None: """Main workspace creation logic. @@ -41,7 +67,8 @@ def create_workspace(type_: str, name: str, target_dir: Path) -> None: workspace_root.mkdir(parents=True, exist_ok=True) # Package directory inside the workspace - pkg_dir = workspace_root / normalize(name) + pkg_name = normalize(name) + pkg_dir = workspace_root / pkg_name # Fail if the package directory already exists if pkg_dir.exists(): @@ -56,8 +83,7 @@ def create_workspace(type_: str, name: str, target_dir: Path) -> None: target_code_file = pkg_dir / f"{name}.py" if not template_file.exists(): - print(f"WARNING: Template not found: {template_file}. Creating empty file.", - file=sys.stderr) + print(f"WARNING: Template not found: {template_file}. Creating empty file.", file=sys.stderr) target_code_file.touch() else: template_content = template_file.read_text() @@ -74,6 +100,8 @@ def create_workspace(type_: str, name: str, target_dir: Path) -> None: # Make the package importable (pkg_dir / "__init__.py").touch() + create_tests(type_=type_, name=name, workspace_root=workspace_root, pkg_name=pkg_name) + # Copy meta/root files for file_name in META_FILES: src = PROJECT_ROOT / file_name diff --git a/src/tools/workspace/templates/test_CustomDetector.py b/src/tools/workspace/templates/test_CustomDetector.py new file mode 100644 index 0000000..2279f75 --- /dev/null +++ b/src/tools/workspace/templates/test_CustomDetector.py @@ -0,0 +1,38 @@ +from detectmatelibrary import schemas + +from custom_component.custom_module import CustomDetector, CustomDetectorConfig + + +default_args = { + "detectors": { + "CustomDetector": { + "method_type": "custom_detector", + "auto_config": False, + "params": {}, + } + } +} + + +class TestCustomDetector: + def test_initialize_default(self) -> None: + detector = CustomDetector(name="CustomDetector", config=default_args) + + assert isinstance(detector, CustomDetector) + assert detector.name == "CustomDetector" + assert isinstance(detector.config, CustomDetectorConfig) + + def test_run_detect_method(self) -> None: + detector = CustomDetector() + data = schemas.ParserSchema({"log": "test log"}) + output = schemas.DetectorSchema() + + result = detector.detect(data, output) + + assert output.description == "Dummy detection process" + if result: + assert output.score == 1.0 + assert "Anomaly detected by CustomDetector" in output.alertsObtain["type"] + else: + assert output.score == 0.0 + assert len(output.alertsObtain) == 0 diff --git a/src/tools/workspace/templates/test_CustomParser.py b/src/tools/workspace/templates/test_CustomParser.py new file mode 100644 index 0000000..be0be45 --- /dev/null +++ b/src/tools/workspace/templates/test_CustomParser.py @@ -0,0 +1,33 @@ +from detectmatelibrary import schemas + +from custom_component.custom_module import CustomParser, CustomParserConfig + + +default_args = { + "parsers": { + "CustomParser": { + "auto_config": False, + "method_type": "custom_parser", + "params": {}, + } + } +} + + +class TestCustomParser: + def test_initialize_default(self) -> None: + parser = CustomParser(name="CustomParser", config=default_args) + + assert isinstance(parser, CustomParser) + assert parser.name == "CustomParser" + assert isinstance(parser.config, CustomParserConfig) + + def test_run_parse_method(self) -> None: + parser = CustomParser() + input_data = schemas.LogSchema({"log": "test log"}) + output_data = schemas.ParserSchema() + + parser.parse(input_data, output_data) + + assert output_data.variables == ["dummy_variable"] + assert output_data.template == "This is a dummy template" diff --git a/src/tools/workspace/utils.py b/src/tools/workspace/utils.py index 914a1c1..d25e501 100644 --- a/src/tools/workspace/utils.py +++ b/src/tools/workspace/utils.py @@ -177,8 +177,9 @@ def create_pyproject(name: str, ws_type: str, target_dir: Path) -> None: # Add dependencies in this section with: uv add --optional dev # Install with all the dev dependencies: uv pip install -e .[dev] dev = [ - "detectmateservice @ git+https://github.com/ait-detectmate/DetectMateService.git + "detectmateservice @ git+https://github.com/ait-detectmate/DetectMateService.git", "prek>=0.2.8", + "pytest>=8.4.2", ] [build-system] From bdaa3fe175593cc0673d71e3ca3e17bbe3836d9e Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Thu, 4 Dec 2025 14:20:17 +0100 Subject: [PATCH 16/21] Add detectmatelibrary as workspace dependency --- src/tools/workspace/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tools/workspace/utils.py b/src/tools/workspace/utils.py index d25e501..93d8c2c 100644 --- a/src/tools/workspace/utils.py +++ b/src/tools/workspace/utils.py @@ -171,6 +171,7 @@ def create_pyproject(name: str, ws_type: str, target_dir: Path) -> None: # Add the libraries your workspace needs below dependencies = [ + "detectmatelibrary @ git+https://github.com/ait-detectmate/DetectMateLibrary.git", ] [project.optional-dependencies] From 53805fbac0ffa6cfbda8a83904c09d3e63d05204 Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Thu, 4 Dec 2025 14:58:46 +0100 Subject: [PATCH 17/21] Make the template tests run from both library and generated workspace --- src/tools/workspace/create_workspace.py | 29 ++++++++++++++----- src/tools/workspace/templates/__init__.py | 0 .../templates/test_templates/__init__.py | 0 .../test_CustomDetector.py | 9 +++--- .../{ => test_templates}/test_CustomParser.py | 7 ++--- tests/test_workspace/test_create_workspace.py | 15 +++++++--- 6 files changed, 39 insertions(+), 21 deletions(-) create mode 100644 src/tools/workspace/templates/__init__.py create mode 100644 src/tools/workspace/templates/test_templates/__init__.py rename src/tools/workspace/templates/{ => test_templates}/test_CustomDetector.py (81%) rename src/tools/workspace/templates/{ => test_templates}/test_CustomParser.py (86%) diff --git a/src/tools/workspace/create_workspace.py b/src/tools/workspace/create_workspace.py index 5796da4..b7682d1 100644 --- a/src/tools/workspace/create_workspace.py +++ b/src/tools/workspace/create_workspace.py @@ -29,11 +29,16 @@ def camelize(name: str) -> str: def create_tests(type_: str, name: str, workspace_root: Path, pkg_name: str) -> None: - """Create a tests/ directory with a basic pytest file for the component.""" + """Create a tests/ directory with a basic pytest file for the component. + + - Reads template tests from src/tools/workspace/templates/test_templates/ + - Rewrites the import to point to . + - Renames CustomParser/CustomDetector to the camelized class name + """ tests_dir = workspace_root / "tests" tests_dir.mkdir(parents=True, exist_ok=True) - template_file = TEMPLATE_DIR / f"test_Custom{type_.capitalize()}.py" + template_file = TEMPLATE_DIR / "test_templates" / f"test_Custom{type_.capitalize()}.py" test_file = tests_dir / f"test_{name}.py" if not template_file.exists(): @@ -42,13 +47,21 @@ def create_tests(type_: str, name: str, workspace_root: Path, pkg_name: str) -> return template_content = template_file.read_text() - original_class = f"Custom{type_.capitalize()}" + base_class = f"Custom{type_.capitalize()}" # base names in the template (CustomParser/CustomDetector) + # The exact import line in the template, e.g: + # from ..CustomParser import CustomParser, CustomParserConfig + # from ..CustomDetector import CustomDetector, CustomDetectorConfig + original_import = f"from ..{base_class} import {base_class}, {base_class}Config" new_class = camelize(name) - - # Replace placeholders - content = template_content.replace(original_class, new_class) # CustomParser/Detector -> actual class - content = content.replace("custom_component", pkg_name) # custom_component -> package directory - content = content.replace("custom_module", name) # custom_module -> module (file) name + # new import line for the generated workspace: + # from . import , Config + new_import = f"from {pkg_name}.{name} import {new_class}, {new_class}Config" + # replace the import line + content = template_content.replace(original_import, new_import) + # replace the remaining occurrences of CustomParser/CustomDetector + # with the new class name (inside the tests) + content = content.replace(base_class, new_class) + content = content.rstrip() + "\n" test_file.write_text(content) print(f"- Added tests file: {test_file}") diff --git a/src/tools/workspace/templates/__init__.py b/src/tools/workspace/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tools/workspace/templates/test_templates/__init__.py b/src/tools/workspace/templates/test_templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tools/workspace/templates/test_CustomDetector.py b/src/tools/workspace/templates/test_templates/test_CustomDetector.py similarity index 81% rename from src/tools/workspace/templates/test_CustomDetector.py rename to src/tools/workspace/templates/test_templates/test_CustomDetector.py index 2279f75..a0e6414 100644 --- a/src/tools/workspace/templates/test_CustomDetector.py +++ b/src/tools/workspace/templates/test_templates/test_CustomDetector.py @@ -1,6 +1,6 @@ +from typing import Any from detectmatelibrary import schemas - -from custom_component.custom_module import CustomDetector, CustomDetectorConfig +from ..CustomDetector import CustomDetector, CustomDetectorConfig default_args = { @@ -17,7 +17,6 @@ class TestCustomDetector: def test_initialize_default(self) -> None: detector = CustomDetector(name="CustomDetector", config=default_args) - assert isinstance(detector, CustomDetector) assert detector.name == "CustomDetector" assert isinstance(detector.config, CustomDetectorConfig) @@ -25,14 +24,14 @@ def test_initialize_default(self) -> None: def test_run_detect_method(self) -> None: detector = CustomDetector() data = schemas.ParserSchema({"log": "test log"}) - output = schemas.DetectorSchema() + output: Any = schemas.DetectorSchema() result = detector.detect(data, output) assert output.description == "Dummy detection process" if result: assert output.score == 1.0 - assert "Anomaly detected by CustomDetector" in output.alertsObtain["type"] + assert "Anomaly detected" in output.alertsObtain["type"] else: assert output.score == 0.0 assert len(output.alertsObtain) == 0 diff --git a/src/tools/workspace/templates/test_CustomParser.py b/src/tools/workspace/templates/test_templates/test_CustomParser.py similarity index 86% rename from src/tools/workspace/templates/test_CustomParser.py rename to src/tools/workspace/templates/test_templates/test_CustomParser.py index be0be45..855cd6d 100644 --- a/src/tools/workspace/templates/test_CustomParser.py +++ b/src/tools/workspace/templates/test_templates/test_CustomParser.py @@ -1,6 +1,6 @@ +from typing import Any from detectmatelibrary import schemas - -from custom_component.custom_module import CustomParser, CustomParserConfig +from ..CustomParser import CustomParser, CustomParserConfig default_args = { @@ -17,7 +17,6 @@ class TestCustomParser: def test_initialize_default(self) -> None: parser = CustomParser(name="CustomParser", config=default_args) - assert isinstance(parser, CustomParser) assert parser.name == "CustomParser" assert isinstance(parser.config, CustomParserConfig) @@ -25,7 +24,7 @@ def test_initialize_default(self) -> None: def test_run_parse_method(self) -> None: parser = CustomParser() input_data = schemas.LogSchema({"log": "test log"}) - output_data = schemas.ParserSchema() + output_data: Any = schemas.ParserSchema() parser.parse(input_data, output_data) diff --git a/tests/test_workspace/test_create_workspace.py b/tests/test_workspace/test_create_workspace.py index 724a800..011fd93 100644 --- a/tests/test_workspace/test_create_workspace.py +++ b/tests/test_workspace/test_create_workspace.py @@ -1,20 +1,22 @@ import subprocess import pytest +from pathlib import Path # Path to the CLI entry point CLI = ["mate"] # installed as console script @pytest.fixture -def temp_dir(tmp_path): +def temp_dir(tmp_path: Path) -> Path: # Creates an isolated directory for each test (workspace root) return tmp_path -def test_create_parser_workspace(temp_dir): +def test_create_parser_workspace(temp_dir: Path): ws_name = "myParser" workspace_root = temp_dir pkg_dir = workspace_root / "myparser" + tests_dir = workspace_root / "tests" # Run the CLI tool subprocess.check_call([ @@ -42,12 +44,15 @@ def test_create_parser_workspace(temp_dir): assert len(py_files) == 2 # __init__.py + myParser.py assert (pkg_dir / f"{ws_name}.py").exists() assert (pkg_dir / "__init__.py").exists() + assert tests_dir.exists() + assert (tests_dir / f"test_{ws_name}.py").exists() -def test_create_detector_workspace(temp_dir): +def test_create_detector_workspace(temp_dir: Path): ws_name = "myDetector" workspace_root = temp_dir pkg_dir = workspace_root / "mydetector" + tests_dir = workspace_root / "tests" subprocess.check_call([ *CLI, @@ -69,9 +74,11 @@ def test_create_detector_workspace(temp_dir): assert len(py_files) == 2 # __init__.py + myDetector.py assert (pkg_dir / f"{ws_name}.py").exists() assert (pkg_dir / "__init__.py").exists() + assert tests_dir.exists() + assert (tests_dir / f"test_{ws_name}.py").exists() -def test_fail_if_dir_exists(temp_dir): +def test_fail_if_dir_exists(temp_dir: Path): ws_name = "existing" workspace_root = temp_dir pkg_dir = workspace_root / ws_name From f4dfe0019995e0af3b9c33b58d44a3e151c7dfa9 Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Thu, 4 Dec 2025 15:29:56 +0100 Subject: [PATCH 18/21] Add tests to ensure that the generated workspace tests pass --- tests/test_workspace/test_create_workspace.py | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/test_workspace/test_create_workspace.py b/tests/test_workspace/test_create_workspace.py index 011fd93..6c64b99 100644 --- a/tests/test_workspace/test_create_workspace.py +++ b/tests/test_workspace/test_create_workspace.py @@ -1,3 +1,5 @@ +import os +import sys import subprocess import pytest from pathlib import Path @@ -97,3 +99,72 @@ def test_fail_if_dir_exists(temp_dir: Path): assert result.returncode != 0 assert "already exists" in result.stderr + + +def test_generated_detector_tests_pass(temp_dir: Path): + """Run pytest inside the generated workspace on the generated detector test + file.""" + + ws_name = "MyCoolThing" + workspace_root = temp_dir + pkg_dir = workspace_root / "mycoolthing" + tests_dir = workspace_root / "tests" + test_file = tests_dir / f"test_{ws_name}.py" + + subprocess.check_call([ + *CLI, + "create", + "--type", "detector", + "--name", ws_name, + "--dir", str(workspace_root), + ]) + + assert workspace_root.exists() + assert pkg_dir.exists() + assert tests_dir.exists() + assert test_file.exists() + + # run pytest on the generated test file + # and make sure the workspace root is on sys.path so "import mycoolthing" works + old_cwd = os.getcwd() + old_sys_path = list(sys.path) + try: + os.chdir(workspace_root) + sys.path.insert(0, str(workspace_root)) + result = pytest.main(["-q", str(test_file.relative_to(workspace_root))]) + assert result == 0 + finally: + os.chdir(old_cwd) + sys.path[:] = old_sys_path + + +def test_generated_parser_tests_pass(temp_dir: Path): + ws_name = "MyCoolParser" + workspace_root = temp_dir + pkg_dir = workspace_root / "mycoolparser" + tests_dir = workspace_root / "tests" + test_file = tests_dir / f"test_{ws_name}.py" + + subprocess.check_call([ + *CLI, + "create", + "--type", "parser", + "--name", ws_name, + "--dir", str(workspace_root), + ]) + + assert workspace_root.exists() + assert pkg_dir.exists() + assert tests_dir.exists() + assert test_file.exists() + + old_cwd = os.getcwd() + old_sys_path = list(sys.path) + try: + os.chdir(workspace_root) + sys.path.insert(0, str(workspace_root)) + result = pytest.main(["-q", str(test_file.relative_to(workspace_root))]) + assert result == 0 + finally: + os.chdir(old_cwd) + sys.path[:] = old_sys_path From c5492731c5f6dc46919f41dd2d944d0d2dfb4938 Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Thu, 4 Dec 2025 15:41:56 +0100 Subject: [PATCH 19/21] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 4c24fe2..ac8ae4f 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ workspaces/custom_parser/ # workspace root ├── custom_parser/ # Python package │ ├── __init__.py │ └── custom_parser.py # CoreParser-based template +├── tests/ +│ └── test_custom_parser.py # generated from template to test custom_parser ├── LICENSE.md # copied from main project ├── .gitignore # copied from main project ├── .pre-commit-config.yaml # copied from main project From e2b08eb7ed6e6c3cd904e062237d092001038145 Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Fri, 5 Dec 2025 13:12:38 +0100 Subject: [PATCH 20/21] Update generated README.md --- .../workspace/templates/CustomDetector.py | 2 +- src/tools/workspace/utils.py | 44 +++++++------------ 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/src/tools/workspace/templates/CustomDetector.py b/src/tools/workspace/templates/CustomDetector.py index 65d3a5d..6eb0111 100644 --- a/src/tools/workspace/templates/CustomDetector.py +++ b/src/tools/workspace/templates/CustomDetector.py @@ -51,6 +51,6 @@ def detect( result = pattern[self._call_count % len(pattern)] if result: output_["score"] = 1.0 # Score of the detector - output_["alertsObtain"]["type"] = "Anomaly detected by CustomDetector" # Aditional info + output_["alertsObtain"]["type"] = "Anomaly detected by CustomDetector" # Additional info return result diff --git a/src/tools/workspace/utils.py b/src/tools/workspace/utils.py index 93d8c2c..7a4ba0b 100644 --- a/src/tools/workspace/utils.py +++ b/src/tools/workspace/utils.py @@ -24,22 +24,17 @@ def create_readme(name: str, ws_type: str, target_impl: Path, target_dir: Path) f""" # {name} - This is a generated **{ws_type}** workspace created with: - - ```bash - mate create --type {ws_type} --name {name} --dir {target_dir.parent} - ``` - + This is an auto-generated workspace for implementing your custom {ws_type}. The directory containing this `README.md` is referred to as the *workspace root* below. ## Contents - - `{target_impl.name}`: starting point for your `{ws_type}` implementation. - - `LICENSE.md`: copied from the main project. - - `.pre-commit-config.yaml`: pre-commit hook configuration from the main project. - - `.gitignore`: standard ignore rules from the main project. + - `{normalize(name)}/{target_impl.name}`: starting point for your `{ws_type}` implementation. + - `tests/test_{target_impl.name}`: unit tests for your `{ws_type}`. + - `LICENSE.md`: EUPL-1.2 license copied from the main project. + - `.pre-commit-config.yaml`: recommended pre-commit hook configuration. + - `.gitignore`: standard ignore rules. - `pyproject.toml`: Python project metadata, dependencies, and dev extras. - - `__init__.py`: makes this directory importable as a package. ## Recommended setup (uv + prek) @@ -60,22 +55,16 @@ def create_readme(name: str, ws_type: str, target_impl: Path, target_dir: Path) # Activate it source .venv/bin/activate # Linux/macOS - # .venv\\Scripts\\activate # Windows (PowerShell / CMD) + # .venv\\Scripts\\activate # Windows ``` - ### 2. Install the project and dev dependencies (including prek) + ### 2. Install the project and dev dependencies ```bash uv pip install -e .[dev] ``` - To add new dev-only dependencies later: - - ```bash - uv add --optional dev - ``` - - ### 3. Install and run Git hooks with prek + ### 3. Install and run Git hooks with prek (optional but recommended) With the virtual environment activated: @@ -83,8 +72,8 @@ def create_readme(name: str, ws_type: str, target_impl: Path, target_dir: Path) # Install Git hooks from .pre-commit-config.yaml using prek prek install - # (Optional but recommended) Run all hooks once on the full codebase - prek run --all-files + # You can run all hooks on the full codebase with: + # prek run --all-files ``` After this, hooks will run automatically on each commit. @@ -101,19 +90,14 @@ def create_readme(name: str, ws_type: str, target_impl: Path, target_dir: Path) # Activate it source .venv/bin/activate # Linux/macOS - # .venv\\Scripts\\activate # Windows (PowerShell / CMD) + # .venv\\Scripts\\activate # Windows # Install the project in editable mode with dev dependencies pip install -e .[dev] ``` With `pip`, `prek` will still be available from the virtual environment, - and you can use the same commands to install hooks: - - ```bash - prek install - prek run --all-files - ``` + and you can use the same `prek install` command to install hooks. ## Next steps @@ -143,6 +127,8 @@ def create_readme(name: str, ws_type: str, target_impl: Path, target_dir: Path) detectmate start --settings service_settings.yaml ``` + For more info about DetectMate Service, see https://github.com/ait-detectmate/DetectMateService. + Make sure you run this command from within the virtual environment where you installed this workspace (e.g. after `uv venv && source .venv/bin/activate`). """ From 6ad08a25db0452d0f8454f8473d7f88b29ddb67d Mon Sep 17 00:00:00 2001 From: Anna Erdi Date: Fri, 5 Dec 2025 14:35:22 +0100 Subject: [PATCH 21/21] Use a safe package name function --- src/tools/workspace/create_workspace.py | 9 ++-- src/tools/workspace/utils.py | 17 ++++++-- tests/test_workspace/test_create_workspace.py | 43 +++++++++++++++++-- tests/test_workspace/test_utils.py | 21 +++++++++ 4 files changed, 78 insertions(+), 12 deletions(-) create mode 100644 tests/test_workspace/test_utils.py diff --git a/src/tools/workspace/create_workspace.py b/src/tools/workspace/create_workspace.py index b7682d1..eb582c1 100644 --- a/src/tools/workspace/create_workspace.py +++ b/src/tools/workspace/create_workspace.py @@ -3,7 +3,7 @@ import sys from pathlib import Path -from .utils import create_readme, create_pyproject, normalize +from .utils import create_readme, create_pyproject, normalize_package_name # resolve paths relative to this file BASE_DIR = Path(__file__).resolve().parent.parent # tools/ @@ -55,7 +55,7 @@ def create_tests(type_: str, name: str, workspace_root: Path, pkg_name: str) -> new_class = camelize(name) # new import line for the generated workspace: # from . import , Config - new_import = f"from {pkg_name}.{name} import {new_class}, {new_class}Config" + new_import = f"from {pkg_name}.{normalize_package_name(name)} import {new_class}, {new_class}Config" # replace the import line content = template_content.replace(original_import, new_import) # replace the remaining occurrences of CustomParser/CustomDetector @@ -80,7 +80,7 @@ def create_workspace(type_: str, name: str, target_dir: Path) -> None: workspace_root.mkdir(parents=True, exist_ok=True) # Package directory inside the workspace - pkg_name = normalize(name) + pkg_name = normalize_package_name(name) pkg_dir = workspace_root / pkg_name # Fail if the package directory already exists @@ -93,7 +93,8 @@ def create_workspace(type_: str, name: str, target_dir: Path) -> None: # Template selection template_file = TEMPLATE_DIR / f"Custom{type_.capitalize()}.py" - target_code_file = pkg_dir / f"{name}.py" + module_name = normalize_package_name(name) + target_code_file = pkg_dir / f"{module_name}.py" if not template_file.exists(): print(f"WARNING: Template not found: {template_file}. Creating empty file.", file=sys.stderr) diff --git a/src/tools/workspace/utils.py b/src/tools/workspace/utils.py index 7a4ba0b..516ee66 100644 --- a/src/tools/workspace/utils.py +++ b/src/tools/workspace/utils.py @@ -8,6 +8,15 @@ def normalize(name: str) -> str: return re.sub(r"[-_.]+", "-", name).lower() +def normalize_package_name(name: str) -> str: + """Normalize to a valid Python package name: lowercase, underscores only.""" + name = name.lower() + name = re.sub(r"[^a-z0-9]+", "_", name) # replace non-alphanumerics w/ _ + name = re.sub(r"_+", "_", name) # collapse repeated _ + name = name.strip("_") + return name + + def create_readme(name: str, ws_type: str, target_impl: Path, target_dir: Path) -> None: """Create a README.md file for the generated workspace. @@ -144,12 +153,12 @@ def create_pyproject(name: str, ws_type: str, target_dir: Path) -> None: - Leaves a dependencies list ready for you to fill with the necessary libraries. """ - project_name = normalize(name) + package_name = normalize_package_name(name) pyproject_text = textwrap.dedent( f""" [project] - name = "{project_name}" + name = "{normalize(name)}" version = "0.1.0" description = "Generated {ws_type} workspace '{name}'" readme = "README.md" @@ -174,8 +183,8 @@ def create_pyproject(name: str, ws_type: str, target_dir: Path) -> None: build-backend = "setuptools.build_meta" [tool.setuptools] - # Treat the '{project_name}' directory as the package - packages = ["{project_name}"] + # Treat the '{package_name}' directory as the package + packages = ["{package_name}"] """ ).strip() + "\n" diff --git a/tests/test_workspace/test_create_workspace.py b/tests/test_workspace/test_create_workspace.py index 6c64b99..ddca99f 100644 --- a/tests/test_workspace/test_create_workspace.py +++ b/tests/test_workspace/test_create_workspace.py @@ -3,6 +3,8 @@ import subprocess import pytest from pathlib import Path +from tools.workspace.utils import normalize_package_name + # Path to the CLI entry point CLI = ["mate"] # installed as console script @@ -17,7 +19,9 @@ def temp_dir(tmp_path: Path) -> Path: def test_create_parser_workspace(temp_dir: Path): ws_name = "myParser" workspace_root = temp_dir - pkg_dir = workspace_root / "myparser" + pkg_name = normalize_package_name(ws_name) # myparser + module_name = normalize_package_name(ws_name) # myparser + pkg_dir = workspace_root / pkg_name tests_dir = workspace_root / "tests" # Run the CLI tool @@ -44,7 +48,7 @@ def test_create_parser_workspace(temp_dir: Path): # Python files live in package directory py_files = list(pkg_dir.glob("*.py")) assert len(py_files) == 2 # __init__.py + myParser.py - assert (pkg_dir / f"{ws_name}.py").exists() + assert (pkg_dir / f"{module_name}.py").exists() assert (pkg_dir / "__init__.py").exists() assert tests_dir.exists() assert (tests_dir / f"test_{ws_name}.py").exists() @@ -53,7 +57,9 @@ def test_create_parser_workspace(temp_dir: Path): def test_create_detector_workspace(temp_dir: Path): ws_name = "myDetector" workspace_root = temp_dir - pkg_dir = workspace_root / "mydetector" + pkg_name = normalize_package_name(ws_name) # mydetector + module_name = normalize_package_name(ws_name) # mydetector + pkg_dir = workspace_root / pkg_name tests_dir = workspace_root / "tests" subprocess.check_call([ @@ -74,12 +80,41 @@ def test_create_detector_workspace(temp_dir: Path): py_files = list(pkg_dir.glob("*.py")) assert len(py_files) == 2 # __init__.py + myDetector.py - assert (pkg_dir / f"{ws_name}.py").exists() + assert (pkg_dir / f"{module_name}.py").exists() assert (pkg_dir / "__init__.py").exists() assert tests_dir.exists() assert (tests_dir / f"test_{ws_name}.py").exists() +def test_create_workspace_with_dash_name(temp_dir: Path): + ws_name = "custom-parser" + workspace_root = temp_dir + pkg_name = normalize_package_name(ws_name) # custom_parser + module_name = normalize_package_name(ws_name) # custom_parser + pkg_dir = workspace_root / pkg_name + tests_dir = workspace_root / "tests" + test_file = tests_dir / f"test_{ws_name}.py" + + subprocess.check_call([ + *CLI, + "create", + "--type", "parser", + "--name", ws_name, + "--dir", str(workspace_root), + ]) + + assert workspace_root.exists() + assert pkg_dir.exists() + assert tests_dir.exists() + assert test_file.exists() + assert (pkg_dir / "__init__.py").exists() + assert (pkg_dir / f"{module_name}.py").exists() + + # check that the generated test imports use the normalized names + content = test_file.read_text() + assert f"from {pkg_name}.{module_name} import " in content + + def test_fail_if_dir_exists(temp_dir: Path): ws_name = "existing" workspace_root = temp_dir diff --git a/tests/test_workspace/test_utils.py b/tests/test_workspace/test_utils.py new file mode 100644 index 0000000..8804c3d --- /dev/null +++ b/tests/test_workspace/test_utils.py @@ -0,0 +1,21 @@ +import pytest +from tools.workspace.utils import normalize_package_name + + +@pytest.mark.parametrize("input_name, expected", [ + ("custom-parser", "custom_parser"), + ("custom.parser", "custom_parser"), + ("CUSTOM__Thing", "custom_thing"), + ("a--b..c", "a_b_c"), +]) +def test_normalize_package_name_basic_cases(input_name: str, expected: str) -> None: + assert normalize_package_name(input_name) == expected + + +@pytest.mark.parametrize("input_name", [ + "_leading", "trailing_", "--both--", "...", "__" +]) +def test_normalize_package_name_strips_outer_underscores(input_name: str) -> None: + result = normalize_package_name(input_name) + assert not result.startswith("_") + assert not result.endswith("_")