From 3f0f2617524fe5a5ea9f7200661e668c5644cfbc Mon Sep 17 00:00:00 2001 From: Shunichiro Nomura Date: Sun, 7 Jul 2024 15:42:30 +0900 Subject: [PATCH 01/12] Add documentation --- README.md | 33 +-- docs/contexts/command.md | 3 + docs/contexts/cpu.md | 3 + docs/contexts/cwd.md | 3 + docs/contexts/envvar.md | 3 + docs/contexts/file.md | 3 + docs/contexts/git.md | 3 + docs/contexts/index.md | 11 + docs/contexts/platform.md | 3 + docs/index.md | 324 ++++++++++++++++++++++++++++ docs/reporters/index.md | 5 + docs/reporters/json_dump.md | 3 + docs/usage/index.md | 1 + docs/watchers/index.md | 6 + docs/watchers/time.md | 3 + docs/watchers/uncaught_exception.md | 3 + mkdocs.yml | 92 ++++++++ pyproject.toml | 12 ++ scripts/gen_ref_pages.py | 37 ++++ 19 files changed, 538 insertions(+), 13 deletions(-) create mode 100644 docs/contexts/command.md create mode 100644 docs/contexts/cpu.md create mode 100644 docs/contexts/cwd.md create mode 100644 docs/contexts/envvar.md create mode 100644 docs/contexts/file.md create mode 100644 docs/contexts/git.md create mode 100644 docs/contexts/index.md create mode 100644 docs/contexts/platform.md create mode 100644 docs/index.md create mode 100644 docs/reporters/index.md create mode 100644 docs/reporters/json_dump.md create mode 100644 docs/usage/index.md create mode 100644 docs/watchers/index.md create mode 100644 docs/watchers/time.md create mode 100644 docs/watchers/uncaught_exception.md create mode 100644 mkdocs.yml create mode 100644 scripts/gen_ref_pages.py diff --git a/README.md b/README.md index 6477b51c..fdfab62b 100644 --- a/README.md +++ b/README.md @@ -8,31 +8,38 @@ [![codecov](https://codecov.io/gh/shunichironomura/capsula/graph/badge.svg?token=BZXF2PPDM0)](https://codecov.io/gh/shunichironomura/capsula) ![PyPI - Downloads](https://img.shields.io/pypi/dm/capsula) -*Capsula*, a Latin word meaning *box*, is a Python package designed to help researchers and developers easily capture and reproduce their command execution context. The primary aim of Capsula is to tackle the reproducibility problem by providing a way to capture the execution context at any point in time, preserving it for future use. This ensures that you can reproduce the exact conditions of past command execution, fostering reproducibility and consistency over time. +*Capsula*, a Latin word meaning *box*, is a Python package designed to help researchers and developers easily capture their command/function execution context for reproducibility. -Features: +With Capsula, you can capture: -1. **Context Capture:** Capsula logs the details of the execution context for future reference and reproduction. The context includes, but is not limited to, the Python version, system environment variables, and the Git commit hash of the current working directory. +- CPU information with [`CpuContext`](contexts/cpu.md) +- Python version with [`PlatformContext`](contexts/platform.md) +- Current working directory with [`CwdContext`](contexts/cwd.md) +- Git repository information (commit hash, branch, etc.) with [`GitRepositoryContext`](contexts/git.md) +- Output of shell commands (e.g., `poetry check --lock`) with [`CommandContext`](contexts/command.md) +- Files (e.g., output files, `pyproject.toml`, `requirements.txt`) with [`FileContext`](contexts/file.md) +- Arguments of Python functions with [`FunctionContext`](contexts/function.md) +- Environment variables with [`EnvVarContext`](contexts/envvar.md) +- Uncaught exceptions with [`UncaughtExceptionWatcher`](watchers/uncaught_exception.md) +- Execution time with [`TimeWatcher`](watchers/time.md) -2. **Execution Monitoring:** Capsula monitors the execution of a Python function, logging information such as the execution status, output, duration, etc. - -3. **Context Diffing (to be implemented):** Capsula can compare the current context with the context captured at a previous point in time. This is useful for identifying changes or for reproducing the exact conditions of a past execution. +The captured contexts are dumped into JSON files for future reference and reproduction. ## Usage -Prepare a `capsula.toml` file in the root directory of your project. An example of the `capsula.toml` file is as follows: +For project-wide settings, prepare a `capsula.toml` file in the root directory of your project. An example of the `capsula.toml` file is as follows: ```toml [pre-run] contexts = [ { type = "CwdContext" }, { type = "CpuContext" }, - { type = "GitRepositoryContext", name = "capsula", path = "." }, - { type = "CommandContext", command = "poetry check --lock" }, - { type = "FileContext", path = "pyproject.toml", copy = true }, - { type = "FileContext", path = "poetry.lock", copy = true }, - { type = "CommandContext", command = "pip freeze --exclude-editable > requirements.txt" }, - { type = "FileContext", path = "requirements.txt", move = true }, + { type = "GitRepositoryContext", name = "capsula", path = ".", path_relative_to_project_root = true }, + { type = "CommandContext", command = "poetry check --lock", cwd = ".", cwd_relative_to_project_root = true }, + { type = "FileContext", path = "pyproject.toml", copy = true, path_relative_to_project_root = true }, + { type = "FileContext", path = "poetry.lock", copy = true, path_relative_to_project_root = true }, + { type = "CommandContext", command = "pip freeze --exclude-editable > requirements.txt", cwd = ".", cwd_relative_to_project_root = true }, + { type = "FileContext", path = "requirements.txt", move = true, path_relative_to_project_root = true }, ] reporters = [{ type = "JsonDumpReporter" }] diff --git a/docs/contexts/command.md b/docs/contexts/command.md new file mode 100644 index 00000000..f79d9823 --- /dev/null +++ b/docs/contexts/command.md @@ -0,0 +1,3 @@ +# `CommandContext` + +::: capsula.CommandContext diff --git a/docs/contexts/cpu.md b/docs/contexts/cpu.md new file mode 100644 index 00000000..8f234d13 --- /dev/null +++ b/docs/contexts/cpu.md @@ -0,0 +1,3 @@ +# `CpuContext` + +::: capsula.CpuContext diff --git a/docs/contexts/cwd.md b/docs/contexts/cwd.md new file mode 100644 index 00000000..1c2e1c02 --- /dev/null +++ b/docs/contexts/cwd.md @@ -0,0 +1,3 @@ +# `CwdContext` + +::: capsula.CwdContext diff --git a/docs/contexts/envvar.md b/docs/contexts/envvar.md new file mode 100644 index 00000000..c71371a0 --- /dev/null +++ b/docs/contexts/envvar.md @@ -0,0 +1,3 @@ +# `EnvVarContext` + +::: capsula.EnvVarContext diff --git a/docs/contexts/file.md b/docs/contexts/file.md new file mode 100644 index 00000000..0840bb4c --- /dev/null +++ b/docs/contexts/file.md @@ -0,0 +1,3 @@ +# `FileContext` + +::: capsula.FileContext diff --git a/docs/contexts/git.md b/docs/contexts/git.md new file mode 100644 index 00000000..3b8ee4ca --- /dev/null +++ b/docs/contexts/git.md @@ -0,0 +1,3 @@ +# `GitRepositoryContext` + +::: capsula.GitRepositoryContext diff --git a/docs/contexts/index.md b/docs/contexts/index.md new file mode 100644 index 00000000..4e1433bd --- /dev/null +++ b/docs/contexts/index.md @@ -0,0 +1,11 @@ +# Built-in contexts + +Capsula provides several built-in contexts that you can capture. The following is a list of built-in contexts: + +- [`CommandContext`](command.md) +- [`CpuContext`](cpu.md) +- [`CwdContext`](cwd.md) +- [`EnvVarContext`](envvar.md) + +- [`GitRepositoryContext`](git.md) +- [`PlatformContext`](platform.md) diff --git a/docs/contexts/platform.md b/docs/contexts/platform.md new file mode 100644 index 00000000..0ac6666c --- /dev/null +++ b/docs/contexts/platform.md @@ -0,0 +1,3 @@ +# `PlatformContext` + +::: capsula.PlatformContext diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..fdfab62b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,324 @@ +# Capsula + +[![PyPI](https://img.shields.io/pypi/v/capsula)](https://pypi.org/project/capsula/) +[![conda-forge](https://img.shields.io/conda/vn/conda-forge/capsula.svg)](https://anaconda.org/conda-forge/capsula) +![PyPI - License](https://img.shields.io/pypi/l/capsula) +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/capsula) +![Test Status](https://github.com/shunichironomura/capsula/workflows/Test/badge.svg?event=push&branch=main) +[![codecov](https://codecov.io/gh/shunichironomura/capsula/graph/badge.svg?token=BZXF2PPDM0)](https://codecov.io/gh/shunichironomura/capsula) +![PyPI - Downloads](https://img.shields.io/pypi/dm/capsula) + +*Capsula*, a Latin word meaning *box*, is a Python package designed to help researchers and developers easily capture their command/function execution context for reproducibility. + +With Capsula, you can capture: + +- CPU information with [`CpuContext`](contexts/cpu.md) +- Python version with [`PlatformContext`](contexts/platform.md) +- Current working directory with [`CwdContext`](contexts/cwd.md) +- Git repository information (commit hash, branch, etc.) with [`GitRepositoryContext`](contexts/git.md) +- Output of shell commands (e.g., `poetry check --lock`) with [`CommandContext`](contexts/command.md) +- Files (e.g., output files, `pyproject.toml`, `requirements.txt`) with [`FileContext`](contexts/file.md) +- Arguments of Python functions with [`FunctionContext`](contexts/function.md) +- Environment variables with [`EnvVarContext`](contexts/envvar.md) +- Uncaught exceptions with [`UncaughtExceptionWatcher`](watchers/uncaught_exception.md) +- Execution time with [`TimeWatcher`](watchers/time.md) + +The captured contexts are dumped into JSON files for future reference and reproduction. + +## Usage + +For project-wide settings, prepare a `capsula.toml` file in the root directory of your project. An example of the `capsula.toml` file is as follows: + +```toml +[pre-run] +contexts = [ + { type = "CwdContext" }, + { type = "CpuContext" }, + { type = "GitRepositoryContext", name = "capsula", path = ".", path_relative_to_project_root = true }, + { type = "CommandContext", command = "poetry check --lock", cwd = ".", cwd_relative_to_project_root = true }, + { type = "FileContext", path = "pyproject.toml", copy = true, path_relative_to_project_root = true }, + { type = "FileContext", path = "poetry.lock", copy = true, path_relative_to_project_root = true }, + { type = "CommandContext", command = "pip freeze --exclude-editable > requirements.txt", cwd = ".", cwd_relative_to_project_root = true }, + { type = "FileContext", path = "requirements.txt", move = true, path_relative_to_project_root = true }, +] +reporters = [{ type = "JsonDumpReporter" }] + +[in-run] +watchers = [{ type = "UncaughtExceptionWatcher" }, { type = "TimeWatcher" }] +reporters = [{ type = "JsonDumpReporter" }] + +[post-run] +reporters = [{ type = "JsonDumpReporter" }] + +``` + +Then, all you need to do is decorate your Python function with the `@capsula.run()` decorator. You can also use the `@capsula.context()` decorator to add a context specific to the function. + +The following is an example of a Python script that estimates the value of π using the Monte Carlo method: + +```python +import random +import capsula + +@capsula.run() +@capsula.context(capsula.FileContext.default("pi.txt", move=True), mode="post") +def calculate_pi(n_samples: int = 1_000, seed: int = 42) -> None: + random.seed(seed) + xs = (random.random() for _ in range(n_samples)) + ys = (random.random() for _ in range(n_samples)) + inside = sum(x * x + y * y <= 1.0 for x, y in zip(xs, ys)) + + # You can record values to the capsule using the `record` method. + capsula.record("inside", inside) + + pi_estimate = (4.0 * inside) / n_samples + print(f"Pi estimate: {pi_estimate}") + capsula.record("pi_estimate", pi_estimate) + print(f"Run name: {capsula.current_run_name()}") + + with open("pi.txt", "w") as output_file: + output_file.write(f"Pi estimate: {pi_estimate}.") + +if __name__ == "__main__": + calculate_pi(n_samples=1_000) +``` + +
+Example of output pre-run-report.json: +
{
+  "cwd": "/home/nomura/ghq/github.com/shunichironomura/capsula",
+  "cpu": {
+    "python_version": "3.8.17.final.0 (64 bit)",
+    "cpuinfo_version": [
+      9,
+      0,
+      0
+    ],
+    "cpuinfo_version_string": "9.0.0",
+    "arch": "X86_64",
+    "bits": 64,
+    "count": 12,
+    "arch_string_raw": "x86_64",
+    "vendor_id_raw": "GenuineIntel",
+    "brand_raw": "Intel(R) Core(TM) i5-10400 CPU @ 2.90GHz",
+    "hz_advertised_friendly": "2.9000 GHz",
+    "hz_actual_friendly": "2.9040 GHz",
+    "hz_advertised": [
+      2900000000,
+      0
+    ],
+    "hz_actual": [
+      2904008000,
+      0
+    ],
+    "stepping": 5,
+    "model": 165,
+    "family": 6,
+    "flags": [
+      "3dnowprefetch",
+      "abm",
+      "adx",
+      "aes",
+      "apic",
+      "arch_capabilities",
+      "arch_perfmon",
+      "avx",
+      "avx2",
+      "bmi1",
+      "bmi2",
+      "clflush",
+      "clflushopt",
+      "cmov",
+      "constant_tsc",
+      "cpuid",
+      "cx16",
+      "cx8",
+      "de",
+      "ept",
+      "ept_ad",
+      "erms",
+      "f16c",
+      "flush_l1d",
+      "fma",
+      "fpu",
+      "fsgsbase",
+      "fxsr",
+      "ht",
+      "hypervisor",
+      "ibpb",
+      "ibrs",
+      "ibrs_enhanced",
+      "invpcid",
+      "invpcid_single",
+      "lahf_lm",
+      "lm",
+      "mca",
+      "mce",
+      "md_clear",
+      "mmx",
+      "movbe",
+      "msr",
+      "mtrr",
+      "nopl",
+      "nx",
+      "osxsave",
+      "pae",
+      "pat",
+      "pcid",
+      "pclmulqdq",
+      "pdcm",
+      "pdpe1gb",
+      "pge",
+      "pni",
+      "popcnt",
+      "pse",
+      "pse36",
+      "rdrand",
+      "rdrnd",
+      "rdseed",
+      "rdtscp",
+      "rep_good",
+      "sep",
+      "smap",
+      "smep",
+      "ss",
+      "ssbd",
+      "sse",
+      "sse2",
+      "sse4_1",
+      "sse4_2",
+      "ssse3",
+      "stibp",
+      "syscall",
+      "tpr_shadow",
+      "tsc",
+      "vme",
+      "vmx",
+      "vnmi",
+      "vpid",
+      "x2apic",
+      "xgetbv1",
+      "xsave",
+      "xsavec",
+      "xsaveopt",
+      "xsaves",
+      "xtopology"
+    ],
+    "l3_cache_size": 12582912,
+    "l2_cache_size": "1.5 MiB",
+    "l1_data_cache_size": 196608,
+    "l1_instruction_cache_size": 196608,
+    "l2_cache_line_size": 256,
+    "l2_cache_associativity": 6
+  },
+  "git": {
+    "capsula": {
+      "working_dir": "/home/nomura/ghq/github.com/shunichironomura/capsula",
+      "sha": "db7b86d3ed95e178521cd140505f1c8b25f4f30e",
+      "remotes": {
+        "origin": "ssh://git@github.com/shunichironomura/capsula.git"
+      },
+      "branch": "update-readme",
+      "is_dirty": false
+    }
+  },
+  "command": {
+    "poetry check --lock": {
+      "command": "poetry check --lock",
+      "cwd": null,
+      "returncode": 0,
+      "stdout": "All set!\n",
+      "stderr": ""
+    },
+    "pip freeze --exclude-editable > requirements.txt": {
+      "command": "pip freeze --exclude-editable > requirements.txt",
+      "cwd": null,
+      "returncode": 0,
+      "stdout": "",
+      "stderr": ""
+    }
+  },
+  "file": {
+    "pyproject.toml": {
+      "copied_to": [
+        "vault/calculate_pi_20240630_015823_S3vb/pyproject.toml"
+      ],
+      "moved_to": null,
+      "hash": {
+        "algorithm": "sha256",
+        "digest": "9b2ccc978e950a3a4d2b5f3d29eadab593e1ffe8cd48e7606389e214cb82c8a6"
+      }
+    },
+    "poetry.lock": {
+      "copied_to": [
+        "vault/calculate_pi_20240630_015823_S3vb/poetry.lock"
+      ],
+      "moved_to": null,
+      "hash": {
+        "algorithm": "sha256",
+        "digest": "8d89f9943c8e515340a5c8c16b17a30a749d935ffe765024acaaa81fc1ed5587"
+      }
+    },
+    "requirements.txt": {
+      "copied_to": [],
+      "moved_to": "vault/calculate_pi_20240630_015823_S3vb",
+      "hash": {
+        "algorithm": "sha256",
+        "digest": "b7a36d48fda3efc9374d7d8b0fd4d910234497e2cf229001a1c2c76fce35810c"
+      }
+    }
+  }
+}
+
+ +
+Example of output in-run-report.json: +
{
+  "function": {
+    "calculate_pi": {
+      "file_path": "examples/simple_decorator.py",
+      "first_line_no": 6,
+      "args": [],
+      "kwargs": {
+        "n_samples": 1000
+      }
+    }
+  },
+  "inside": 782,
+  "pi_estimate": 3.128,
+  "time": {
+    "execution_time": "0:00:00.000568"
+  },
+  "exception": {
+    "exception": {
+      "exc_type": null,
+      "exc_value": null,
+      "traceback": null
+    }
+  }
+}
+
+ +
+Example of output post-run-report.json: +
{
+  "file": {
+    "pi.txt": {
+      "copied_to": [],
+      "moved_to": "vault/calculate_pi_20240630_015823_S3vb",
+      "hash": {
+        "algorithm": "sha256",
+        "digest": "a64c761cb6b6f9ef1bc1f6afa6ba44d796c5c51d14df0bdc9d3ab9ced7982a74"
+      }
+    }
+  }
+}
+
+ +## Installation + +You can install Capsula via pip: + +```bash +pip install capsula +``` diff --git a/docs/reporters/index.md b/docs/reporters/index.md new file mode 100644 index 00000000..cdfcc485 --- /dev/null +++ b/docs/reporters/index.md @@ -0,0 +1,5 @@ +# Built-in reporters + +Capsula provides several built-in reporters that report the captured contexts. + +- [`JsonDumpReporter`](json_dump.md) diff --git a/docs/reporters/json_dump.md b/docs/reporters/json_dump.md new file mode 100644 index 00000000..4d356df2 --- /dev/null +++ b/docs/reporters/json_dump.md @@ -0,0 +1,3 @@ +# `JsonDumpReporter` + +::: capsula.JsonDumpReporter diff --git a/docs/usage/index.md b/docs/usage/index.md new file mode 100644 index 00000000..8f04b05a --- /dev/null +++ b/docs/usage/index.md @@ -0,0 +1 @@ +# Usage diff --git a/docs/watchers/index.md b/docs/watchers/index.md new file mode 100644 index 00000000..7701860d --- /dev/null +++ b/docs/watchers/index.md @@ -0,0 +1,6 @@ +# Built-in watchers + +Capsula provides several built-in watchers that you can use to monitor the execution of your command/function. The following is a list of built-in watchers: + +- [`TimeWatcher`](time.md) +- [`UncaughtExceptionWatcher`](uncaught_exception.md) diff --git a/docs/watchers/time.md b/docs/watchers/time.md new file mode 100644 index 00000000..36706b86 --- /dev/null +++ b/docs/watchers/time.md @@ -0,0 +1,3 @@ +# `TimeWatcher` + +::: capsula.TimeWatcher diff --git a/docs/watchers/uncaught_exception.md b/docs/watchers/uncaught_exception.md new file mode 100644 index 00000000..07bb9dd9 --- /dev/null +++ b/docs/watchers/uncaught_exception.md @@ -0,0 +1,3 @@ +# `UncaughtExceptionWatcher` + +::: capsula.UncaughtExceptionWatcher diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..d808c8c1 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,92 @@ +site_name: Capsula Documentation +site_url: https://shunichironomura.github.io/capsula/ +theme: + name: material + icon: + logo: material/satellite-variant + repo: fontawesome/brands/github + palette: + # Palette toggle for automatic mode + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode + primary: yellow + accent: yellow + + # Palette toggle for light mode + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + primary: yellow + accent: yellow + + # Palette toggle for dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to system preference + primary: yellow + accent: yellow + features: + - navigation.instant + - navigation.tabs + - navigation.tabs.sticky +plugins: + - search + - gen-files: + scripts: + - scripts/gen_ref_pages.py + - literate-nav: + nav_file: SUMMARY.md + - section-index + - mkdocstrings: + default_handler: python + handlers: + python: + options: + show_if_no_docstring: true + unwrap_annotated: true + show_root_heading: true + inherited_members: false + separate_signature: true + merge_init_into_class: true + signature_crossrefs: true + show_symbol_type_heading: true + show_symbol_type_toc: true + group_by_category: false + show_signature_annotations: true + members_order: source + docstring_section_style: spacy + filters: + - "!^_[^_]" + - "!__init_subclass__" + extensions: + - griffe_typingdoc +repo_url: https://github.com/shunichironomura/capsula +repo_name: shunichironomura/capsula +markdown_extensions: + - def_list + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.tasklist: + custom_checkbox: true + - admonition +nav: + - Home: index.md + - Usage: usage/ + - Contexts: contexts/ + - Watchers: watchers/ + - Reporters: reporters/ + - API Reference: reference/ +extra: + version: + provider: mike diff --git a/pyproject.toml b/pyproject.toml index 0e7b0030..1f2813b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,16 @@ click = ">=8.1.3" rich = ">=13.7.0" +[tool.poetry.group.docs.dependencies] +mkdocs-material = "9.5.28" +mkdocstrings = { extras = ["python"], version = "0.25.1" } +mkdocs-gen-files = "0.5.0" +mkdocs-literate-nav = "0.6.1" +mkdocs-section-index = "0.3.9" +mike = "2.1.2" +griffe-typingdoc = "0.2.5" +black = "24.4.2" + [tool.poetry.scripts] capsula = "capsula._cli:app" @@ -120,3 +130,5 @@ ignore = [ "TRY003", # Avoid specifying long messages outside the exception class "EM102", # Exception must not use an f-string literal, assign to variable first" ] +"scripts/*" = ["INP001"] +"tests/**" = ["SLF001"] diff --git a/scripts/gen_ref_pages.py b/scripts/gen_ref_pages.py new file mode 100644 index 00000000..2365b967 --- /dev/null +++ b/scripts/gen_ref_pages.py @@ -0,0 +1,37 @@ +"""Generate the code reference pages.""" + +from pathlib import Path + +import mkdocs_gen_files + +nav = mkdocs_gen_files.Nav() # type: ignore[attr-defined, no-untyped-call] + +src = Path(__file__).parents[1] +package_root = src / "capsula" + +for path in sorted(package_root.rglob("*.py")): + module_path = path.relative_to(src).with_suffix("") + doc_path = path.relative_to(src).with_suffix(".md") + full_doc_path = Path("reference", doc_path) + + parts = tuple(module_path.parts) + + if parts[-1] == "__init__": + parts = parts[:-1] + if parts[-1].startswith("_"): + continue + doc_path = doc_path.with_name("index.md") + full_doc_path = full_doc_path.with_name("index.md") + elif parts[-1] == "__main__" or parts[-1].startswith("_"): + continue + + nav[parts] = doc_path.as_posix() + + with mkdocs_gen_files.open(full_doc_path, "w") as fd: + identifier = ".".join(parts) + print("::: " + identifier, file=fd) + + mkdocs_gen_files.set_edit_path(full_doc_path, path) + +with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) From 2f4c619ef00e447e03f48a752cd671eb5d304fee Mon Sep 17 00:00:00 2001 From: Shunichiro Nomura Date: Sun, 7 Jul 2024 15:42:51 +0900 Subject: [PATCH 02/12] Make attribute private --- capsula/_capsule.py | 14 +++++++-- capsula/_context/_base.py | 5 ++- capsula/_context/_command.py | 56 ++++++++++++++++++---------------- capsula/_context/_file.py | 50 +++++++++++++++--------------- capsula/_context/_function.py | 16 +++++----- capsula/_context/_git.py | 24 +++++++-------- capsula/_reporter/_base.py | 14 +++++++-- capsula/_reporter/_json.py | 16 +++++----- capsula/_watcher/_base.py | 5 ++- capsula/_watcher/_exception.py | 18 +++++------ capsula/_watcher/_time.py | 12 ++++---- tests/context/test_file.py | 12 ++++---- 12 files changed, 136 insertions(+), 106 deletions(-) diff --git a/capsula/_capsule.py b/capsula/_capsule.py index b07f5252..597ccbf8 100644 --- a/capsula/_capsule.py +++ b/capsula/_capsule.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Callable, Tuple, Union -from typing_extensions import deprecated +from typing_extensions import Annotated, deprecated if TYPE_CHECKING: from collections.abc import Mapping @@ -50,5 +50,15 @@ def callback(params: CapsuleParams) -> Self: # type: ignore[type-var,misc] # no @classmethod @deprecated("Use builder instead") - def default(cls, *args: Any, **kwargs: Any) -> Callable[[CapsuleParams], Self]: + def default( + cls, + *args: Any, + **kwargs: Any, + ) -> Annotated[ + Callable[[CapsuleParams], Self], + deprecated("""Deprecated since v0.4.0. + + Use `builder` method instead. + """), + ]: return cls.builder(*args, **kwargs) diff --git a/capsula/_context/_base.py b/capsula/_context/_base.py index 72582df7..4e5d43b6 100644 --- a/capsula/_context/_base.py +++ b/capsula/_context/_base.py @@ -7,7 +7,10 @@ class ContextBase(CapsuleItem): _subclass_registry: Final[dict[str, type[ContextBase]]] = {} - abort_on_error: bool = False + + @property + def abort_on_error(self) -> bool: + return False def __init_subclass__(cls, **kwargs: Any) -> None: if cls.__name__ in cls._subclass_registry: diff --git a/capsula/_context/_command.py b/capsula/_context/_command.py index cea34f93..51751f2c 100644 --- a/capsula/_context/_command.py +++ b/capsula/_context/_command.py @@ -31,32 +31,10 @@ def __init__( check: bool = True, abort_on_error: bool = True, ) -> None: - self.command = command - self.cwd = cwd - self.check = check - self.abort_on_error = abort_on_error - - def encapsulate(self) -> _CommandContextData: - logger.debug(f"Running command: {self.command}") - output = subprocess.run( # noqa: S602 - self.command, - shell=True, - text=True, - capture_output=True, - cwd=self.cwd, - check=self.check, - ) - logger.debug(f"Ran command: {self.command}. Result: {output}") - return { - "command": self.command, - "cwd": self.cwd, - "returncode": output.returncode, - "stdout": output.stdout, - "stderr": output.stderr, - } - - def default_key(self) -> tuple[str, str]: - return ("command", self.command) + self._command = command + self._cwd = cwd + self._check = check + self._abort_on_error = abort_on_error @classmethod def builder( @@ -84,3 +62,29 @@ def callback(params: CapsuleParams) -> CommandContext: ) return callback + + @property + def abort_on_error(self) -> bool: + return self._abort_on_error + + def encapsulate(self) -> _CommandContextData: + logger.debug(f"Running command: {self._command}") + output = subprocess.run( # noqa: S602 + self._command, + shell=True, + text=True, + capture_output=True, + cwd=self._cwd, + check=self._check, + ) + logger.debug(f"Ran command: {self._command}. Result: {output}") + return { + "command": self._command, + "cwd": self._cwd, + "returncode": output.returncode, + "stdout": output.stdout, + "stderr": output.stderr, + } + + def default_key(self) -> tuple[str, str]: + return ("command", self._command) diff --git a/capsula/_context/_file.py b/capsula/_context/_file.py index fab719e7..be21119e 100644 --- a/capsula/_context/_file.py +++ b/capsula/_context/_file.py @@ -35,60 +35,60 @@ def __init__( move_to: Path | str | None = None, ignore_missing: bool = False, ) -> None: - self.path = Path(path) - self.hash_algorithm = self._default_hash_algorithm if hash_algorithm is None else hash_algorithm - self.compute_hash = compute_hash - self.move_to = None if move_to is None else Path(move_to) - self.ignore_missing = ignore_missing + self._path = Path(path) + self._hash_algorithm = self._default_hash_algorithm if hash_algorithm is None else hash_algorithm + self._compute_hash = compute_hash + self._move_to = None if move_to is None else Path(move_to) + self._ignore_missing = ignore_missing if copy_to is None: - self.copy_to: tuple[Path, ...] = () + self._copy_to: tuple[Path, ...] = () elif isinstance(copy_to, (str, Path)): - self.copy_to = (Path(copy_to),) + self._copy_to = (Path(copy_to),) else: - self.copy_to = tuple(Path(p) for p in copy_to) + self._copy_to = tuple(Path(p) for p in copy_to) def _normalize_copy_dst_path(self, p: Path) -> Path: if p.is_dir(): - return p / self.path.name + return p / self._path.name else: return p def encapsulate(self) -> _FileContextData: - if not self.path.exists(): - if self.ignore_missing: - logger.warning(f"File {self.path} does not exist. Ignoring.") + if not self._path.exists(): + if self._ignore_missing: + logger.warning(f"File {self._path} does not exist. Ignoring.") return _FileContextData(copied_to=(), moved_to=None, hash=None) else: - msg = f"File {self.path} does not exist." + msg = f"File {self._path} does not exist." raise FileNotFoundError(msg) - self.copy_to = tuple(self._normalize_copy_dst_path(p) for p in self.copy_to) + self._copy_to = tuple(self._normalize_copy_dst_path(p) for p in self._copy_to) - if self.compute_hash: - with self.path.open("rb") as f: - digest = file_digest(f, self.hash_algorithm).hexdigest() + if self._compute_hash: + with self._path.open("rb") as f: + digest = file_digest(f, self._hash_algorithm).hexdigest() hash_data = { - "algorithm": self.hash_algorithm, + "algorithm": self._hash_algorithm, "digest": digest, } else: hash_data = None info: _FileContextData = { - "copied_to": self.copy_to, - "moved_to": self.move_to, + "copied_to": self._copy_to, + "moved_to": self._move_to, "hash": hash_data, } - for path in self.copy_to: - copyfile(self.path, path) - if self.move_to is not None: - move(str(self.path), self.move_to) + for path in self._copy_to: + copyfile(self._path, path) + if self._move_to is not None: + move(str(self._path), self._move_to) return info def default_key(self) -> tuple[str, str]: - return ("file", str(self.path)) + return ("file", str(self._path)) @classmethod def builder( diff --git a/capsula/_context/_function.py b/capsula/_context/_function.py index 03e2d02c..1997ca19 100644 --- a/capsula/_context/_function.py +++ b/capsula/_context/_function.py @@ -16,19 +16,19 @@ class _FunctionCallContextData(TypedDict): class FunctionCallContext(ContextBase): def __init__(self, function: Callable[..., Any], args: Sequence[Any], kwargs: Mapping[str, Any]) -> None: - self.function = function - self.args = args - self.kwargs = kwargs + self._function = function + self._args = args + self._kwargs = kwargs def encapsulate(self) -> _FunctionCallContextData: - file_path = Path(inspect.getfile(self.function)) - _, first_line_no = inspect.getsourcelines(self.function) + file_path = Path(inspect.getfile(self._function)) + _, first_line_no = inspect.getsourcelines(self._function) return { "file_path": file_path, "first_line_no": first_line_no, - "args": self.args, - "kwargs": self.kwargs, + "args": self._args, + "kwargs": self._kwargs, } def default_key(self) -> tuple[str, str]: - return ("function", self.function.__name__) + return ("function", self._function.__name__) diff --git a/capsula/_context/_git.py b/capsula/_context/_git.py index b3057e57..6deb6a67 100644 --- a/capsula/_context/_git.py +++ b/capsula/_context/_git.py @@ -45,15 +45,15 @@ def __init__( search_parent_directories: bool = False, allow_dirty: bool = True, ) -> None: - self.name = name - self.path = Path(path) - self.search_parent_directories = search_parent_directories - self.allow_dirty = allow_dirty - self.diff_file = None if diff_file is None else Path(diff_file) + self._name = name + self._path = Path(path) + self._search_parent_directories = search_parent_directories + self._allow_dirty = allow_dirty + self._diff_file = None if diff_file is None else Path(diff_file) def encapsulate(self) -> _GitRepositoryContextData: - repo = Repo(self.path, search_parent_directories=self.search_parent_directories) - if not self.allow_dirty and repo.is_dirty(): + repo = Repo(self._path, search_parent_directories=self._search_parent_directories) + if not self._allow_dirty and repo.is_dirty(): raise GitRepositoryDirtyError(repo) def get_optional_branch_name(repo: Repo) -> str | None: @@ -73,15 +73,15 @@ def get_optional_branch_name(repo: Repo) -> str | None: diff_txt = repo.git.diff() if diff_txt: - assert self.diff_file is not None, "diff_file is None" - with self.diff_file.open("w") as f: + assert self._diff_file is not None, "diff_file is None" + with self._diff_file.open("w") as f: f.write(diff_txt) - logger.debug(f"Wrote diff to {self.diff_file}") - info["diff_file"] = self.diff_file + logger.debug(f"Wrote diff to {self._diff_file}") + info["diff_file"] = self._diff_file return info def default_key(self) -> tuple[str, str]: - return ("git", self.name) + return ("git", self._name) @classmethod def builder( diff --git a/capsula/_reporter/_base.py b/capsula/_reporter/_base.py index 0cd43a6d..9ed9cfe7 100644 --- a/capsula/_reporter/_base.py +++ b/capsula/_reporter/_base.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any, Callable, Final -from typing_extensions import deprecated +from typing_extensions import Annotated, deprecated if TYPE_CHECKING: from capsula._backport import Self @@ -38,5 +38,15 @@ def callback(params: CapsuleParams) -> Self: # type: ignore[type-var,misc] # no @classmethod @deprecated("Use builder instead") - def default(cls, *args: Any, **kwargs: Any) -> Callable[[CapsuleParams], Self]: + def default( + cls, + *args: Any, + **kwargs: Any, + ) -> Annotated[ + Callable[[CapsuleParams], Self], + deprecated("""Deprecated since v0.4.0. + + Use `builder` method instead. + """), + ]: return cls.builder(*args, **kwargs) diff --git a/capsula/_reporter/_json.py b/capsula/_reporter/_json.py index 7bf6a8e9..4a3a2a61 100644 --- a/capsula/_reporter/_json.py +++ b/capsula/_reporter/_json.py @@ -43,12 +43,12 @@ def __init__( option: int | None = None, mkdir: bool = True, ) -> None: - self.path = Path(path) + self._path = Path(path) if mkdir: - self.path.parent.mkdir(parents=True, exist_ok=True) + self._path.parent.mkdir(parents=True, exist_ok=True) if default is None: - self.default_for_encoder = default_preset + self._default_for_encoder = default_preset else: def _default(obj: Any) -> Any: @@ -57,12 +57,12 @@ def _default(obj: Any) -> Any: except TypeError: return default(obj) - self.default_for_encoder = _default + self._default_for_encoder = _default - self.option = option + self._option = option def report(self, capsule: Capsule) -> None: - logger.debug(f"Dumping capsule to {self.path}") + logger.debug(f"Dumping capsule to {self._path}") def _str_to_tuple(s: str | tuple[str, ...]) -> tuple[str, ...]: if isinstance(s, str): @@ -73,8 +73,8 @@ def _str_to_tuple(s: str | tuple[str, ...]) -> tuple[str, ...]: if capsule.fails: nested_data["__fails"] = to_nested_dict({_str_to_tuple(k): v for k, v in capsule.fails.items()}) - json_bytes = orjson.dumps(nested_data, default=self.default_for_encoder, option=self.option) - self.path.write_bytes(json_bytes) + json_bytes = orjson.dumps(nested_data, default=self._default_for_encoder, option=self._option) + self._path.write_bytes(json_bytes) @classmethod def builder( diff --git a/capsula/_watcher/_base.py b/capsula/_watcher/_base.py index d2731bb4..bd64b927 100644 --- a/capsula/_watcher/_base.py +++ b/capsula/_watcher/_base.py @@ -14,7 +14,10 @@ class WatcherBase(CapsuleItem, ABC): _subclass_registry: Final[dict[str, type[WatcherBase]]] = {} - abort_on_error: bool = False + + @property + def abort_on_error(self) -> bool: + return False def __init_subclass__(cls, **kwargs: Any) -> None: if cls.__name__ in cls._subclass_registry: diff --git a/capsula/_watcher/_exception.py b/capsula/_watcher/_exception.py index 5958a1e1..0f3c9e62 100644 --- a/capsula/_watcher/_exception.py +++ b/capsula/_watcher/_exception.py @@ -21,22 +21,22 @@ def __init__( *, base: type[BaseException] = Exception, ) -> None: - self.name = name - self.base = base - self.exception: BaseException | None = None + self._name = name + self._base = base + self._exception: BaseException | None = None def encapsulate(self) -> ExceptionInfo: - return ExceptionInfo.from_exception(self.exception) + return ExceptionInfo.from_exception(self._exception) @contextmanager def watch(self) -> Iterator[None]: - self.exception = None + self._exception = None try: yield - except self.base as e: - logger.debug(f"UncaughtExceptionWatcher: {self.name} observed exception: {e}") - self.exception = e + except self._base as e: + logger.debug(f"UncaughtExceptionWatcher: {self._name} observed exception: {e}") + self._exception = e raise def default_key(self) -> tuple[str, str]: - return ("exception", self.name) + return ("exception", self._name) diff --git a/capsula/_watcher/_time.py b/capsula/_watcher/_time.py index 4ac555ef..92db3b74 100644 --- a/capsula/_watcher/_time.py +++ b/capsula/_watcher/_time.py @@ -16,11 +16,11 @@ class TimeWatcher(WatcherBase): def __init__(self, name: str = "execution_time") -> None: - self.name = name - self.duration: timedelta | None = None + self._name = name + self._duration: timedelta | None = None def encapsulate(self) -> timedelta | None: - return self.duration + return self._duration @contextmanager def watch(self) -> Iterator[None]: @@ -29,8 +29,8 @@ def watch(self) -> Iterator[None]: yield finally: end = time.perf_counter() - self.duration = timedelta(seconds=end - start) - logger.debug(f"TimeWatcher: {self.name} took {self.duration}.") + self._duration = timedelta(seconds=end - start) + logger.debug(f"TimeWatcher: {self._name} took {self._duration}.") def default_key(self) -> tuple[str, str]: - return ("time", self.name) + return ("time", self._name) diff --git a/tests/context/test_file.py b/tests/context/test_file.py index a79e924b..ab06ece3 100644 --- a/tests/context/test_file.py +++ b/tests/context/test_file.py @@ -23,12 +23,12 @@ def source_file(tmp_path: Path) -> Path: def test_file_context_init(source_file: Path) -> None: fc = capsula.FileContext(path=source_file) - assert fc.path == source_file - assert fc.hash_algorithm == "sha256" - assert fc.compute_hash is True - assert fc.copy_to == () - assert fc.move_to is None - assert fc.ignore_missing is False + assert fc._path == source_file + assert fc._hash_algorithm == "sha256" + assert fc._compute_hash is True + assert fc._copy_to == () + assert fc._move_to is None + assert fc._ignore_missing is False def test_file_context_hash(source_file: Path) -> None: From 367e98f01038185833db5afbd431cf8a3bc3222b Mon Sep 17 00:00:00 2001 From: Shunichiro Nomura Date: Sun, 7 Jul 2024 16:39:47 +0900 Subject: [PATCH 03/12] add pages --- README.md | 6 ++-- docs/concepts.md | 35 +++++++++++++++++++++++ docs/config.md | 70 +++++++++++++++++++++++++++++++++++++++++++++ docs/index.md | 6 ++-- docs/usage/index.md | 1 - mkdocs.yml | 7 +++-- 6 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 docs/concepts.md create mode 100644 docs/config.md delete mode 100644 docs/usage/index.md diff --git a/README.md b/README.md index fdfab62b..d901b2e6 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ With Capsula, you can capture: The captured contexts are dumped into JSON files for future reference and reproduction. -## Usage +## Usage example For project-wide settings, prepare a `capsula.toml` file in the root directory of your project. An example of the `capsula.toml` file is as follows: @@ -61,7 +61,7 @@ import random import capsula @capsula.run() -@capsula.context(capsula.FileContext.default("pi.txt", move=True), mode="post") +@capsula.context(capsula.FileContext.builder("pi.txt", move=True), mode="post") def calculate_pi(n_samples: int = 1_000, seed: int = 42) -> None: random.seed(seed) xs = (random.random() for _ in range(n_samples)) @@ -83,6 +83,8 @@ if __name__ == "__main__": calculate_pi(n_samples=1_000) ``` +After running the script, a directory (`calculate_pi_20240630_015823_S3vb` in this example) will be created under the `vault` directory, and you will find the following files there: +
Example of output pre-run-report.json:
{
diff --git a/docs/concepts.md b/docs/concepts.md
new file mode 100644
index 00000000..1fff337e
--- /dev/null
+++ b/docs/concepts.md
@@ -0,0 +1,35 @@
+# Concepts
+
+## Context
+
+A context encapsulates a piece of information at a specific point in time. It can be anything from the current working directory ([`CwdContext`](contexts/cwd.md)) to the output of a command ([`CommandContext`](contexts/command.md)). Contexts are used to capture the state of the environment in which a command/function is executed.
+
+See the [Contexts](contexts/index.md) section for the list of all the built-in contexts.
+
+## Watcher
+
+A watcher encapsulates a piece of information by monitoring the execution of a command/function. It can be used to detect uncaught exceptions ([`UncaughtExceptionWatcher`](watchers/uncaught_exception.md)) or measure the execution time ([`TimeWatcher`](watchers/time.md)). Watchers are used to monitor the execution of a command/function.
+
+See the [Watchers](watchers/index.md) section for more information.
+
+## Encapsulator and Capsule
+
+You register multiple contexts and/or watchers to an encapsulator. Then, the encapsulator encapsulates the information encapsulated by the contexts and/or watchers into a "capsule".
+
+## Reporter
+
+A reporter reports the capsule in a specific format. For example, the [`JsonDumpReporter`](reporters/json_dump.md) reports the capsule in JSON format.
+
+See the [Reporters](reporters/index.md) section for more information.
+
+## Run
+
+A run is a single execution of a command/function. It has three encapsulators: pre-run, in-run, and post-run.
+
+| Encapsulator           | When to encapsulate                             | Allowed registrations |
+| :--------------------: | :---------------------------------------------- | :-------------------: |
+| Pre-run encapsulator   | *Before* the execution of the command/function. | Contexts              |
+| In-run encapsulator    | *During* the execution of the command/function. | Watchers              |
+| Post-run encapsulator  | *After* the execution of the command/function.  | Contexts              |
+
+For each run, a run directory is created in the `vault` directory. This directory is used to store the files generated by contexts, watchers, and reporters.
diff --git a/docs/config.md b/docs/config.md
new file mode 100644
index 00000000..aaec0ae7
--- /dev/null
+++ b/docs/config.md
@@ -0,0 +1,70 @@
+# Configuration
+
+## `capsula.toml` file
+
+For project-wide settings, prepare a `capsula.toml` file in the root directory of your project. An example of the `capsula.toml` file is as follows:
+
+```toml
+[pre-run]
+contexts = [
+    { type = "CwdContext" },
+    { type = "CpuContext" },
+    { type = "GitRepositoryContext", name = "capsula", path = ".", path_relative_to_project_root = true },
+    { type = "CommandContext", command = "poetry check --lock", cwd = ".", cwd_relative_to_project_root = true },
+    { type = "FileContext", path = "pyproject.toml", copy = true, path_relative_to_project_root = true },
+    { type = "FileContext", path = "poetry.lock", copy = true, path_relative_to_project_root = true },
+    { type = "CommandContext", command = "pip freeze --exclude-editable > requirements.txt", cwd = ".", cwd_relative_to_project_root = true },
+    { type = "FileContext", path = "requirements.txt", move = true, path_relative_to_project_root = true },
+]
+reporters = [{ type = "JsonDumpReporter" }]
+
+[in-run]
+watchers = [{ type = "UncaughtExceptionWatcher" }, { type = "TimeWatcher" }]
+reporters = [{ type = "JsonDumpReporter" }]
+
+[post-run]
+reporters = [{ type = "JsonDumpReporter" }]
+```
+
+This configuration file specifies the contexts, watchers, and reporters to be used in the pre-run, in-run, and post-run encapsulators. The `JsonDumpReporter` is used to dump the captured contexts into JSON files.
+
+For each context, watcher, or reporter, the `type` field specifies the class name of the context, watcher, or reporter. The other fields are the keyword arguments to the `builder` method of the class.
+
+## Decorators
+
+For encapsulating the pre-run, in-run, and post-run capsules for a specific function, you can use the `@capsula.run()` decorator. You can also use the `@capsula.context()`, `@capsula.watcher()`, and `@capsula.reporter()` decorators to add a context, watcher, or reporter respectively to the function.
+
+The following is an example of a Python script that estimates the value of π using the Monte Carlo method:
+
+```python
+import random
+import capsula
+
+@capsula.run()
+# Register a `FileContext` to the post-run encapsulator.
+@capsula.context(capsula.FileContext.default("pi.txt", move=True), mode="post")
+def calculate_pi(n_samples: int = 1_000, seed: int = 42) -> None:
+    random.seed(seed)
+    xs = (random.random() for _ in range(n_samples))
+    ys = (random.random() for _ in range(n_samples))
+    inside = sum(x * x + y * y <= 1.0 for x, y in zip(xs, ys))
+    pi_estimate = (4.0 * inside) / n_samples
+
+    # You can record values to the capsule using the `record` method.
+    capsula.record("pi_estimate", pi_estimate)
+    # You can access the current run name using the `current_run_name` function.
+    print(f"Run name: {capsula.current_run_name()}")
+
+    with open("pi.txt", "w") as output_file:
+        output_file.write(f"Pi estimate: {pi_estimate}.")
+
+if __name__ == "__main__":
+    calculate_pi(n_samples=1_000)
+```
+
+## Order of encapsulation
+
+For each encapsulators, the order of encapsulation is as follows:
+
+1. Contexts, watchers, and reporters specified in the `capsula.toml` file, in the order of appearance (from top to bottom).
+2. Contexts, watchers, and reporters specified using the `@capsula.context()` and `@capsula.watcher()` decorators, in the order of appearance (from top to bottom).
diff --git a/docs/index.md b/docs/index.md
index fdfab62b..d901b2e6 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -25,7 +25,7 @@ With Capsula, you can capture:
 
 The captured contexts are dumped into JSON files for future reference and reproduction.
 
-## Usage
+## Usage example
 
 For project-wide settings, prepare a `capsula.toml` file in the root directory of your project. An example of the `capsula.toml` file is as follows:
 
@@ -61,7 +61,7 @@ import random
 import capsula
 
 @capsula.run()
-@capsula.context(capsula.FileContext.default("pi.txt", move=True), mode="post")
+@capsula.context(capsula.FileContext.builder("pi.txt", move=True), mode="post")
 def calculate_pi(n_samples: int = 1_000, seed: int = 42) -> None:
     random.seed(seed)
     xs = (random.random() for _ in range(n_samples))
@@ -83,6 +83,8 @@ if __name__ == "__main__":
     calculate_pi(n_samples=1_000)
 ```
 
+After running the script, a directory (`calculate_pi_20240630_015823_S3vb` in this example) will be created under the `vault` directory, and you will find the following files there:
+
 
Example of output pre-run-report.json:
{
diff --git a/docs/usage/index.md b/docs/usage/index.md
deleted file mode 100644
index 8f04b05a..00000000
--- a/docs/usage/index.md
+++ /dev/null
@@ -1 +0,0 @@
-# Usage
diff --git a/mkdocs.yml b/mkdocs.yml
index d808c8c1..edfcf6ba 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -80,9 +80,12 @@ markdown_extensions:
   - pymdownx.tasklist:
       custom_checkbox: true
   - admonition
+  - tables
 nav:
-  - Home: index.md
-  - Usage: usage/
+  - Getting Started:
+      - Welcome!: index.md
+      - Concepts: concepts.md
+      - Configuration: config.md
   - Contexts: contexts/
   - Watchers: watchers/
   - Reporters: reporters/

From df2031223ae32eb89e6cb08c11be662d7a2cd262 Mon Sep 17 00:00:00 2001
From: Shunichiro Nomura 
Date: Sun, 7 Jul 2024 16:41:46 +0900
Subject: [PATCH 04/12] add descriptions

---
 docs/contexts/index.md  | 12 ++++++------
 docs/reporters/index.md |  2 +-
 docs/watchers/index.md  |  4 ++--
 3 files changed, 9 insertions(+), 9 deletions(-)

diff --git a/docs/contexts/index.md b/docs/contexts/index.md
index 4e1433bd..6ffac248 100644
--- a/docs/contexts/index.md
+++ b/docs/contexts/index.md
@@ -2,10 +2,10 @@
 
 Capsula provides several built-in contexts that you can capture. The following is a list of built-in contexts:
 
-- [`CommandContext`](command.md)
-- [`CpuContext`](cpu.md)
-- [`CwdContext`](cwd.md)
-- [`EnvVarContext`](envvar.md)
+- [`CommandContext`](command.md) - Captures the output of shell commands.
+- [`CpuContext`](cpu.md) - Captures the CPU information.
+- [`CwdContext`](cwd.md) - Captures the current working directory.
+- [`EnvVarContext`](envvar.md) - Captures the environment variables.
 
-- [`GitRepositoryContext`](git.md)
-- [`PlatformContext`](platform.md)
+- [`GitRepositoryContext`](git.md) - Captures the Git repository information.
+- [`PlatformContext`](platform.md) - Captures the Python version.
diff --git a/docs/reporters/index.md b/docs/reporters/index.md
index cdfcc485..ab43d208 100644
--- a/docs/reporters/index.md
+++ b/docs/reporters/index.md
@@ -2,4 +2,4 @@
 
 Capsula provides several built-in reporters that report the captured contexts.
 
-- [`JsonDumpReporter`](json_dump.md)
+- [`JsonDumpReporter`](json_dump.md) - Reports the capsule in JSON format.
diff --git a/docs/watchers/index.md b/docs/watchers/index.md
index 7701860d..fcb15619 100644
--- a/docs/watchers/index.md
+++ b/docs/watchers/index.md
@@ -2,5 +2,5 @@
 
 Capsula provides several built-in watchers that you can use to monitor the execution of your command/function. The following is a list of built-in watchers:
 
-- [`TimeWatcher`](time.md)
-- [`UncaughtExceptionWatcher`](uncaught_exception.md)
+- [`TimeWatcher`](time.md) - Monitors the execution time.
+- [`UncaughtExceptionWatcher`](uncaught_exception.md) - Monitors uncaught exceptions.

From 43e5300847d1508582c0753f13ede6165f4287d8 Mon Sep 17 00:00:00 2001
From: Shunichiro Nomura 
Date: Sun, 7 Jul 2024 16:49:05 +0900
Subject: [PATCH 05/12] change icon and colors

---
 mkdocs.yml | 16 ++++++++--------
 1 file changed, 8 insertions(+), 8 deletions(-)

diff --git a/mkdocs.yml b/mkdocs.yml
index edfcf6ba..e30469c1 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -3,7 +3,7 @@ site_url: https://shunichironomura.github.io/capsula/
 theme:
   name: material
   icon:
-    logo: material/satellite-variant
+    logo: material/package-variant-closed
     repo: fontawesome/brands/github
   palette:
     # Palette toggle for automatic mode
@@ -11,8 +11,8 @@ theme:
       toggle:
         icon: material/brightness-auto
         name: Switch to light mode
-      primary: yellow
-      accent: yellow
+      primary: blue grey
+      accent: blue grey
 
       # Palette toggle for light mode
     - media: "(prefers-color-scheme: light)"
@@ -20,8 +20,8 @@ theme:
       toggle:
         icon: material/brightness-7
         name: Switch to dark mode
-      primary: yellow
-      accent: yellow
+      primary: blue grey
+      accent: blue grey
 
     # Palette toggle for dark mode
     - media: "(prefers-color-scheme: dark)"
@@ -29,8 +29,8 @@ theme:
       toggle:
         icon: material/brightness-4
         name: Switch to system preference
-      primary: yellow
-      accent: yellow
+      primary: blue grey
+      accent: blue grey
   features:
     - navigation.instant
     - navigation.tabs
@@ -89,7 +89,7 @@ nav:
   - Contexts: contexts/
   - Watchers: watchers/
   - Reporters: reporters/
-  - API Reference: reference/
+  # - API Reference: reference/
 extra:
   version:
     provider: mike

From 494c3ed558bf8d6f9638fef9c5efd97e0a1c0647 Mon Sep 17 00:00:00 2001
From: Shunichiro Nomura 
Date: Sun, 7 Jul 2024 16:53:22 +0900
Subject: [PATCH 06/12] Update Python and poetry version

---
 .github/workflows/test.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6090305e..b19548c2 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -26,8 +26,8 @@ on:
     types: [opened, synchronize, reopened, ready_for_review]
 
 env:
-  PYTHON_VERSION: "3.11"
-  POETRY_VERSION: "1.7.0"
+  PYTHON_VERSION: "3.12"
+  POETRY_VERSION: "1.8.3"
 
 jobs:
   ruff:

From 0f79a739c0aa76bf832298261c6fe04b19423f92 Mon Sep 17 00:00:00 2001
From: Shunichiro Nomura 
Date: Sun, 7 Jul 2024 16:54:01 +0900
Subject: [PATCH 07/12] add build-docs step

---
 .github/workflows/test.yml | 24 ++++++++++++++++++++++--
 1 file changed, 22 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b19548c2..2d744428 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -64,11 +64,31 @@ jobs:
           poetry-version: ${{ env.POETRY_VERSION }}
 
       - name: Install dependencies
-        run: poetry install --no-interaction
+        run: poetry install --no-interaction --only dev
 
       - name: Run mypy
         run: poetry run --no-interaction -- mypy --python-version ${{ matrix.python-version }} .
 
+  build-docs:
+    if: github.event.pull_request.draft == false
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: ${{ env.PYTHON_VERSION }}
+
+      - name: Set up Poetry
+        uses: abatilo/actions-poetry@v3
+        with:
+          poetry-version: ${{ env.POETRY_VERSION }}
+
+      - name: Install dependencies
+        run: poetry install --no-interaction --only docs
+
+      - name: Build docs
+        run: poetry run --no-interaction mkdocs build
+
   pytest:
     if: github.event.pull_request.draft == false
     runs-on: ${{ matrix.os }}
@@ -107,7 +127,7 @@ jobs:
         poetry-version: ${{ env.POETRY_VERSION }}
 
     - name: Install dependencies
-      run: poetry install --no-interaction
+      run: poetry install --no-interaction --only dev
 
     - name: Run pytest
       run: poetry run --no-interaction -- coverage run --source=./capsula -m pytest ./tests --import-mode importlib

From 1ab13479b3e450124186907de84c18adeb2bb85b Mon Sep 17 00:00:00 2001
From: Shunichiro Nomura 
Date: Sun, 7 Jul 2024 16:54:08 +0900
Subject: [PATCH 08/12] add deploy-docs workflow

---
 .github/workflows/deploy-docs.yml | 50 +++++++++++++++++++++++++++++++
 1 file changed, 50 insertions(+)
 create mode 100644 .github/workflows/deploy-docs.yml

diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
new file mode 100644
index 00000000..43ee74cb
--- /dev/null
+++ b/.github/workflows/deploy-docs.yml
@@ -0,0 +1,50 @@
+name: Deploy docs
+
+on:
+  push:
+    branches:
+      - main
+    paths:
+      - ".github/workflows/deploy-docs.yml"
+      - "capsula/**"
+      - "docs/**"
+      - "scripts/gen_ref_pages.py"
+      - "mkdocs.yml"
+      - "pyproject.toml"
+
+
+env:
+  PYTHON_VERSION: "3.12"
+  POETRY_VERSION: "1.8.3"
+
+concurrency:
+  group: "deploy-docs"
+  cancel-in-progress: true
+
+jobs:
+  deploy-docs:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write
+    steps:
+      - uses: actions/checkout@v4
+      - uses: actions/setup-python@v5
+        with:
+          python-version: ${{ env.PYTHON_VERSION }}
+
+      - name: Set up Poetry
+        uses: abatilo/actions-poetry@v3
+        with:
+          poetry-version: ${{ env.POETRY_VERSION }}
+
+      - name: Install dependencies
+        run: poetry install --no-interaction --only docs
+
+      - name: Set up Git
+        run: |
+          git config --global user.name "github-actions"
+          git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
+          git fetch --prune --unshallow
+
+      - name: Update the docs
+        run: poetry run --no-interaction -- mike deploy --push dev

From fce327a23cd233cff9ca1642a277e5c610d7f937 Mon Sep 17 00:00:00 2001
From: Shunichiro Nomura 
Date: Sun, 7 Jul 2024 16:57:30 +0900
Subject: [PATCH 09/12] add order of methods

---
 capsula/_context/_command.py | 26 ++++++------
 capsula/_context/_file.py    | 68 +++++++++++++++----------------
 capsula/_context/_git.py     | 78 ++++++++++++++++++------------------
 capsula/_reporter/_json.py   | 28 ++++++-------
 4 files changed, 100 insertions(+), 100 deletions(-)

diff --git a/capsula/_context/_command.py b/capsula/_context/_command.py
index 51751f2c..b3d71716 100644
--- a/capsula/_context/_command.py
+++ b/capsula/_context/_command.py
@@ -23,19 +23,6 @@ class _CommandContextData(TypedDict):
 
 
 class CommandContext(ContextBase):
-    def __init__(
-        self,
-        command: str,
-        *,
-        cwd: Path | None = None,
-        check: bool = True,
-        abort_on_error: bool = True,
-    ) -> None:
-        self._command = command
-        self._cwd = cwd
-        self._check = check
-        self._abort_on_error = abort_on_error
-
     @classmethod
     def builder(
         cls,
@@ -63,6 +50,19 @@ def callback(params: CapsuleParams) -> CommandContext:
 
         return callback
 
+    def __init__(
+        self,
+        command: str,
+        *,
+        cwd: Path | None = None,
+        check: bool = True,
+        abort_on_error: bool = True,
+    ) -> None:
+        self._command = command
+        self._cwd = cwd
+        self._check = check
+        self._abort_on_error = abort_on_error
+
     @property
     def abort_on_error(self) -> bool:
         return self._abort_on_error
diff --git a/capsula/_context/_file.py b/capsula/_context/_file.py
index be21119e..f2578c6f 100644
--- a/capsula/_context/_file.py
+++ b/capsula/_context/_file.py
@@ -25,6 +25,40 @@ class _FileContextData(TypedDict):
 class FileContext(ContextBase):
     _default_hash_algorithm = "sha256"
 
+    @classmethod
+    def builder(
+        cls,
+        path: Path | str,
+        *,
+        compute_hash: bool = True,
+        hash_algorithm: str | None = None,
+        copy: bool = False,
+        move: bool = False,
+        ignore_missing: bool = False,
+        path_relative_to_project_root: bool = False,
+    ) -> Callable[[CapsuleParams], FileContext]:
+        if copy and move:
+            warnings.warn("Both copy and move are True. Only move will be performed.", UserWarning, stacklevel=2)
+            move = True
+            copy = False
+
+        def callback(params: CapsuleParams) -> FileContext:
+            if path_relative_to_project_root and path is not None and not Path(path).is_absolute():
+                file_path = params.project_root / path
+            else:
+                file_path = Path(path)
+
+            return cls(
+                path=file_path,
+                compute_hash=compute_hash,
+                hash_algorithm=hash_algorithm,
+                copy_to=params.run_dir if copy else None,
+                move_to=params.run_dir if move else None,
+                ignore_missing=ignore_missing,
+            )
+
+        return callback
+
     def __init__(
         self,
         path: Path | str,
@@ -89,37 +123,3 @@ def encapsulate(self) -> _FileContextData:
 
     def default_key(self) -> tuple[str, str]:
         return ("file", str(self._path))
-
-    @classmethod
-    def builder(
-        cls,
-        path: Path | str,
-        *,
-        compute_hash: bool = True,
-        hash_algorithm: str | None = None,
-        copy: bool = False,
-        move: bool = False,
-        ignore_missing: bool = False,
-        path_relative_to_project_root: bool = False,
-    ) -> Callable[[CapsuleParams], FileContext]:
-        if copy and move:
-            warnings.warn("Both copy and move are True. Only move will be performed.", UserWarning, stacklevel=2)
-            move = True
-            copy = False
-
-        def callback(params: CapsuleParams) -> FileContext:
-            if path_relative_to_project_root and path is not None and not Path(path).is_absolute():
-                file_path = params.project_root / path
-            else:
-                file_path = Path(path)
-
-            return cls(
-                path=file_path,
-                compute_hash=compute_hash,
-                hash_algorithm=hash_algorithm,
-                copy_to=params.run_dir if copy else None,
-                move_to=params.run_dir if move else None,
-                ignore_missing=ignore_missing,
-            )
-
-        return callback
diff --git a/capsula/_context/_git.py b/capsula/_context/_git.py
index 6deb6a67..d3758b9d 100644
--- a/capsula/_context/_git.py
+++ b/capsula/_context/_git.py
@@ -36,6 +36,45 @@ class _GitRepositoryContextData(TypedDict):
 
 
 class GitRepositoryContext(ContextBase):
+    @classmethod
+    def builder(
+        cls,
+        name: str | None = None,
+        *,
+        path: Path | str | None = None,
+        path_relative_to_project_root: bool = False,
+        allow_dirty: bool | None = None,
+    ) -> Callable[[CapsuleParams], GitRepositoryContext]:
+        def callback(params: CapsuleParams) -> GitRepositoryContext:
+            if path_relative_to_project_root and path is not None and not Path(path).is_absolute():
+                repository_path: Path | None = params.project_root / path
+            else:
+                repository_path = Path(path) if path is not None else None
+
+            if repository_path is not None:
+                repo = Repo(repository_path, search_parent_directories=False)
+            else:
+                if isinstance(params.exec_info, FuncInfo):
+                    repo_search_start_path = Path(inspect.getfile(params.exec_info.func)).parent
+                elif isinstance(params.exec_info, CommandInfo) or params.exec_info is None:
+                    repo_search_start_path = Path.cwd()
+                else:
+                    msg = f"exec_info must be an instance of FuncInfo or CommandInfo, not {type(params.exec_info)}."
+                    raise TypeError(msg)
+                repo = Repo(repo_search_start_path, search_parent_directories=True)
+
+            repo_name = Path(repo.working_dir).name
+
+            return cls(
+                name=Path(repo.working_dir).name if name is None else name,
+                path=Path(repo.working_dir),
+                diff_file=params.run_dir / f"{repo_name}.diff",
+                search_parent_directories=False,
+                allow_dirty=True if allow_dirty is None else allow_dirty,
+            )
+
+        return callback
+
     def __init__(
         self,
         name: str,
@@ -82,42 +121,3 @@ def get_optional_branch_name(repo: Repo) -> str | None:
 
     def default_key(self) -> tuple[str, str]:
         return ("git", self._name)
-
-    @classmethod
-    def builder(
-        cls,
-        name: str | None = None,
-        *,
-        path: Path | str | None = None,
-        path_relative_to_project_root: bool = False,
-        allow_dirty: bool | None = None,
-    ) -> Callable[[CapsuleParams], GitRepositoryContext]:
-        def callback(params: CapsuleParams) -> GitRepositoryContext:
-            if path_relative_to_project_root and path is not None and not Path(path).is_absolute():
-                repository_path: Path | None = params.project_root / path
-            else:
-                repository_path = Path(path) if path is not None else None
-
-            if repository_path is not None:
-                repo = Repo(repository_path, search_parent_directories=False)
-            else:
-                if isinstance(params.exec_info, FuncInfo):
-                    repo_search_start_path = Path(inspect.getfile(params.exec_info.func)).parent
-                elif isinstance(params.exec_info, CommandInfo) or params.exec_info is None:
-                    repo_search_start_path = Path.cwd()
-                else:
-                    msg = f"exec_info must be an instance of FuncInfo or CommandInfo, not {type(params.exec_info)}."
-                    raise TypeError(msg)
-                repo = Repo(repo_search_start_path, search_parent_directories=True)
-
-            repo_name = Path(repo.working_dir).name
-
-            return cls(
-                name=Path(repo.working_dir).name if name is None else name,
-                path=Path(repo.working_dir),
-                diff_file=params.run_dir / f"{repo_name}.diff",
-                search_parent_directories=False,
-                allow_dirty=True if allow_dirty is None else allow_dirty,
-            )
-
-        return callback
diff --git a/capsula/_reporter/_json.py b/capsula/_reporter/_json.py
index 4a3a2a61..d0b8725c 100644
--- a/capsula/_reporter/_json.py
+++ b/capsula/_reporter/_json.py
@@ -35,6 +35,20 @@ def default_preset(obj: Any) -> Any:
 
 
 class JsonDumpReporter(ReporterBase):
+    @classmethod
+    def builder(
+        cls,
+        *,
+        option: int | None = None,
+    ) -> Callable[[CapsuleParams], JsonDumpReporter]:
+        def callback(params: CapsuleParams) -> JsonDumpReporter:
+            return cls(
+                params.run_dir / f"{params.phase}-run-report.json",
+                option=orjson.OPT_INDENT_2 if option is None else option,
+            )
+
+        return callback
+
     def __init__(
         self,
         path: Path | str,
@@ -75,17 +89,3 @@ def _str_to_tuple(s: str | tuple[str, ...]) -> tuple[str, ...]:
 
         json_bytes = orjson.dumps(nested_data, default=self._default_for_encoder, option=self._option)
         self._path.write_bytes(json_bytes)
-
-    @classmethod
-    def builder(
-        cls,
-        *,
-        option: int | None = None,
-    ) -> Callable[[CapsuleParams], JsonDumpReporter]:
-        def callback(params: CapsuleParams) -> JsonDumpReporter:
-            return cls(
-                params.run_dir / f"{params.phase}-run-report.json",
-                option=orjson.OPT_INDENT_2 if option is None else option,
-            )
-
-        return callback

From 3491ec92f88cf04893dfb289f71b343b9800a423 Mon Sep 17 00:00:00 2001
From: Shunichiro Nomura 
Date: Sun, 7 Jul 2024 17:24:08 +0900
Subject: [PATCH 10/12] add docs to arguments of primary methods

---
 capsula/_context/_command.py   | 34 +++++++++++++++++++++++++++++-----
 capsula/_context/_cpu.py       |  2 ++
 capsula/_context/_cwd.py       |  2 ++
 capsula/_context/_envvar.py    |  6 +++++-
 capsula/_context/_file.py      | 29 ++++++++++++++++++++++-------
 capsula/_context/_git.py       | 21 ++++++++++++++++-----
 capsula/_context/_platform.py  |  2 ++
 capsula/_reporter/_json.py     |  8 +++++++-
 capsula/_watcher/_exception.py | 12 ++++++++++--
 capsula/_watcher/_time.py      |  7 ++++++-
 docs/config.md                 |  2 +-
 11 files changed, 102 insertions(+), 23 deletions(-)

diff --git a/capsula/_context/_command.py b/capsula/_context/_command.py
index b3d71716..807ff54c 100644
--- a/capsula/_context/_command.py
+++ b/capsula/_context/_command.py
@@ -5,6 +5,8 @@
 from pathlib import Path
 from typing import TYPE_CHECKING, Callable, TypedDict
 
+from typing_extensions import Annotated, Doc
+
 from ._base import ContextBase
 
 if TYPE_CHECKING:
@@ -23,15 +25,37 @@ class _CommandContextData(TypedDict):
 
 
 class CommandContext(ContextBase):
+    """Context to capture the output of a command run in a subprocess."""
+
     @classmethod
     def builder(
         cls,
-        command: str,
+        command: Annotated[str, Doc("Command to run")],
         *,
-        cwd: Path | str | None = None,
-        check: bool = True,
-        abort_on_error: bool = True,
-        cwd_relative_to_project_root: bool = False,
+        cwd: Annotated[
+            Path | str | None,
+            Doc("Working directory for the command, passed to the `cwd` argument of `subprocess.run`"),
+        ] = None,
+        check: Annotated[
+            bool,
+            Doc(
+                "Whether to raise an exception if the command returns a non-zero exit code, passed to the `check` "
+                "argument of `subprocess.run",
+            ),
+        ] = True,
+        abort_on_error: Annotated[
+            bool,
+            Doc("Whether to abort the encapsulation if the command returns a non-zero exit code"),
+        ] = True,
+        cwd_relative_to_project_root: Annotated[
+            bool,
+            Doc(
+                "Whether `cwd` argument is relative to the project root. Will be ignored if `cwd` is None or absolute. "
+                "If True, it will be interpreted as relative to the project root. "
+                "If False, `cwd` will be interpreted as relative to the current working directory. "
+                "It is recommended to set this to True in the configuration file.",
+            ),
+        ] = False,
     ) -> Callable[[CapsuleParams], CommandContext]:
         def callback(params: CapsuleParams) -> CommandContext:
             if cwd_relative_to_project_root and cwd is not None and not Path(cwd).is_absolute():
diff --git a/capsula/_context/_cpu.py b/capsula/_context/_cpu.py
index ab99ac28..2c128a63 100644
--- a/capsula/_context/_cpu.py
+++ b/capsula/_context/_cpu.py
@@ -8,6 +8,8 @@
 
 
 class CpuContext(ContextBase):
+    """Context to capture CPU information."""
+
     def encapsulate(self) -> dict[str, Any]:
         return get_cpu_info()  # type: ignore[no-any-return]
 
diff --git a/capsula/_context/_cwd.py b/capsula/_context/_cwd.py
index fa804ea1..c17a1aed 100644
--- a/capsula/_context/_cwd.py
+++ b/capsula/_context/_cwd.py
@@ -4,6 +4,8 @@
 
 
 class CwdContext(ContextBase):
+    """Context to capture the current working directory."""
+
     def encapsulate(self) -> Path:
         return Path.cwd()
 
diff --git a/capsula/_context/_envvar.py b/capsula/_context/_envvar.py
index 96283bec..20447435 100644
--- a/capsula/_context/_envvar.py
+++ b/capsula/_context/_envvar.py
@@ -2,11 +2,15 @@
 
 import os
 
+from typing_extensions import Annotated, Doc
+
 from ._base import ContextBase
 
 
 class EnvVarContext(ContextBase):
-    def __init__(self, name: str) -> None:
+    """Context to capture an environment variable."""
+
+    def __init__(self, name: Annotated[str, Doc("Name of the environment variable")]) -> None:
         self.name = name
 
     def encapsulate(self) -> str | None:
diff --git a/capsula/_context/_file.py b/capsula/_context/_file.py
index f2578c6f..87412ba7 100644
--- a/capsula/_context/_file.py
+++ b/capsula/_context/_file.py
@@ -6,6 +6,8 @@
 from shutil import copyfile, move
 from typing import TYPE_CHECKING, Callable, Iterable, TypedDict
 
+from typing_extensions import Annotated, Doc
+
 from capsula._backport import file_digest
 
 from ._base import ContextBase
@@ -23,19 +25,32 @@ class _FileContextData(TypedDict):
 
 
 class FileContext(ContextBase):
+    """Context to capture a file."""
+
     _default_hash_algorithm = "sha256"
 
     @classmethod
     def builder(
         cls,
-        path: Path | str,
+        path: Annotated[Path | str, Doc("Path to the file")],
         *,
-        compute_hash: bool = True,
-        hash_algorithm: str | None = None,
-        copy: bool = False,
-        move: bool = False,
-        ignore_missing: bool = False,
-        path_relative_to_project_root: bool = False,
+        compute_hash: Annotated[bool, Doc("Whether to compute the hash of the file")] = True,
+        hash_algorithm: Annotated[
+            str | None,
+            Doc("Hash algorithm to use. This will be fed to `hashlib.file_digest` as the `digest` argument."),
+        ] = None,
+        copy: Annotated[bool, Doc("Whether to copy the file to the run directory")] = False,
+        move: Annotated[bool, Doc("Whether to move the file to the run directory")] = False,
+        ignore_missing: Annotated[bool, Doc("Whether to ignore if the file does not exist")] = False,
+        path_relative_to_project_root: Annotated[
+            bool,
+            Doc(
+                "Whether `path` is relative to the project root. Will be ignored if `path` is absolute."
+                "If True, it will be interpreted as relative to the project root. "
+                "If False, `path` will be interpreted as relative to the current working directory. "
+                "It is recommended to set this to True in the configuration file.",
+            ),
+        ] = False,
     ) -> Callable[[CapsuleParams], FileContext]:
         if copy and move:
             warnings.warn("Both copy and move are True. Only move will be performed.", UserWarning, stacklevel=2)
diff --git a/capsula/_context/_git.py b/capsula/_context/_git.py
index d3758b9d..e93cf25f 100644
--- a/capsula/_context/_git.py
+++ b/capsula/_context/_git.py
@@ -6,6 +6,7 @@
 from typing import TYPE_CHECKING, Callable, TypedDict
 
 from git.repo import Repo
+from typing_extensions import Annotated, Doc
 
 from capsula._exceptions import CapsulaError
 from capsula._run import CommandInfo, FuncInfo
@@ -36,14 +37,24 @@ class _GitRepositoryContextData(TypedDict):
 
 
 class GitRepositoryContext(ContextBase):
+    """Context to capture a Git repository."""
+
     @classmethod
     def builder(
         cls,
-        name: str | None = None,
+        name: Annotated[str | None, Doc("Name of the Git repository")] = None,
         *,
-        path: Path | str | None = None,
-        path_relative_to_project_root: bool = False,
-        allow_dirty: bool | None = None,
+        path: Annotated[Path | str | None, Doc("Path to the Git repository")] = None,
+        path_relative_to_project_root: Annotated[
+            bool,
+            Doc(
+                "Whether `path` is relative to the project root. Will be ignored if `path` is None or absolute. "
+                "If True, it will be interpreted as relative to the project root. "
+                "If False, `path` will be interpreted as relative to the current working directory. "
+                "It is recommended to set this to True in the configuration file.",
+            ),
+        ] = False,
+        allow_dirty: Annotated[bool, Doc("Whether to allow the repository to be dirty")] = True,
     ) -> Callable[[CapsuleParams], GitRepositoryContext]:
         def callback(params: CapsuleParams) -> GitRepositoryContext:
             if path_relative_to_project_root and path is not None and not Path(path).is_absolute():
@@ -70,7 +81,7 @@ def callback(params: CapsuleParams) -> GitRepositoryContext:
                 path=Path(repo.working_dir),
                 diff_file=params.run_dir / f"{repo_name}.diff",
                 search_parent_directories=False,
-                allow_dirty=True if allow_dirty is None else allow_dirty,
+                allow_dirty=allow_dirty,
             )
 
         return callback
diff --git a/capsula/_context/_platform.py b/capsula/_context/_platform.py
index 4b0178e5..102e2bee 100644
--- a/capsula/_context/_platform.py
+++ b/capsula/_context/_platform.py
@@ -18,6 +18,8 @@ class _PlatformContextData(TypedDict):
 
 
 class PlatformContext(ContextBase):
+    """Context to capture platform information, including Python version."""
+
     def encapsulate(self) -> _PlatformContextData:
         return {
             "machine": pf.machine(),
diff --git a/capsula/_reporter/_json.py b/capsula/_reporter/_json.py
index d0b8725c..e249aa5d 100644
--- a/capsula/_reporter/_json.py
+++ b/capsula/_reporter/_json.py
@@ -8,6 +8,7 @@
 from typing import TYPE_CHECKING, Any, Callable
 
 import orjson
+from typing_extensions import Annotated, Doc
 
 from capsula._utils import to_nested_dict
 
@@ -35,11 +36,16 @@ def default_preset(obj: Any) -> Any:
 
 
 class JsonDumpReporter(ReporterBase):
+    """Reporter to dump the capsule to a JSON file."""
+
     @classmethod
     def builder(
         cls,
         *,
-        option: int | None = None,
+        option: Annotated[
+            int | None,
+            Doc("Option to pass to `orjson.dumps`. If not provided, `orjson.OPT_INDENT_2` will be used."),
+        ] = None,
     ) -> Callable[[CapsuleParams], JsonDumpReporter]:
         def callback(params: CapsuleParams) -> JsonDumpReporter:
             return cls(
diff --git a/capsula/_watcher/_exception.py b/capsula/_watcher/_exception.py
index 0f3c9e62..14ce2f24 100644
--- a/capsula/_watcher/_exception.py
+++ b/capsula/_watcher/_exception.py
@@ -4,6 +4,8 @@
 from contextlib import contextmanager
 from typing import TYPE_CHECKING
 
+from typing_extensions import Annotated, Doc
+
 if TYPE_CHECKING:
     from collections.abc import Iterator
 
@@ -15,11 +17,17 @@
 
 
 class UncaughtExceptionWatcher(WatcherBase):
+    """Watcher to capture an uncaught exception.
+
+    This watcher captures an uncaught exception and stores it in the context.
+    Note that it does not consume the exception, so it will still be raised.
+    """
+
     def __init__(
         self,
-        name: str = "exception",
+        name: Annotated[str, Doc("Name of the exception. Used as a key in the output.")] = "exception",
         *,
-        base: type[BaseException] = Exception,
+        base: Annotated[type[BaseException], Doc("Base exception class to catch.")] = Exception,
     ) -> None:
         self._name = name
         self._base = base
diff --git a/capsula/_watcher/_time.py b/capsula/_watcher/_time.py
index 92db3b74..e7d1b4cc 100644
--- a/capsula/_watcher/_time.py
+++ b/capsula/_watcher/_time.py
@@ -6,6 +6,8 @@
 from datetime import timedelta
 from typing import TYPE_CHECKING
 
+from typing_extensions import Annotated, Doc
+
 if TYPE_CHECKING:
     from collections.abc import Iterator
 
@@ -15,7 +17,10 @@
 
 
 class TimeWatcher(WatcherBase):
-    def __init__(self, name: str = "execution_time") -> None:
+    def __init__(
+        self,
+        name: Annotated[str, Doc("Name of the time watcher. Used as a key in the output.")] = "execution_time",
+    ) -> None:
         self._name = name
         self._duration: timedelta | None = None
 
diff --git a/docs/config.md b/docs/config.md
index aaec0ae7..6f51e4d1 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -28,7 +28,7 @@ reporters = [{ type = "JsonDumpReporter" }]
 
 This configuration file specifies the contexts, watchers, and reporters to be used in the pre-run, in-run, and post-run encapsulators. The `JsonDumpReporter` is used to dump the captured contexts into JSON files.
 
-For each context, watcher, or reporter, the `type` field specifies the class name of the context, watcher, or reporter. The other fields are the keyword arguments to the `builder` method of the class.
+For each context, watcher, or reporter, the `type` field specifies the class name of the context, watcher, or reporter. The other fields are used as the keyword arguments to the `builder` method of the class to create an instance of the class. If the class does not implement the `builder` method, the `__init__` method is used instead.
 
 ## Decorators
 

From 1e245884ac2de500d1d608cc3d06b26614cf4f3d Mon Sep 17 00:00:00 2001
From: Shunichiro Nomura 
Date: Sun, 7 Jul 2024 17:29:23 +0900
Subject: [PATCH 11/12] chore: Update poetry installation command to remove
 `--only docs` option

---
 .github/workflows/deploy-docs.yml | 2 +-
 .github/workflows/test.yml        | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
index 43ee74cb..3c738353 100644
--- a/.github/workflows/deploy-docs.yml
+++ b/.github/workflows/deploy-docs.yml
@@ -38,7 +38,7 @@ jobs:
           poetry-version: ${{ env.POETRY_VERSION }}
 
       - name: Install dependencies
-        run: poetry install --no-interaction --only docs
+        run: poetry install --no-interaction
 
       - name: Set up Git
         run: |
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 2d744428..4523ce4e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -84,7 +84,7 @@ jobs:
           poetry-version: ${{ env.POETRY_VERSION }}
 
       - name: Install dependencies
-        run: poetry install --no-interaction --only docs
+        run: poetry install --no-interaction
 
       - name: Build docs
         run: poetry run --no-interaction mkdocs build
@@ -127,7 +127,7 @@ jobs:
         poetry-version: ${{ env.POETRY_VERSION }}
 
     - name: Install dependencies
-      run: poetry install --no-interaction --only dev
+      run: poetry install --no-interaction
 
     - name: Run pytest
       run: poetry run --no-interaction -- coverage run --source=./capsula -m pytest ./tests --import-mode importlib

From 3b4f510069a8cf91e79a1b54ebf5e1ddc82714ee Mon Sep 17 00:00:00 2001
From: Shunichiro Nomura 
Date: Sun, 7 Jul 2024 17:32:09 +0900
Subject: [PATCH 12/12] chore: Update poetry installation command to remove
 `--only dev` option

---
 .github/workflows/test.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4523ce4e..390379a7 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -64,7 +64,7 @@ jobs:
           poetry-version: ${{ env.POETRY_VERSION }}
 
       - name: Install dependencies
-        run: poetry install --no-interaction --only dev
+        run: poetry install --no-interaction
 
       - name: Run mypy
         run: poetry run --no-interaction -- mypy --python-version ${{ matrix.python-version }} .