Skip to content

Commit

Permalink
Merge pull request #261 from shunichironomura/add-docs
Browse files Browse the repository at this point in the history
  • Loading branch information
shunichironomura authored Jul 7, 2024
2 parents 93bed03 + 3b4f510 commit 1ff6b1c
Show file tree
Hide file tree
Showing 38 changed files with 1,042 additions and 227 deletions.
50 changes: 50 additions & 0 deletions .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
@@ -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

- 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
24 changes: 22 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -69,6 +69,26 @@ jobs:
- 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

- name: Build docs
run: poetry run --no-interaction mkdocs build

pytest:
if: github.event.pull_request.draft == false
runs-on: ${{ matrix.os }}
Expand Down
39 changes: 24 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
The captured contexts are dumped into JSON files for future reference and reproduction.

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.
## Usage example

## 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" }]

Expand All @@ -54,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))
Expand All @@ -76,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:

<details>
<summary>Example of output <code>pre-run-report.json</code>:</summary>
<pre><code>{
Expand Down
14 changes: 12 additions & 2 deletions capsula/_capsule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
5 changes: 4 additions & 1 deletion capsula/_context/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
106 changes: 67 additions & 39 deletions capsula/_context/_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -23,50 +25,37 @@ 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

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)
"""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():
Expand All @@ -84,3 +73,42 @@ 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

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)
2 changes: 2 additions & 0 deletions capsula/_context/_cpu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
2 changes: 2 additions & 0 deletions capsula/_context/_cwd.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@


class CwdContext(ContextBase):
"""Context to capture the current working directory."""

def encapsulate(self) -> Path:
return Path.cwd()

Expand Down
6 changes: 5 additions & 1 deletion capsula/_context/_envvar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading

0 comments on commit 1ff6b1c

Please sign in to comment.