Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
051b199
feat: switch to modular monolith
rorybyrne Dec 30, 2024
540b09d
feat: lab plan works
rorybyrne Dec 31, 2024
1d7fab2
wip: unify models and install sqlalchemy
rorybyrne Dec 31, 2024
a0ecf06
feat: add `make_db` function to create SQLite database connection usi…
rorybyrne Dec 31, 2024
c0a7bf1
feat: Add persistence layer for runs
rorybyrne Dec 31, 2024
6c29b9b
feat: Implement abstract repository and in-memory run repository
rorybyrne Dec 31, 2024
df28526
refactor: Implement generic Repository and update RunRepository
rorybyrne Dec 31, 2024
7ac275e
fix: remove unused memory.py file
rorybyrne Dec 31, 2024
5d6b202
feat: Implement in-memory repositories for ProjectRun and ExperimentRun
rorybyrne Dec 31, 2024
bcc4ad7
refactor: migrate to async runtime and repository pattern
rorybyrne Dec 31, 2024
1c1db37
docs(cli): add source reference for AsyncTyper class implementation
rorybyrne Dec 31, 2024
201629d
test: Add plan service unit tests
rorybyrne Jan 1, 2025
7b6c4a0
feat: Add tests for PlanService
rorybyrne Jan 1, 2025
3b15437
feat: Add args to ScriptExecution in Experiment
rorybyrne Jan 1, 2025
3c33376
Here is the commit message for the changes:
rorybyrne Jan 1, 2025
7207e55
test(parser): improve test_parser assertions and update import path
rorybyrne Jan 1, 2025
247faf1
feat: migrate from rye to uv package manager for improved dependency …
rorybyrne Jan 1, 2025
c6dbb68
chore: lint fixes
rorybyrne Jan 1, 2025
dc5a3df
ci: add GitHub Actions workflow for running tests and linting on Pyth…
rorybyrne Jan 1, 2025
3192b51
ci: Add support for Python 3.9 and upwards
rorybyrne Jan 1, 2025
d1ae7a1
feat: Add logging and UI modules
rorybyrne Jan 1, 2025
79f8961
fix: Implement logging and UI separation in CLI tool
rorybyrne Jan 1, 2025
85021b2
fix: Use null handler for root logger when no log file is specified
rorybyrne Jan 1, 2025
d150c87
fix: Update logging configuration to use string keys
rorybyrne Jan 1, 2025
015e0dc
feat: Update RunService to use event-specific subscriber lists
rorybyrne Jan 1, 2025
aa80b33
feat: Add subscribers to RunService constructor
rorybyrne Jan 1, 2025
d82f17a
feat: Add experiment start event rendering to run command
rorybyrne Jan 1, 2025
84c1aa8
feat: Add methods to UI class to render experiment events
rorybyrne Jan 1, 2025
c192a9b
feat: Add type annotations for subscribers in RunService
rorybyrne Jan 1, 2025
34ce36f
fix: Update RunService subscribers type to accept ExperimentRunEvent …
rorybyrne Jan 1, 2025
e7f848b
feat: Add ExperimentRunEvent import to ui.py
rorybyrne Jan 1, 2025
7fbe782
fix: Fix imports and type annotations in run.py
rorybyrne Jan 1, 2025
cb193c9
fix: Update type hints in `_emit_event` method
rorybyrne Jan 1, 2025
34b354f
fix: Use generic EventHandler to ensure type safety
rorybyrne Jan 1, 2025
f150ffd
fix: Improve logging in RunService._emit_event
rorybyrne Jan 1, 2025
15c5b98
fix: Add type checks to _emit_event method in RunService
rorybyrne Jan 1, 2025
52bae09
feat: add support for custom log file path in logging config
rorybyrne Jan 1, 2025
f5df26f
Here is the one-line commit message for the changes:
rorybyrne Jan 1, 2025
38cabfa
refactor: simplify logging configuration and event handling system
rorybyrne Jan 3, 2025
f5e8262
feat: Add test command to CLI
rorybyrne Jan 3, 2025
a2fd6f7
fix: Migrate from typer to click in plan, test, and run commands
rorybyrne Jan 3, 2025
ebf7384
feat: Add plan command to generate execution plan from Labfile
rorybyrne Jan 4, 2025
dff9841
feat: Add click decorator for `path` in `plan.py`
rorybyrne Jan 4, 2025
23b7c79
refactor: migrate CLI to use dishka dependency injection framework
rorybyrne Jan 4, 2025
2eaa6b8
refactor: remove unused typer dependency from project dependencies
rorybyrne Jan 4, 2025
9efe317
refactor: migrate to message bus pattern for runtime event handling
rorybyrne Jan 4, 2025
7a93b26
refactor: remove provider declaration and unused parser test
rorybyrne Jan 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes.
62 changes: 62 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: CI

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install just
uses: taiki-e/install-action@just

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true
cache-dependency-glob: "uv.lock"

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
run: uv sync --all-extras --dev

- name: Lint
run: just lint

test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v4

- name: Install just
uses: taiki-e/install-action@just

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true
cache-dependency-glob: "uv.lock"

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: uv sync --all-extras --dev

- name: Test
run: just test
23 changes: 23 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
default:
@just --list

test:
@uv run pytest

test-s:
@uv run pytest -s

ruff:
uv run ruff format lab

pyright:
uv run pyright lab

lint:
just ruff
just pyright

lint-file file:
- ruff {{file}}
- pyright {{file}}

5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
# lab
A CLI tool for managing computational science

## Usage

Running `lab run .` will search for a `Labfile` in the directory, and then run the experiments defined there. `lab`
will automatically resolve dependencies between experiments and run them in the right order.
21 changes: 14 additions & 7 deletions lab/cli/cli.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import typer
import click
from dishka.integrations.click import setup_dishka
from lab.cli.commands.plan import plan

from lab.cli.commands import exp, run
from lab.cli.commands.run import run
from lab.di import DI

app = typer.Typer()

def start():
@click.group()
@click.pass_context
def main(context: click.Context):
di = DI()
setup_dishka(container=di.container, context=context, auto_inject=True)

def main():
exp.attach(app, name="exp")
run.attach(app, name="run")
main.command(name="plan")(plan)
main.command(name="run")(run)

app()
main()
43 changes: 0 additions & 43 deletions lab/cli/commands/exp.py

This file was deleted.

20 changes: 20 additions & 0 deletions lab/cli/commands/plan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from pathlib import Path
from dishka import FromDishka
import rich
import click

from lab.core.ui import UserInterface
from lab.project.service.labfile import LabfileService
from lab.project.service.plan import PlanService


@click.argument("path", type=click.Path(exists=True, path_type=Path))
def plan(path: Path, ui: FromDishka[UserInterface]):
"""Generate execution plan from Labfile"""
ui.print(f"Generating plan for [b]{path.resolve()}[/b]\n")
labfile_service = LabfileService()
plan_service = PlanService()
project = labfile_service.parse(path)

plan = plan_service.create_execution_plan(project)
ui.print(str(plan))
61 changes: 45 additions & 16 deletions lab/cli/commands/run.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,53 @@
from pathlib import Path
from typing import Annotated
import rich
import typer
import logging
import click
from dishka import FromDishka

from lab.service.experiment import ExperimentService
from lab.service.labfile import LabfileService
from lab.service.pipeline import PipelineService
from lab.cli.utils import coro
from lab.core.logging import setup_logging
from lab.core.ui import UserInterface
from lab.project.service.labfile import LabfileService
from lab.project.service.plan import PlanService
from lab.runtime.runtime import Runtime

logger = logging.getLogger("lab")

def run(path: Annotated[Path, typer.Argument(help="Path to Labfile")]):
rich.print(f"Running [b]{path.resolve()}[/b]\n")
labfile_service = LabfileService()
project = labfile_service.parse(path)

experiment_service = ExperimentService()
pipeline_service = PipelineService(experiment_service=experiment_service)
@click.argument("path", type=click.Path(exists=True, path_type=Path))
@coro
async def run(
path: Path,
ui: FromDishka[UserInterface],
runtime: FromDishka[Runtime],
labfile_service: FromDishka[LabfileService],
plan_service: FromDishka[PlanService],
):
"""Run experiments defined in Labfile"""
setup_logging(Path("~/.local/lab/logs/lab.log"))

pipeline = pipeline_service.create_pipeline(project)
pipeline_service.run(pipeline)
try:
ui.display_start(str(path.resolve()))

# Load and parse project
logger.debug("Loading project", extra={"context": {"path": str(path)}})
labfile_service = LabfileService()
project = labfile_service.parse(path)

def attach(app: typer.Typer, *, name: str):
app.command(name=name)(run)
# Create execution plan
plan_service = PlanService()
plan = plan_service.create_execution_plan(project)

await runtime.start(plan)

# # Execute experiments with progress display
# with ui.create_progress() as progress:
# task = progress.add_task(
# "Running experiments...", total=len(plan.ordered_experiments)
# )

ui.display_success()

except Exception as e:
logger.exception("Execution failed")
ui.display_error(message="Execution failed", details=str(e))
raise
11 changes: 11 additions & 0 deletions lab/cli/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import asyncio
from functools import wraps
from typing import Callable


def coro(f: Callable):
@wraps(f)
def wrapper(*args, **kwargs):
return asyncio.run(f(*args, **kwargs))

return wrapper
File renamed without changes.
29 changes: 29 additions & 0 deletions lab/core/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from typing import Optional
from sqlalchemy import create_engine, Engine
from sqlalchemy.pool import StaticPool


def make_db(database_url: Optional[str] = None) -> Engine:
"""Create a database engine using SQLAlchemy.

Args:
database_url: Optional database URL. Defaults to in-memory SQLite.

Returns:
SQLAlchemy Engine instance
"""
if database_url is None:
database_url = "sqlite:///:memory:"

connect_args = (
{"check_same_thread": False} if database_url.startswith("sqlite") else {}
)

return create_engine(
database_url,
# For SQLite in-memory DB, use StaticPool to maintain a single connection
poolclass=StaticPool if database_url == "sqlite:///:memory:" else None,
connect_args=connect_args,
# Echo SQL for debugging
echo=False,
)
54 changes: 54 additions & 0 deletions lab/core/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import json
import logging.config
import os
import yaml
from datetime import datetime
from pathlib import Path
from typing import Optional


class JSONFormatter(logging.Formatter):
"""JSON formatter for structured logging"""

def format(self, record: logging.LogRecord) -> str:
log_obj = {
"timestamp": datetime.fromtimestamp(record.created).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"context": getattr(record, "context", {}),
}
if record.exc_info:
log_obj["exception"] = self.formatException(record.exc_info)
return json.dumps(log_obj)


def load_logging_config(path: Path) -> dict:
config_template = path.read_text()

config_str = config_template.replace(
"_LOG_FILE_", os.getenv("LOG_FILE", "/var/log/default.log")
)

return yaml.safe_load(config_str)


def setup_logging(log_file: Optional[Path] = None) -> None:
"""Configure application logging from YAML file"""
if log_file:
# Ensure log directory exists
log_file = log_file.expanduser().resolve()
log_file.parent.mkdir(parents=True, exist_ok=True)

# Set environment variable for template substitution
os.environ["LOG_FILE"] = str(log_file)
# Load config file
config_path = Path(__file__).parent / "logging.yaml"
config = load_logging_config(config_path)

# Apply configuration
logging.config.dictConfig(config)
else:
# If no log file specified, use null handler
logger = logging.getLogger("lab")
logger.addHandler(logging.NullHandler())
41 changes: 41 additions & 0 deletions lab/core/logging.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
version: 1
disable_existing_loggers: false

formatters:
json:
(): lab.core.logging.JSONFormatter
simple:
format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s'

handlers:
file:
class: logging.handlers.RotatingFileHandler
formatter: json
filename: _LOG_FILE_
maxBytes: 10485760 # 10MB
backupCount: 5
encoding: utf8

null_handler:
class: logging.NullHandler

loggers:
lab:
level: DEBUG
handlers: [file]
propagate: false

# Third party loggers
urllib3:
level: WARNING
handlers: [null_handler]
propagate: false

asyncio:
level: WARNING
handlers: [null_handler]
propagate: false

root:
level: WARNING
handlers: [null_handler]
Empty file added lab/core/messaging/__init__.py
Empty file.
Loading
Loading