mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 16:13:23 +00:00
The backend import package is now docsgpt, the name it will carry on PyPI; application was far too generic to install into anyone's site-packages. git mv plus a mechanical rewrite of every import, dotted string and path reference: 734 Python files, the compose files, Dockerfile, workflows, docs, setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage config, .gitignore. Behaviour is unchanged. Kept for one release: - A top-level application package whose meta-path finder resolves application.x.y to the already-imported docsgpt.x.y object, so old imports and entry points (celery -A application.app.celery, uvicorn application.asgi:asgi_app) keep working with a FutureWarning. - Celery registers every application.* task name as an alias of its docsgpt.* task on start-up, so messages queued by the previous release still run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries the previous release wrote are left unread instead of firing twice. The backend image builds from the repository root (docker build -f docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore allow-lists docsgpt/ and application/ and keeps caches, local data, .env files, the sample index files and the Dockerfile out. Compose and the image workflows point at the new context.
174 lines
6.8 KiB
Python
174 lines
6.8 KiB
Python
"""Unit tests for artifacts_capture scratch/outputs filtering."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
from docsgpt.sandbox import artifacts_capture as ac
|
|
from docsgpt.sandbox.artifacts_capture import _is_scratch, _matches_outputs
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestIsScratch:
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
["tmp/x.csv", "tmp/sub/y.json", "__pycache__/m.pyc", "pkg/__pycache__/m.pyc",
|
|
".cache/blob", ".ipynb_checkpoints/nb", "a.tmp", "b.lock", "c.pyc"],
|
|
)
|
|
def test_scratch_paths_excluded(self, path):
|
|
assert _is_scratch(path) is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"path", ["report.pdf", "out/data.csv", "deck.pptx", "notes.txt", "tmpfile.txt"],
|
|
)
|
|
def test_real_outputs_kept(self, path):
|
|
assert _is_scratch(path) is False
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMatchesOutputs:
|
|
def test_basename_and_path(self):
|
|
assert _matches_outputs("report.pdf", ["report.pdf"])
|
|
assert _matches_outputs("out/report.pdf", ["report.pdf"]) # basename also matches
|
|
|
|
def test_globs(self):
|
|
assert _matches_outputs("a/b.csv", ["*.csv"])
|
|
assert _matches_outputs("out/x.json", ["out/*.json"])
|
|
|
|
def test_no_match(self):
|
|
assert not _matches_outputs("report.pdf", ["*.csv"])
|
|
|
|
|
|
class _FakeMgr:
|
|
"""Serves a fixed {rel_path: bytes} workspace listing."""
|
|
|
|
def __init__(self, files):
|
|
self._files = files
|
|
|
|
def list_files(self, _sid):
|
|
return list(self._files)
|
|
|
|
def get_file(self, _sid, path):
|
|
return self._files[path]
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestCaptureFiltering:
|
|
@staticmethod
|
|
def _captured(monkeypatch, files, pre=None, outputs=None):
|
|
seen = []
|
|
|
|
def fake_persist(rel_path, data, **_kw):
|
|
seen.append(rel_path)
|
|
return {"artifact_id": rel_path, "version": 1,
|
|
"filename": rel_path.rsplit("/", 1)[-1], "mime_type": "x", "size": len(data)}
|
|
|
|
monkeypatch.setattr(ac, "persist_artifact", fake_persist)
|
|
ac.capture_artifacts(_FakeMgr(files), "sid", pre or {}, user_id="u", outputs=outputs)
|
|
return seen
|
|
|
|
def test_auto_skips_scratch(self, monkeypatch):
|
|
files = {"report.pdf": b"x", "tmp/scratch.csv": b"y", "__pycache__/m.pyc": b"z"}
|
|
assert self._captured(monkeypatch, files) == ["report.pdf"]
|
|
|
|
def test_inputs_never_captured(self, monkeypatch):
|
|
files = {"report.pdf": b"x", "inputs/source.csv": b"y"}
|
|
assert self._captured(monkeypatch, files) == ["report.pdf"]
|
|
|
|
def test_outputs_allow_list_only(self, monkeypatch):
|
|
files = {"report.pdf": b"x", "data.csv": b"y", "notes.txt": b"z"}
|
|
assert self._captured(monkeypatch, files, outputs=["report.pdf"]) == ["report.pdf"]
|
|
|
|
def test_outputs_bypass_scratch(self, monkeypatch):
|
|
# An explicit pattern wins over the scratch skip.
|
|
files = {"tmp/keep.csv": b"x", "skip.txt": b"y"}
|
|
assert self._captured(monkeypatch, files, outputs=["*.csv"]) == ["tmp/keep.csv"]
|
|
|
|
def test_unchanged_file_skipped(self, monkeypatch):
|
|
pre = {"report.pdf": (1, hashlib.sha256(b"x").hexdigest())}
|
|
assert self._captured(monkeypatch, {"report.pdf": b"x"}, pre=pre) == []
|
|
# Content change is captured.
|
|
assert self._captured(monkeypatch, {"report.pdf": b"xy"}, pre=pre) == ["report.pdf"]
|
|
|
|
|
|
class _CountingMgr:
|
|
"""Serves a fixed {rel_path: bytes} workspace and counts every get_file read."""
|
|
|
|
def __init__(self, files):
|
|
self._files = files
|
|
self.reads = 0
|
|
|
|
def list_files(self, _sid):
|
|
return list(self._files)
|
|
|
|
def get_file(self, _sid, path):
|
|
self.reads += 1
|
|
return self._files[path]
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestReadSweepCap:
|
|
def test_snapshot_signature_scan_capped(self):
|
|
# An unchanged-file-heavy workspace can no longer be read in full each pass.
|
|
files = {f"f{i:04d}.txt": b"x" for i in range(ac.MAX_SCANNED_FILES + 50)}
|
|
mgr = _CountingMgr(files)
|
|
sigs = ac.snapshot_signatures(mgr, "sid")
|
|
assert mgr.reads == ac.MAX_SCANNED_FILES
|
|
assert len(sigs) == ac.MAX_SCANNED_FILES
|
|
|
|
def test_capture_read_sweep_capped(self, monkeypatch):
|
|
# Every get_file (even for unchanged, never-persisted files) counts toward the cap.
|
|
files = {f"f{i:04d}.txt": b"x" for i in range(ac.MAX_SCANNED_FILES + 50)}
|
|
pre = {name: (1, hashlib.sha256(b"x").hexdigest()) for name in files} # all unchanged
|
|
mgr = _CountingMgr(files)
|
|
monkeypatch.setattr(ac, "persist_artifact", lambda *a, **k: None)
|
|
captured = ac.capture_artifacts(mgr, "sid", pre, user_id="u")
|
|
assert captured == [] # nothing changed -> nothing persisted
|
|
assert mgr.reads == ac.MAX_SCANNED_FILES # but the sweep is still bounded
|
|
|
|
|
|
class _ListingRaisesMgr:
|
|
"""A manager whose workspace listing fails (sandbox auto-stopped/deleted)."""
|
|
|
|
def list_files(self, _sid):
|
|
# Mirrors the generic IOError the Daytona/Jupyter backends raise when the
|
|
# workspace can't be listed because the runtime is gone.
|
|
raise IOError("list_files failed: RuntimeError")
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestListingFailureLogLevel:
|
|
"""Regression: a swallowed, recoverable workspace-listing failure must log at
|
|
WARNING, not ERROR.
|
|
|
|
``snapshot_signatures`` / ``capture_artifacts`` catch a failed listing, log it,
|
|
and return empty (best-effort — the exec result is still delivered). Logging it
|
|
via ``logger.exception`` stamps it ERROR, which inflated the production error
|
|
stream: on 2026-07-20 these two lines were 7 of the day's ~43 real-app ERRORs
|
|
and false-alarmed the error review (ledger #28, Bug B). A no-op capture is not
|
|
an error.
|
|
"""
|
|
|
|
def test_pre_exec_listing_failure_logs_warning_not_error(self, caplog):
|
|
with caplog.at_level(logging.DEBUG, logger="docsgpt.sandbox.artifacts_capture"):
|
|
sigs = ac.snapshot_signatures(_ListingRaisesMgr(), "sid")
|
|
assert sigs == {} # swallowed, best-effort
|
|
recs = [r for r in caplog.records if "pre-exec listing failed" in r.getMessage()]
|
|
assert recs, "expected a pre-exec listing-failed log record"
|
|
assert all(r.levelno == logging.WARNING for r in recs), (
|
|
"a swallowed, recoverable listing failure must not log at ERROR"
|
|
)
|
|
|
|
def test_post_exec_listing_failure_logs_warning_not_error(self, caplog):
|
|
with caplog.at_level(logging.DEBUG, logger="docsgpt.sandbox.artifacts_capture"):
|
|
captured = ac.capture_artifacts(_ListingRaisesMgr(), "sid", {}, user_id="u")
|
|
assert captured == [] # swallowed, best-effort
|
|
recs = [r for r in caplog.records if "post-exec listing failed" in r.getMessage()]
|
|
assert recs, "expected a post-exec listing-failed log record"
|
|
assert all(r.levelno == logging.WARNING for r in recs), (
|
|
"a swallowed, recoverable listing failure must not log at ERROR"
|
|
)
|