diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9c80755..246b777 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,22 +1,6 @@ -# Copilot Instructions for Analysis Template +# Copilot Instructions -## Project context +Canonical repo guidance lives in `AGENTS.md` — layout, paths, environments and commands. +`README.md` is the user-facing overview. -See the project README for details about the project goal, datasets, and structure. - -## Quick reference - -| Task | Command | -|------|---------| -| Run Python | `pixi run python script.py` | -| Run tests | `pixi run test` | -| Add conda package | `pixi add ` | -| Add PyPI package | `pixi add --pypi ` | - -## Project structure -- **Notebooks**: `analysis/[INITIALS]-[YYYY]-[MM]-[DD]_description.ipynb` -- **Data**: `data//{raw,processed,resources,results}/` -- **Paths**: Use `from import FilePaths` (edit `_constants.py` for datasets) -- **Deps**: All in `pixi.toml` (not pyproject.toml) -- pyproject.toml exists mainly for package metadata and testing -- Run `pixi install` after pulling changes that update `pixi.toml` +If this file conflicts with them, they win. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..33d34a9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# AGENTS.md — working conventions + +This file owns the working conventions and is the canonical guidance for any coding agent. +`README.md` is the user-facing overview; anything documented there is referenced from here, +never restated. + +## Layout + +- **Notebooks**: `analysis/[INITIALS]-[YYYY]-[MM]-[DD]_description.ipynb` +- **Data**: `data//{raw,processed,resources,results}/`, gitignored +- **Package**: `src//`, installed editable from the checkout + +## Paths + +Never hardcode a path into `data/` or `figures/` — every path hangs off `FilePaths`: + +```python +from myanalysis import FilePaths + +FilePaths.DATA # data/ +FilePaths.FIGURES # figures/ — curated output: talk and paper figures +FilePaths.EXAMPLE_DATASET / "processed" / "adata.h5ad" +``` + +`FilePaths.ROOT` is resolved from git, so it names the *main* checkout even when called from a +worktree and shared data does not follow your branch. Add a dataset as a constant in +`_constants.py`; each one keeps the `{raw,processed,resources,results}` layout by convention. + +## Environments + +Dependencies live in `pixi.toml`, not `pyproject.toml` — the latter carries package metadata and +the test config. Run `pixi install` after pulling a change to `pixi.toml`, in the main checkout. + +| Task | Command | +| --- | --- | +| Run Python | `pixi run python script.py` | +| Run tests | `pixi run test` | +| Add conda package | `pixi add ` | +| Add PyPI package | `pixi add --pypi ` | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5a69517 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# Analysis Template Agent Entry Point + +@AGENTS.md diff --git a/src/myanalysis/__init__.py b/src/myanalysis/__init__.py index a336f31..9b44508 100644 --- a/src/myanalysis/__init__.py +++ b/src/myanalysis/__init__.py @@ -1,7 +1,7 @@ from importlib.metadata import version -from ._constants import DatasetPaths, FilePaths +from ._constants import FilePaths from .plotting import qc_violin -__all__ = ["DatasetPaths", "FilePaths", "qc_violin"] +__all__ = ["FilePaths", "qc_violin"] __version__ = version("myanalysis") diff --git a/src/myanalysis/_constants.py b/src/myanalysis/_constants.py index ac69cd6..7eebb98 100644 --- a/src/myanalysis/_constants.py +++ b/src/myanalysis/_constants.py @@ -1,77 +1,41 @@ -"""Project-wide path constants for notebooks and scripts.""" +"""Project-wide paths for notebooks and scripts.""" from __future__ import annotations -from dataclasses import dataclass +import subprocess +from functools import lru_cache from pathlib import Path -# Files that mark the repository root, searched for upward from this module. -_ROOT_MARKERS = ("pixi.toml", ".git") +@lru_cache(maxsize=1) +def _repo_root() -> Path: + """The repository root, from git so a worktree still names the main checkout. -def _find_root(start: Path) -> Path: - """Locate the repo root by walking upward until a marker file is found. - - Falls back to the fixed ``src//`` layout (three levels up) when no - marker is present, e.g. for a non-editable installed copy. + Falls back to walking up for ``pixi.toml``/``.git`` outside a repository. """ - for parent in (start, *start.parents): - if any((parent / marker).exists() for marker in _ROOT_MARKERS): - return parent - return start.parents[2] - - -@dataclass(frozen=True) -class DatasetPaths: - """Standard subfolders for a single dataset (``data//``).""" - - root: Path - - @property - def raw(self) -> Path: - """Original, unmodified input data.""" - return self.root / "raw" - - @property - def processed(self) -> Path: - """Preprocessed / intermediate data.""" - return self.root / "processed" - - @property - def resources(self) -> Path: - """Reference data, gene sets, annotations.""" - return self.root / "resources" - - @property - def results(self) -> Path: - """Analysis outputs (tables, exported objects).""" - return self.root / "results" - - def create(self) -> DatasetPaths: - """Create all standard subfolders (idempotent). Returns ``self``.""" - for path in (self.raw, self.processed, self.resources, self.results): - path.mkdir(parents=True, exist_ok=True) - return self + try: + git_dir = subprocess.run( + ["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], + cwd=Path(__file__).resolve().parent, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + return Path(git_dir).parent + except (subprocess.CalledProcessError, OSError): + here = Path(__file__).resolve() + for parent in (here, *here.parents): + if (parent / "pixi.toml").exists() or (parent / ".git").exists(): + return parent + return here.parents[2] class FilePaths: - """Project-wide paths for notebooks and scripts.""" + """Project-wide paths. Add a dataset as a constant here; never hardcode one.""" - ROOT = _find_root(Path(__file__).resolve()) + ROOT = _repo_root() DATA = ROOT / "data" FIGURES = ROOT / "figures" - # The bundled example dataset; customize / add your own via `dataset()`. EXAMPLE_DATASET = DATA / "example_dataset" - - @classmethod - def dataset(cls, name: str) -> DatasetPaths: - """Return the standard raw/processed/resources/results paths for a dataset. - - Examples - -------- - >>> paths = FilePaths.dataset("pbmc3k").create() - >>> paths.processed / "adata.h5ad" # doctest: +SKIP - """ - return DatasetPaths(cls.DATA / name) diff --git a/tests/test_basic.py b/tests/test_basic.py index d4a4c20..9e6ae0e 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -16,10 +16,10 @@ def test_package_has_version(): def test_filepaths(): - """Project root resolves, and the dataset accessor builds standard subpaths.""" + """The root resolves to the checkout, and dataset constants hang off it.""" assert (FilePaths.ROOT / "pixi.toml").exists() - ds = FilePaths.dataset("example_dataset") - assert ds.raw == FilePaths.DATA / "example_dataset" / "raw" + assert FilePaths.DATA == FilePaths.ROOT / "data" + assert FilePaths.EXAMPLE_DATASET == FilePaths.DATA / "example_dataset" def test_qc_violin():