diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml
index 40cfef8d..44c9dc91 100644
--- a/.github/workflows/lint-and-test.yml
+++ b/.github/workflows/lint-and-test.yml
@@ -21,8 +21,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
- pip install pre-commit
- pip install -e . -r requirements.txt -r requirements-test.txt
+ python -m pip install -e . -r requirements.txt -r requirements-test.txt
cd frontend && npm install
- name: Lint with pre-commit
run: |
@@ -34,7 +33,7 @@ jobs:
cp -a dist/. ../src/mandr/dashboard/static
- name: Test with pytest
run: |
- pytest --cov
+ python -m pytest tests
- name: Test with vitest
run: |
cd frontend
diff --git a/pyproject.toml b/pyproject.toml
index 3ac476f4..c3b6b8a0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,7 +22,13 @@ dependencies = [
]
[project.optional-dependencies]
-test = ["pytest", "pytest-cov", "pre-commit", "ruff"]
+test = [
+ "pre-commit",
+ "pytest",
+ "pytest-cov",
+ "pytest-randomly",
+ "ruff"
+]
[tool.hatch.build.targets.wheel]
packages = ["src/mandr"]
@@ -31,7 +37,14 @@ packages = ["src/mandr"]
upgrade = true
[tool.pytest.ini_options]
-addopts = ["--import-mode=importlib"]
+addopts = [
+ "--cov=src/",
+ "--cov=tests/",
+ "--cov-branch",
+ "--import-mode=importlib",
+ "--no-header",
+ "--verbosity=2",
+]
[tool.ruff]
src = ["mandr"]
diff --git a/src/mandr/__main__.py b/src/mandr/__main__.py
deleted file mode 100644
index 772c2a8d..00000000
--- a/src/mandr/__main__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""Main entry point for the Mandr web application."""
-
-from .app import app
-
-if __name__ == "__main__":
- app.run()
diff --git a/src/mandr/app.py b/src/mandr/app.py
deleted file mode 100644
index 9146c129..00000000
--- a/src/mandr/app.py
+++ /dev/null
@@ -1,157 +0,0 @@
-"""The webapp that can render the InfoMander objects."""
-
-import json
-import logging
-from pathlib import Path
-
-from flask import Flask, Response, request
-from jinja2 import Template
-
-from .infomander import ARTIFACTS_KEY, LOGS_KEY, VIEWS_KEY, InfoMander
-from .templates import TemplateRenderer
-
-app = Flask(__name__)
-logger = logging.getLogger(__name__)
-
-
-def fetch_mander(*path):
- """Give a path as *args and return the mander at the given path."""
- return InfoMander("/".join(path))
-
-
-def render_views(*path):
- """Render the views attached to the mander for the given path."""
- mander = fetch_mander(*path)
- view_nav_templ = read_template("partials/views.html")
- first_name = None
- if mander[VIEWS_KEY]:
- first_name = list(mander[VIEWS_KEY].items())[0][0]
- return view_nav_templ.render(
- views=list(mander[VIEWS_KEY].items()), first_name=first_name
- )
-
-
-def render_info(*path):
- """Render the info attached to the mander for the given path."""
- mander = fetch_mander(*path)
- return (
- '
'
- + json.dumps(
- {k: str(v) for k, v in mander.fetch().items() if not k.startswith("_")},
- indent=2,
- )
- + "
"
- )
-
-
-def render_logs(*path):
- """Render the logs attached to the mander for the given path."""
- mander = fetch_mander(*path)
- view_nav_templ = read_template("partials/logs.html")
- return view_nav_templ.render(
- logs=list(mander[LOGS_KEY].items()),
- first_name=list(mander[LOGS_KEY].items())[0][0],
- )
-
-
-def render_artifacts(*path):
- """Render the artifacts attached to the mander for the given path."""
- mander = fetch_mander(*path)
- view_nav_templ = read_template("partials/artifacts.html")
- return view_nav_templ.render(artifacts=list(mander[ARTIFACTS_KEY].items()))
-
-
-def read_template(path):
- """Read a template from the templates directory."""
- p = Path(__file__).parent / "templates" / path
- return Template(p.read_text())
-
-
-def render_top_nav(*args):
- """Render the top navigation bar for the given path, which allows for navigation."""
- nav_temp = read_template("partials/nav-top.html")
- path_pairs = []
- for i, p in enumerate(args):
- path_pairs.append(["/" + "/".join(args[: i + 1]), p])
- curr_file_path = Path(".datamander")
- for arg in args:
- curr_file_path = curr_file_path / arg
- glob = Path(curr_file_path).glob("*")
- links_out = [
- str(g).replace(".datamander", "")
- for g in glob
- if g.is_dir() and not g.parts[-1].startswith("_")
- ]
- logger.debug(f"{links_out=}")
- logger.debug(f"{path_pairs=}")
- return nav_temp.render(path_pairs=path_pairs, links_out=links_out)
-
-
-def render_mid_nav(*args):
- """Render the content at a given path."""
- nav_temp = read_template("partials/nav-mid.html")
- return nav_temp.render(path="/".join(args))
-
-
-def render_mander(*args):
- """Render the interface for the mander mander at the given path."""
- p = Path(__file__).parent / "templates" / "page.html"
- t = Template(p.read_text())
- res = render_top_nav(*args)
- res += render_mid_nav(*args)
- return t.render(body=res)
-
-
-@app.route("/", defaults={"path": ""}, methods=["GET", "POST"])
-@app.route("/", methods=["GET", "POST"])
-def home(path):
- """
- Render the main route for the app.
-
- This route will render the mander and allows for navigation. Internally this route
- can also render all the partials.
-
- Parameters
- ----------
- path : str
- The path to render.
- """
- if "favicon" in path:
- return Response("", status=400)
- if len(path) == 0:
- return render_mander(*[])
- path_parts = path.split("/")
- logger.debug(f"{path_parts=}")
- if path_parts[0] == "info":
- return render_info(*path_parts[1:])
- if path_parts[0] == "view":
- return render_views(*path_parts[1:])
- if path_parts[0] == "logs":
- return render_logs(*path_parts[1:])
- if path_parts[0] == "artifacts":
- return render_artifacts(*path_parts[1:])
- if path_parts[0] == "sketchpad":
- return render_sketchpad(*path_parts[1:])
- if path_parts[0] == "render":
- return render_template(*path_parts[1:])
- return render_mander(*path.split("/"))
-
-
-def render_sketchpad(*path):
- """Render the sketchpad for templates."""
- mander = fetch_mander(*path)
- children = [f"{m.path}" for m in mander.children()]
- return read_template("sketchpad.html").render(
- children=sorted(children), mander_path=mander.path
- )
-
-
-def render_template(*path):
- """Render a template. Used for the sketchpad."""
- mander = fetch_mander(*path)
- template_rendered = TemplateRenderer(mander)
- return template_rendered.render(request.form["template"])
-
-
-if __name__ == "__main__":
- app.run(debug=True, reload=True)
diff --git a/src/mandr/dashboard/webapp.py b/src/mandr/dashboard/webapp.py
index 70bce5d9..edbd5e86 100644
--- a/src/mandr/dashboard/webapp.py
+++ b/src/mandr/dashboard/webapp.py
@@ -1,11 +1,12 @@
"""A FastAPI based webapp to serve a local dashboard."""
+import os
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.staticfiles import StaticFiles
-from mandr.infomander import ARTIFACTS_KEY, LOGS_KEY, VIEWS_KEY, InfoManderRepository
+from mandr import InfoMander
_DASHBOARD_PATH = Path(__file__).resolve().parent
_STATIC_PATH = _DASHBOARD_PATH / "static"
@@ -16,23 +17,43 @@
@app.get("/api/mandrs")
async def list_mandrs(request: Request) -> list[str]:
"""Send the list of mandrs path below the current working directory."""
- return [f"{p}" for p in InfoManderRepository.get_all_paths()]
+ path = os.environ["MANDR_PATH"]
+ root = Path(os.environ["MANDR_ROOT"])
+ ims = [InfoMander(path, root=root)]
+ paths = []
+ # Use `ims` as a queue to recursively iterate over children to retrieve path.
+ for im in ims:
+ ims[len(ims) :] = im.children()
+ absolute_path = im.project_path
+ relative_path = absolute_path.relative_to(root)
-@app.get("/api/mandrs/{mander_path:path}")
-async def get_mandr(request: Request, mander_path: str):
+ paths.append(str(relative_path))
+
+ return sorted(paths)
+
+
+@app.get("/api/mandrs/{path:path}")
+async def get_mandr(request: Request, path: str):
"""Return one mandr."""
- mander = InfoManderRepository.get(path=mander_path)
- if mander is None:
- raise HTTPException(status_code=404, detail=f"No mandr found in {mander_path}")
- serialized_mander = {
- "path": f"{mander.path}",
- "views": mander[VIEWS_KEY].items(),
- "logs": mander[LOGS_KEY].items(),
- "artifacts": mander[ARTIFACTS_KEY].items(),
- "info": {k: str(v) for k, v in mander.fetch().items() if not k.startswith("_")},
+ root = Path(os.environ["MANDR_ROOT"])
+
+ if not (root / path).exists():
+ raise HTTPException(status_code=404, detail=f"No mandr found in '{path}'")
+
+ im = InfoMander(path, root=root)
+
+ return {
+ "path": path,
+ "views": im[InfoMander.VIEWS_KEY].items(),
+ "logs": im[InfoMander.LOGS_KEY].items(),
+ "artifacts": im[InfoMander.ARTIFACTS_KEY].items(),
+ "info": {
+ key: str(value)
+ for key, value in im.fetch().items()
+ if key not in InfoMander.RESERVED_KEYS
+ },
}
- return serialized_mander
# as we mount / this line should be after all route declarations
diff --git a/src/mandr/infomander.py b/src/mandr/infomander.py
index 37154013..6b24a2d2 100644
--- a/src/mandr/infomander.py
+++ b/src/mandr/infomander.py
@@ -1,23 +1,13 @@
"""Contains the code for the main InfoMander class."""
-import os
+from datetime import UTC, datetime
from pathlib import Path
-from time import time
from diskcache import Cache
from joblib import dump
from .templates import TemplateRenderer
-LOGS_KEY = "logs"
-VIEWS_KEY = "views"
-TEMPLATES_KEY = "templates"
-ARTIFACTS_KEY = "artifacts"
-
-STATS_FOLDER = ".stats"
-ARTIFACTS_FOLDER = ".artifacts"
-LOGS_FOLDER = ".logs"
-
def _get_storage_path() -> Path:
"""Return a path to the local mander storage."""
@@ -27,32 +17,59 @@ def _get_storage_path() -> Path:
class InfoMander:
"""Represents a dictionary, on disk, with a path-like structure."""
- def __init__(self, path):
+ ARTIFACTS_KEY = "artifacts"
+ LOGS_KEY = "logs"
+ TEMPLATES_KEY = "templates"
+ VIEWS_KEY = "views"
+
+ RESERVED_KEYS = {
+ ARTIFACTS_KEY,
+ LOGS_KEY,
+ TEMPLATES_KEY,
+ VIEWS_KEY,
+ }
+
+ STATS_FOLDER = ".stats"
+ ARTIFACTS_FOLDER = ".artifacts"
+
+ RESERVED_FOLDERS = {
+ STATS_FOLDER,
+ ARTIFACTS_FOLDER,
+ }
+
+ def __init__(self, path: str, /, *, root: Path = None):
+ if Path(path).is_absolute():
+ raise ValueError("Cant use absolute path")
+
+ if root is None:
+ root = Path(".datamander").resolve()
+
# Set local disk paths
self.path = path
- self.project_path = _get_storage_path() / path
- self.cache = Cache(self.project_path / STATS_FOLDER)
+ self.root = root
+ self.project_path = self.root / path
+ self.cache = Cache(self.project_path / InfoMander.STATS_FOLDER)
- # For practical reasons the logs and artifacts are stored on disk, not sqlite
+ # For practical reasons the artifacts are stored on disk, not sqlite
# We could certainly revisit this later though
- self.artifact_path = self.project_path / ARTIFACTS_FOLDER
- self.log_path = self.project_path / LOGS_FOLDER
+ self.artifact_path = self.project_path / InfoMander.ARTIFACTS_FOLDER
# Initialize the internal cache with empty values if need be
- for key in [ARTIFACTS_KEY, TEMPLATES_KEY, VIEWS_KEY, LOGS_KEY]:
+ for key in InfoMander.RESERVED_KEYS:
if key not in self.cache:
self.cache[key] = {}
# This will be used for rendering templates into views
self.renderer = TemplateRenderer(self)
- def _check_key(self, key):
- if key in [ARTIFACTS_KEY, TEMPLATES_KEY, VIEWS_KEY, LOGS_KEY]:
+ @staticmethod
+ def _check_key(key):
+ if key in InfoMander.RESERVED_KEYS:
raise ValueError(
- f"Cannot overwrite {key} key. This is reserved for internal use."
+ f"Cannot overwrite '{key}' key. This is reserved for internal use."
)
- def add_info(self, key, value, method="overwrite"):
+ def add_info(self, key, value, overwrite=True):
"""
Add information to the cache. Can be appended or overwritten.
@@ -62,18 +79,19 @@ def add_info(self, key, value, method="overwrite"):
The key to add the information to.
value : any
The value to add.
- method : str
- The method to use. Can be 'overwrite' or 'append'.
+ overwrite : bool, default=True
+ Overwrite value, otherwise append.
"""
- if method == "overwrite":
+ if overwrite:
self.cache[key] = value
- if method == "append":
+ else:
if not isinstance(value, list):
value = [value]
if key in self.cache:
value = value + self.cache[key]
self.cache[key] = value
- self.cache["updated_at"] = int(time())
+
+ self.cache["updated_at"] = datetime.now(tz=UTC).isoformat()
def _add_to_key(self, top_key, key, value):
"""
@@ -117,7 +135,7 @@ def add_artifact(self, key, obj, **metadata):
if not file_location.parent.exists():
file_location.parent.mkdir(parents=True)
dump(obj, file_location)
- self._add_to_key(ARTIFACTS_KEY, key, {"obj": obj, **metadata})
+ self._add_to_key(InfoMander.ARTIFACTS_KEY, key, metadata)
def add_view(self, key, html):
"""
@@ -134,7 +152,7 @@ def add_view(self, key, html):
The HTML to store.
"""
self._check_key(key)
- self._add_to_key(VIEWS_KEY, key, html)
+ self._add_to_key(InfoMander.VIEWS_KEY, key, html)
def add_template(self, key, template):
"""
@@ -151,11 +169,13 @@ def add_template(self, key, template):
The template to store.
"""
self._check_key(key)
- if key in self.cache[VIEWS_KEY]:
+
+ if key in self.cache[InfoMander.VIEWS_KEY]:
raise ValueError(
- f"Cannot add template {key} because that name is already present."
+ f"Unable to add template '{key}': view rendered with this key name."
)
- self._add_to_key("_templates", key, template)
+
+ self._add_to_key(InfoMander.TEMPLATES_KEY, key, template)
def render_templates(self):
"""
@@ -168,14 +188,14 @@ def render_templates(self):
template : Template
The template to store.
"""
- for name, template in self.cache["_templates"].items():
+ for name, template in self.cache[InfoMander.TEMPLATES_KEY].items():
self.add_view(name, template.render(self))
def add_logs(self, key, logs):
"""
Add logs to the cache.
- Logs are stored separately in the cache and can be viewed in the web ui.
+ Logs are stored in the cache and can be viewed in the web ui.
Parameters
----------
@@ -184,7 +204,7 @@ def add_logs(self, key, logs):
logs : list
The logs to store.
"""
- self._add_to_key(LOGS_KEY, key, logs)
+ self._add_to_key(InfoMander.LOGS_KEY, key, logs)
def fetch(self):
"""Return all information from the cache."""
@@ -194,8 +214,8 @@ def __getitem__(self, key):
"""Return a specific item from the cache."""
return self.cache[key]
- @classmethod
- def get_property(cls, mander, dsl_str):
+ @staticmethod
+ def get_property(mander, dsl_str):
"""
Get a property from a mander using the DSL syntax.
@@ -236,29 +256,25 @@ def dsl_path_exists(self, path):
path : str
The path to check for.
"""
- actual_path = ".datamander" / path
- assert Path(actual_path).exists()
+ assert (self.root / path).exists()
- def get_child(self, *path):
+ def get_child(self, *pathsegments: str):
"""
Get a child mander from the current mander.
Parameters
----------
- path : str
- The path to the child mander
+ *pathsegments : str
+ The path to the child mander.
"""
- new_path = self.project_path
- for p in path:
- new_path = new_path / p
- return InfoMander(str(new_path))
+ return InfoMander(str(Path(*pathsegments)), root=self.project_path)
def children(self):
- """Return all children of the mander."""
+ """Return all direct children of the mander."""
return [
- InfoMander("/".join(p.parts[1:]))
+ InfoMander(str(p.relative_to(self.project_path)), root=self.project_path)
for p in self.project_path.iterdir()
- if p.is_dir() and not p.name.startswith(".")
+ if p.is_dir() and (p.stem not in InfoMander.RESERVED_FOLDERS)
]
def get(self, dsl_str):
@@ -285,65 +301,8 @@ def __repr__(self):
"""Return a string representation of the mander."""
return f"InfoMander({self.project_path})"
-
-class InfoManderRepository:
- """A repository to manage InfoMander objects."""
-
- @staticmethod
- def get_all_paths() -> list[Path]:
- """Return a list of all manders relative path below `root_path`.
-
- Parameters
- ----------
- root_path : str
- The rooth path in which the function will look for manders.
-
- Returns
- -------
- list[str]
- A list of mander path.
- """
- target_folders = [STATS_FOLDER, ARTIFACTS_FOLDER, LOGS_FOLDER]
- matching_paths = []
- storage_path = _get_storage_path()
-
- # get all matching folder as str
- # (multiple times if folder contains stats & artifacts for example)
- for root, folders, _ in os.walk(storage_path.resolve()):
- for folder in folders:
- root_str = f"{root}"
- if folder in target_folders and root_str not in matching_paths:
- matching_paths.append(root_str)
-
- # return as relative to `_get_storage_path` Path objects
- return sorted([Path(p).relative_to(storage_path) for p in matching_paths])
-
- @staticmethod
- def get(path: str) -> InfoMander | None:
- """Get an `InfoMander` by it's path.
-
- Parameters
- ----------
- path : str
- The path in which the function will look for a mander.
-
- Returns
- -------
- Infomander | None
- The InfoMander or None if nothing is found at this path.
- """
- mander_path = _get_storage_path() / path
- if mander_path.exists():
- sub_folder_names = [
- f"{p.relative_to(mander_path)}" for p in mander_path.iterdir()
- ]
- does_path_contain_mander_folder = any(
- [
- STATS_FOLDER in sub_folder_names,
- ARTIFACTS_FOLDER in sub_folder_names,
- LOGS_FOLDER in sub_folder_names,
- ]
- )
- if mander_path.is_dir() or not does_path_contain_mander_folder:
- return InfoMander(path)
- return None
+ def __eq__(self, other):
+ """Return True if both instances have the same project path."""
+ if isinstance(other, InfoMander):
+ return self.project_path == other.project_path
+ return False
diff --git a/tests/conftest.py b/tests/conftest.py
index f35d0311..e0706a21 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,22 +1,42 @@
-from pathlib import Path
+from datetime import UTC, datetime
import pytest
from fastapi.testclient import TestClient
+from mandr import InfoMander
from mandr.dashboard.webapp import app
@pytest.fixture
-def client() -> TestClient:
- """Build the test client."""
- return TestClient(app=app)
+def mock_now():
+ return datetime.now(tz=UTC)
+
+@pytest.fixture
+def mock_nowstr(mock_now):
+ return mock_now.isoformat()
+
+
+@pytest.fixture
+def mock_mandr(monkeypatch, mock_now, tmp_path):
+ class MockCache(dict):
+ def __init__(self, *args, **kwargs):
+ super().__init__()
-@pytest.fixture(autouse=True)
-def create_manders_in_tmp(monkeypatch, tmp_path):
- """Create all manders in a tmp path for test purposes."""
+ def iterkeys(self, *args, **kwargs):
+ yield from self.keys()
- def mocked_get_storage_path() -> Path:
- """Return a path to the local mander storage."""
- return Path(tmp_path)
+ class MockDatetime:
+ @staticmethod
+ def now(*args, **kwargs):
+ return mock_now
- monkeypatch.setattr("mandr.infomander._get_storage_path", mocked_get_storage_path)
+ monkeypatch.setattr("mandr.infomander.Cache", MockCache)
+ monkeypatch.setattr("mandr.infomander.datetime", MockDatetime)
+
+ return InfoMander("root", root=tmp_path)
+
+
+@pytest.fixture
+def client() -> TestClient:
+ """Build the test client."""
+ return TestClient(app=app)
diff --git a/tests/__init__.py b/tests/functional/.gitkeep
similarity index 100%
rename from tests/__init__.py
rename to tests/functional/.gitkeep
diff --git a/tests/integration/dashboard/test_webapp.py b/tests/integration/dashboard/test_webapp.py
index 7a0a9c41..ff89b86a 100644
--- a/tests/integration/dashboard/test_webapp.py
+++ b/tests/integration/dashboard/test_webapp.py
@@ -1,34 +1,68 @@
+import os
+
from fastapi.testclient import TestClient
from mandr.infomander import InfoMander
def test_index(client: TestClient):
response = client.get("/")
+
assert "html" in response.headers["Content-Type"]
assert response.status_code == 200
-def test_get_mandrs(client: TestClient):
- number_of_manders = 5
- for i in range(5):
- mander = InfoMander(f"probabl-ai/test-mandr/{i}")
- mander.add_info("hey", "ho")
+def test_list_mandrs(client: TestClient, tmp_path):
+ os.environ["MANDR_PATH"] = "root"
+ os.environ["MANDR_ROOT"] = str(tmp_path)
+
+ InfoMander("root", root=tmp_path).add_info("key", "value")
+ InfoMander("root/subroot1", root=tmp_path).add_info("key", "value")
+ InfoMander("root/subroot2", root=tmp_path).add_info("key", "value")
+ InfoMander("root/subroot2/subsubroot1", root=tmp_path).add_info("key", "value")
+ InfoMander("root/subroot2/subsubroot2", root=tmp_path).add_info("key", "value")
response = client.get("/api/mandrs")
- mander_paths = response.json()
- assert len(mander_paths) == number_of_manders
+
assert response.status_code == 200
+ assert response.json() == [
+ "root",
+ "root/subroot1",
+ "root/subroot2",
+ "root/subroot2/subsubroot1",
+ "root/subroot2/subsubroot2",
+ ]
+
+def test_get_mandr(monkeypatch, mock_now, mock_nowstr, client: TestClient, tmp_path):
+ class MockDatetime:
+ @staticmethod
+ def now(*args, **kwargs):
+ return mock_now
-def test_get_mandr(client: TestClient):
- mander_path = "probabl-ai/test-mandr/42"
- mander = InfoMander(mander_path)
- mander.add_info("hey", "ho let's go")
+ monkeypatch.setattr("mandr.infomander.datetime", MockDatetime)
+
+ os.environ["MANDR_ROOT"] = str(tmp_path)
+
+ InfoMander("root", root=tmp_path).add_info("key", None)
+ InfoMander("root/subroot1", root=tmp_path).add_info("key", None)
+ InfoMander("root/subroot2", root=tmp_path).add_info("key", None)
+ InfoMander("root/subroot2/subsubroot1", root=tmp_path).add_info("key", None)
+ InfoMander("root/subroot2/subsubroot2", root=tmp_path).add_info("key", "value")
+
+ response = client.get("/api/mandrs/root/subroot2/subsubroot2")
- response = client.get(f"/api/mandrs/{mander_path}")
- mander_json = response.json()
- assert mander_path in mander_json.get("path")
assert response.status_code == 200
+ assert response.json() == {
+ "path": "root/subroot2/subsubroot2",
+ "views": {},
+ "logs": {},
+ "artifacts": {},
+ "info": {
+ "key": "value",
+ "updated_at": mock_nowstr,
+ },
+ }
+
+ response = client.get("/api/mandrs/root/subroot2/subroot3")
- response = client.get("/api/mandrs/i/do/not/exists")
assert response.status_code == 404
diff --git a/tests/test_mander_dsl.py b/tests/test_mander_dsl.py
deleted file mode 100644
index 11d0eb71..00000000
--- a/tests/test_mander_dsl.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""
-The mander comes with a special DSL to fetch the right elements from the cache.
-This is tested here.
-"""
-
-
-def test_the_obvious():
- """Temporary test to ensure that CI runs it."""
- assert 2 + 2 == 4
diff --git a/tests/unit/test_infomander.py b/tests/unit/test_infomander.py
new file mode 100644
index 00000000..2fa7bb5d
--- /dev/null
+++ b/tests/unit/test_infomander.py
@@ -0,0 +1,191 @@
+from operator import attrgetter
+
+import joblib
+import pytest
+from mandr import InfoMander
+
+
+class TestInfoMander:
+ def test_check_key(self):
+ for key in InfoMander.RESERVED_KEYS:
+ with pytest.raises(ValueError):
+ InfoMander._check_key(key)
+
+ def test_add_info_overwrite_true(self, mock_nowstr, mock_mandr):
+ mock_mandr.add_info("key1", "value1", overwrite=True)
+ mock_mandr.add_info("key2", "value1", overwrite=True)
+ mock_mandr.add_info("key2", "value2", overwrite=True)
+
+ assert mock_mandr.cache == {
+ "artifacts": {},
+ "templates": {},
+ "views": {},
+ "logs": {},
+ "key1": "value1",
+ "key2": "value2",
+ "updated_at": mock_nowstr,
+ }
+
+ def test_add_info_overwrite_false(self, mock_nowstr, mock_mandr):
+ mock_mandr.add_info("key1", "value1", overwrite=False)
+ mock_mandr.add_info("key2", ["value1"], overwrite=False)
+ mock_mandr.add_info("key2", ["value2"], overwrite=False)
+ mock_mandr.add_info("key3", "value1", overwrite=False)
+ mock_mandr.add_info("key3", "value2", overwrite=False)
+
+ assert mock_mandr.cache == {
+ "artifacts": {},
+ "templates": {},
+ "views": {},
+ "logs": {},
+ "key1": ["value1"],
+ "key2": ["value2", "value1"],
+ "key3": ["value2", "value1"],
+ "updated_at": mock_nowstr,
+ }
+
+ def test_add_to_key(self, mock_nowstr, mock_mandr):
+ mock_mandr._add_to_key("artifacts", "key", "value")
+
+ assert mock_mandr.cache == {
+ "artifacts": {"key": "value"},
+ "templates": {},
+ "views": {},
+ "logs": {},
+ "updated_at": mock_nowstr,
+ }
+
+ def test_add_artifact(self, mock_now, mock_nowstr, mock_mandr, tmp_path):
+ mock_mandr.add_artifact("key", "value", datetime=mock_now)
+
+ assert joblib.load(tmp_path / "root" / ".artifacts" / "key.joblib") == "value"
+ assert mock_mandr.cache == {
+ "artifacts": {"key": {"datetime": mock_now}},
+ "templates": {},
+ "views": {},
+ "logs": {},
+ "updated_at": mock_nowstr,
+ }
+
+ def test_add_view(self, mock_nowstr, mock_mandr):
+ mock_mandr.add_view("key", "hydrated")
+
+ assert mock_mandr.cache == {
+ "artifacts": {},
+ "templates": {},
+ "views": {"key": "hydrated"},
+ "logs": {},
+ "updated_at": mock_nowstr,
+ }
+
+ def test_add_template(self, mock_nowstr, mock_mandr):
+ mock_mandr.add_template("key", "{{template}}")
+
+ assert mock_mandr.cache == {
+ "artifacts": {},
+ "templates": {"key": "{{template}}"},
+ "views": {},
+ "logs": {},
+ "updated_at": mock_nowstr,
+ }
+
+ def test_add_template_exception(self, mock_mandr):
+ mock_mandr.cache["views"] = {"key": "hydrated"}
+
+ with pytest.raises(ValueError):
+ mock_mandr.add_template("key", "{{template}}")
+
+ def test_render_templates(self, mock_nowstr, mock_mandr):
+ class MockTemplate:
+ def render(self, *args, **kwargs):
+ return "hydrated"
+
+ template = MockTemplate()
+ mock_mandr.cache["templates"] = {"key": template}
+ mock_mandr.render_templates()
+
+ assert mock_mandr.cache == {
+ "artifacts": {},
+ "templates": {"key": template},
+ "views": {"key": "hydrated"},
+ "logs": {},
+ "updated_at": mock_nowstr,
+ }
+
+ def test_add_logs(self, mock_nowstr, mock_mandr):
+ mock_mandr.add_logs("key", "value")
+
+ assert mock_mandr.cache == {
+ "artifacts": {},
+ "templates": {},
+ "views": {},
+ "logs": {"key": "value"},
+ "updated_at": mock_nowstr,
+ }
+
+ def test_fetch(self, mock_mandr):
+ assert mock_mandr.fetch() == {
+ "artifacts": {},
+ "templates": {},
+ "views": {},
+ "logs": {},
+ }
+
+ def test_getitem(self, mock_mandr):
+ assert mock_mandr["artifacts"] == {}
+
+ def test_dsl_path_exists(self, mock_mandr, tmp_path):
+ (tmp_path / "root2").mkdir(parents=True)
+ (tmp_path / "root3").mkdir(parents=True)
+
+ assert mock_mandr.dsl_path_exists("root2") is None
+ assert mock_mandr.dsl_path_exists("root3") is None
+
+ with pytest.raises(AssertionError):
+ mock_mandr.dsl_path_exists("root4")
+
+ def test_child(self, mock_mandr, tmp_path):
+ tmp_path /= "root"
+
+ (tmp_path).mkdir(parents=True)
+ (tmp_path / "subroot1").mkdir(parents=True)
+
+ assert mock_mandr.get_child("subroot1") == InfoMander("subroot1", root=tmp_path)
+ assert mock_mandr.get_child("subroot2") == InfoMander("subroot2", root=tmp_path)
+
+ def test_children(self, mock_mandr, tmp_path):
+ tmp_path /= "root"
+
+ (tmp_path).mkdir(parents=True)
+ (tmp_path / ".stats").mkdir(parents=True)
+ (tmp_path / ".artifacts").mkdir(parents=True)
+
+ assert mock_mandr.children() == []
+
+ (tmp_path / "subroot1").mkdir(parents=True)
+ (tmp_path / "subroot2").mkdir(parents=True)
+ (tmp_path / "subroot2" / ".stats").mkdir(parents=True)
+ (tmp_path / "subroot2" / ".artifacts").mkdir(parents=True)
+ (tmp_path / "subroot2" / "subsubroot1").mkdir(parents=True)
+ (tmp_path / "subroot2" / "subsubroot2").mkdir(parents=True)
+ (tmp_path / "subroot2" / "subsubroot2" / ".stats").mkdir(parents=True)
+ (tmp_path / "subroot2" / "subsubroot2" / ".artifacts").mkdir(parents=True)
+
+ assert sorted(mock_mandr.children(), key=attrgetter("project_path")) == [
+ InfoMander("subroot1", root=tmp_path),
+ InfoMander("subroot2", root=tmp_path),
+ ]
+
+ def test_repr(self, mock_mandr, tmp_path):
+ assert repr(mock_mandr) == f"InfoMander({tmp_path / 'root'})"
+
+ def test_eq(self, tmp_path):
+ IM = InfoMander
+
+ assert IM("root", root=tmp_path) == IM("root", root=tmp_path)
+ assert IM("root/subroot", root=tmp_path) == IM("root/subroot", root=tmp_path)
+ assert IM("root1", root=tmp_path) != IM("root2", root=tmp_path)
+ assert IM("root1/subroot", root=tmp_path) != IM("root2/subroot", root=tmp_path)
+ assert IM("root1/subroot1", root=tmp_path) != IM(
+ "root1/subroot2", root=tmp_path
+ )