diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f73f0dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +*.db + +# Virtual environments +.venv/ +venv/ +env/ + +# Environment variables +.env + +# Pytest +.pytest_cache/ + +# Logs +*.log + +# VS Code +.vscode/ + +# OS files +.DS_Store +Thumbs.db + +# Node (frontend) +node_modules/ +.next/ +dist/ +build/ + +# uv +.uv/ + +# Misc +*.sqlite3 \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/pipeline/__init__.py b/backend/app/pipeline/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/pipeline/utils.py b/backend/app/pipeline/utils.py new file mode 100644 index 0000000..791cc0d --- /dev/null +++ b/backend/app/pipeline/utils.py @@ -0,0 +1,36 @@ +from collections import defaultdict, deque + +def build_graph(pipeline): + graph = defaultdict(list) + in_degree = defaultdict(int) + + for node in pipeline["nodes"]: + node_id = node["id"] + graph[node_id] = [] + in_degree[node_id] = 0 + + for edge in pipeline["edges"]: + src = edge["source"] + dest = edge["target"] + if src not in graph or dest not in graph: + raise ValueError(f"Invalid edge: {src} -> {dest}") + graph[src].append(dest) + in_degree[dest] += 1 + + return graph, in_degree + +def topological_sort(pipeline): + graph, in_degree = build_graph(pipeline) + queue = deque([node for node in in_degree if in_degree[node] == 0]) + order = [] + + while queue: + current = queue.popleft() + order.append(current) + for neighbor in graph[current]: + in_degree[neighbor] -= 1 + if in_degree[neighbor] == 0: + queue.append(neighbor) + if len(order) != len(graph): + raise ValueError("Cycle detected in the pipeline") + return order \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4bab724..4d22a3f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,6 +9,12 @@ dependencies = [ "numpy>=2.4.4", "pandas>=3.0.2", "pydantic>=2.12.5", + "pytest>=9.0.2", "scikit-learn>=1.8.0", "uvicorn>=0.43.0", ] + +[dependency-groups] +dev = [ + "pytest>=9.0.2", +] diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_utils.py b/backend/tests/test_utils.py new file mode 100644 index 0000000..5a696cd --- /dev/null +++ b/backend/tests/test_utils.py @@ -0,0 +1,51 @@ +from app.pipeline.utils import build_graph, topological_sort +import pytest + + +def test_build_graph(): + pipeline = { + "nodes": [{"id": "A"}, {"id": "B"}], + "edges": [{"source": "A", "target": "B"}] + } + + graph, in_degree = build_graph(pipeline) + + assert dict(graph) == {"A": ["B"], "B": []} + assert dict(in_degree) == {"A": 0, "B": 1} + + +def test_topological_sort(): + pipeline = { + "nodes": [{"id": "A"}, {"id": "B"}, {"id": "C"}], + "edges": [ + {"source": "A", "target": "B"}, + {"source": "B", "target": "C"} + ] + } + + order = topological_sort(pipeline) + + assert order == ["A", "B", "C"] + + +def test_cycle_detection(): + pipeline = { + "nodes": [{"id": "A"}, {"id": "B"}], + "edges": [ + {"source": "A", "target": "B"}, + {"source": "B", "target": "A"} + ] + } + + with pytest.raises(ValueError): + topological_sort(pipeline) + + +def test_invalid_edge(): + pipeline = { + "nodes": [{"id": "A"}], + "edges": [{"source": "A", "target": "X"}] + } + + with pytest.raises(ValueError): + build_graph(pipeline) \ No newline at end of file diff --git a/backend/uv.lock b/backend/uv.lock index 274c5eb..d053327 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -46,20 +46,30 @@ dependencies = [ { name = "numpy" }, { name = "pandas" }, { name = "pydantic" }, + { name = "pytest" }, { name = "scikit-learn" }, { name = "uvicorn" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.135.3" }, { name = "numpy", specifier = ">=2.4.4" }, { name = "pandas", specifier = ">=3.0.2" }, { name = "pydantic", specifier = ">=2.12.5" }, + { name = "pytest", specifier = ">=9.0.2" }, { name = "scikit-learn", specifier = ">=1.8.0" }, { name = "uvicorn", specifier = ">=0.43.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.0.2" }] + [[package]] name = "click" version = "8.3.2" @@ -115,6 +125,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "joblib" version = "1.5.3" @@ -153,6 +172,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, ] +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + [[package]] name = "pandas" version = "3.0.2" @@ -182,6 +210,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -236,6 +273,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"