From 6c97e25657ff9cfc132eed3c416c6fea7f7dc0d7 Mon Sep 17 00:00:00 2001 From: Marius1311 Date: Sat, 22 Aug 2026 16:31:44 +0200 Subject: [PATCH 1/3] chore: agent-neutral guidance, one root resolver, dataset paths as a set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guidance lived only in .github/copilot-instructions.md, which named one agent. AGENTS.md is now the canonical file, with CLAUDE.md and the copilot instructions as pointers to it. _find_root walked up for a marker file, which stops at a git worktree — so data would resolve into the worktree rather than the checkout everyone shares. The root is now resolved from git (--git-common-dir names the main checkout from anywhere), with the marker walk as the fallback for a non-git install. DatasetPaths becomes a plain field dataclass over a small base that supplies create(), built by expanding DATASET_DIRS, so a subfolder name is written once. EXAMPLE_DATASET is dropped: FilePaths.dataset() is the single way to reach a dataset. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VYsKhgGuNCFtxqFhSLGtCK --- .github/copilot-instructions.md | 24 +---- AGENTS.md | 42 ++++++++ CLAUDE.md | 3 + .../ML-2026-01-27_demo_scRNA_workflow.ipynb | 4 +- src/myanalysis/_constants.py | 99 +++++++++---------- 5 files changed, 100 insertions(+), 72 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md 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..454383d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +# 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/`. Everything resolves from `FilePaths`: + +```python +from myanalysis import FilePaths + +ds = FilePaths.dataset("pbmc3k") # .root .raw .processed .resources .results +ds.create() # idempotent; call it in the writer, not at import +adata.write_h5ad(ds.processed / "adata.h5ad") +``` + +`FilePaths.ROOT` is the repository root, resolved from git so it names the *main* checkout +even when called from a worktree; `FilePaths.DATA` and `FilePaths.FIGURES` hang off it. Add a +dataset by calling `FilePaths.dataset("")` — `_constants.py` only needs editing to change +the shared layout itself. + +`FilePaths.FIGURES` is for **curated** output: figures chosen for a talk or a paper. + +## 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/analysis/ML-2026-01-27_demo_scRNA_workflow.ipynb b/analysis/ML-2026-01-27_demo_scRNA_workflow.ipynb index e93671b..cccbafd 100644 --- a/analysis/ML-2026-01-27_demo_scRNA_workflow.ipynb +++ b/analysis/ML-2026-01-27_demo_scRNA_workflow.ipynb @@ -95,7 +95,7 @@ "outputs": [], "source": [ "sc.settings.verbosity = 2\n", - "sc.settings.datasetdir = FilePaths.EXAMPLE_DATASET / \"raw\"\n", + "sc.settings.datasetdir = FilePaths.dataset(\"example_dataset\").raw\n", "sc.settings.set_figure_params(dpi=100, frameon=False)\n", "sc.settings.figdir = FilePaths.FIGURES / \"example_dataset\"\n", "\n", @@ -498,7 +498,7 @@ "metadata": {}, "outputs": [], "source": [ - "output_path = FilePaths.EXAMPLE_DATASET / \"processed\" / \"pbmc3k_processed.h5ad\"\n", + "output_path = FilePaths.dataset(\"example_dataset\").processed / \"pbmc3k_processed.h5ad\"\n", "adata.write(output_path)\n", "print(f\"Saved to: {output_path}\")" ] diff --git a/src/myanalysis/_constants.py b/src/myanalysis/_constants.py index ac69cd6..2d68970 100644 --- a/src/myanalysis/_constants.py +++ b/src/myanalysis/_constants.py @@ -1,77 +1,76 @@ -"""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 dataclasses import dataclass, fields +from functools import lru_cache from pathlib import Path +from typing import Self -# Files that mark the repository root, searched for upward from this module. -_ROOT_MARKERS = ("pixi.toml", ".git") +#: Standard subfolders of ``data//``. +DATASET_DIRS = ("raw", "processed", "resources", "results") -def _find_root(start: Path) -> Path: - """Locate the repo root by walking upward until a marker file is found. +@lru_cache(maxsize=1) +def _repo_root() -> Path: + """The repository root, from git so a worktree still names the main checkout. - 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] + 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] @dataclass(frozen=True) -class DatasetPaths: - """Standard subfolders for a single dataset (``data//``).""" +class _Dirs: + """A named set of directories.""" - 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) + def create(self) -> Self: + """Create them all, idempotently. Call from the writer, never at import.""" + for field in fields(self): + getattr(self, field.name).mkdir(parents=True, exist_ok=True) return self -class FilePaths: - """Project-wide paths for notebooks and scripts.""" +@dataclass(frozen=True) +class DatasetPaths(_Dirs): + """``data//`` and its standard subfolders.""" - ROOT = _find_root(Path(__file__).resolve()) + root: Path + raw: Path + processed: Path + resources: Path + results: Path + +class FilePaths: + """Project-wide paths. Reach datasets through :meth:`dataset`; never hardcode one.""" + + 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. + """Standard paths for ``data//``. Examples -------- - >>> paths = FilePaths.dataset("pbmc3k").create() - >>> paths.processed / "adata.h5ad" # doctest: +SKIP + >>> FilePaths.dataset("pbmc3k").create().processed # doctest: +SKIP """ - return DatasetPaths(cls.DATA / name) + root = cls.DATA / name + return DatasetPaths(root=root, **{d: root / d for d in DATASET_DIRS}) From 64fca1cada710f52c88ebe3b05a440f6c73778c4 Mon Sep 17 00:00:00 2001 From: Marius1311 Date: Sat, 22 Aug 2026 16:37:38 +0200 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20keep=20FilePaths=20flat=20?= =?UTF-8?q?=E2=80=94=20drop=20the=20dataset()=20accessor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Datasets were reached by a method call returning a dataclass while figures were a plain attribute, so the two looked nothing alike. The one repo that has used this layout for years (gli3_merscope_analysis) reaches every dataset as a flat constant and treats {raw,processed,resources,results} as a documented convention, which has held up fine. FilePaths is now attributes only, all of them Paths. DatasetPaths and the shared _Dirs base are gone; the layout convention is stated in AGENTS.md instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VYsKhgGuNCFtxqFhSLGtCK --- AGENTS.md | 17 ++++++--------- src/myanalysis/__init__.py | 4 ++-- src/myanalysis/_constants.py | 41 +++--------------------------------- tests/test_basic.py | 6 +++--- 4 files changed, 15 insertions(+), 53 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 454383d..33d34a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,22 +12,19 @@ never restated. ## Paths -Never hardcode a path into `data/` or `figures/`. Everything resolves from `FilePaths`: +Never hardcode a path into `data/` or `figures/` — every path hangs off `FilePaths`: ```python from myanalysis import FilePaths -ds = FilePaths.dataset("pbmc3k") # .root .raw .processed .resources .results -ds.create() # idempotent; call it in the writer, not at import -adata.write_h5ad(ds.processed / "adata.h5ad") +FilePaths.DATA # data/ +FilePaths.FIGURES # figures/ — curated output: talk and paper figures +FilePaths.EXAMPLE_DATASET / "processed" / "adata.h5ad" ``` -`FilePaths.ROOT` is the repository root, resolved from git so it names the *main* checkout -even when called from a worktree; `FilePaths.DATA` and `FilePaths.FIGURES` hang off it. Add a -dataset by calling `FilePaths.dataset("")` — `_constants.py` only needs editing to change -the shared layout itself. - -`FilePaths.FIGURES` is for **curated** output: figures chosen for a talk or a paper. +`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 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 2d68970..7eebb98 100644 --- a/src/myanalysis/_constants.py +++ b/src/myanalysis/_constants.py @@ -3,13 +3,8 @@ from __future__ import annotations import subprocess -from dataclasses import dataclass, fields from functools import lru_cache from pathlib import Path -from typing import Self - -#: Standard subfolders of ``data//``. -DATASET_DIRS = ("raw", "processed", "resources", "results") @lru_cache(maxsize=1) @@ -35,42 +30,12 @@ def _repo_root() -> Path: return here.parents[2] -@dataclass(frozen=True) -class _Dirs: - """A named set of directories.""" - - def create(self) -> Self: - """Create them all, idempotently. Call from the writer, never at import.""" - for field in fields(self): - getattr(self, field.name).mkdir(parents=True, exist_ok=True) - return self - - -@dataclass(frozen=True) -class DatasetPaths(_Dirs): - """``data//`` and its standard subfolders.""" - - root: Path - raw: Path - processed: Path - resources: Path - results: Path - - class FilePaths: - """Project-wide paths. Reach datasets through :meth:`dataset`; never hardcode one.""" + """Project-wide paths. Add a dataset as a constant here; never hardcode one.""" ROOT = _repo_root() + DATA = ROOT / "data" FIGURES = ROOT / "figures" - @classmethod - def dataset(cls, name: str) -> DatasetPaths: - """Standard paths for ``data//``. - - Examples - -------- - >>> FilePaths.dataset("pbmc3k").create().processed # doctest: +SKIP - """ - root = cls.DATA / name - return DatasetPaths(root=root, **{d: root / d for d in DATASET_DIRS}) + EXAMPLE_DATASET = DATA / "example_dataset" 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(): From 391c54738cc3ddb617bd1f108e9293ddf3425fba Mon Sep 17 00:00:00 2001 From: Marius1311 Date: Sat, 22 Aug 2026 16:37:57 +0200 Subject: [PATCH 3/3] fix: restore the demo notebook's EXAMPLE_DATASET usage It still called the dataset() accessor removed in the previous commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VYsKhgGuNCFtxqFhSLGtCK --- analysis/ML-2026-01-27_demo_scRNA_workflow.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/analysis/ML-2026-01-27_demo_scRNA_workflow.ipynb b/analysis/ML-2026-01-27_demo_scRNA_workflow.ipynb index cccbafd..e93671b 100644 --- a/analysis/ML-2026-01-27_demo_scRNA_workflow.ipynb +++ b/analysis/ML-2026-01-27_demo_scRNA_workflow.ipynb @@ -95,7 +95,7 @@ "outputs": [], "source": [ "sc.settings.verbosity = 2\n", - "sc.settings.datasetdir = FilePaths.dataset(\"example_dataset\").raw\n", + "sc.settings.datasetdir = FilePaths.EXAMPLE_DATASET / \"raw\"\n", "sc.settings.set_figure_params(dpi=100, frameon=False)\n", "sc.settings.figdir = FilePaths.FIGURES / \"example_dataset\"\n", "\n", @@ -498,7 +498,7 @@ "metadata": {}, "outputs": [], "source": [ - "output_path = FilePaths.dataset(\"example_dataset\").processed / \"pbmc3k_processed.h5ad\"\n", + "output_path = FilePaths.EXAMPLE_DATASET / \"processed\" / \"pbmc3k_processed.h5ad\"\n", "adata.write(output_path)\n", "print(f\"Saved to: {output_path}\")" ]