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 + )