Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
*.pyc
posts.db
.coverage
4 changes: 3 additions & 1 deletion .trunk/configs/ruff.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Generic, formatter-friendly config.
select = ["B", "D3", "D4", "E", "F"]
select = ["B", "D3", "D4", "E", "F", "ANN"]

# Never enforce `E501` (line length violations). This should be handled by formatters.
ignore = ["E501"]

target-version = "py312"
3 changes: 2 additions & 1 deletion .trunk/trunk.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ lint:
- mypy
enabled:
- actionlint@1.7.8
- ty@0.0.20
- ty@0.0.21
- bandit@1.9.4
- black@26.1.0
- checkov@3.2.507
Expand All @@ -34,6 +34,7 @@ lint:
- linters: [bandit]
paths:
- "**/test_*"
- test/fakes/*
actions:
enabled:
- trunk-announce
Expand Down
230 changes: 230 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
# AGENTS.md - PolyClean Development Guide

This document provides essential information for AI agents working on the PolyClean project.

## Project Overview

PolyClean combines python-polylith with Clean Architecture principles. The codebase uses a strict directory structure with `components/` (reusable building blocks), `bases/` (entry points), and `test/` (tests mirror component structure).

## Build, Lint, and Test Commands

### Running Tests

```bash
# Run all tests
uv run pytest -q

# Run a single test file
uv run pytest test/flows/test_create_post_flow.py

# Run a single test by name
uv run pytest test/flows/test_create_post_flow.py::test_create_post_flow_success -v

# Run architecture tests
uv run pytest test/architecture/ -v

# Run with coverage
uv run pytest --cov=polyclean --cov-report=term-missing
```

### Linting & Type Checking

```bash
# Run all linters via trunk (ruff, black, isort, etc.)
trunk check

# Run trunk with auto-fix
trunk check --fix
```

Note: `trunk check` runs ruff, black, isort, and other linters. mypy is disabled in this project.

### Running the Application

```bash
# Start API server with reload
uv run uvicorn polyclean.publishing_api.main:app --reload
```

### Polylith CLI Commands

```bash
uv run poly info
uv run poly deps
uv run poly test diff
```

## Code Style Guidelines

### Import Organization

All Python files must start with:

```python
from __future__ import annotations
```

Imports should be organized in the following order (use `isort` with black profile):

1. Standard library imports
2. Third-party imports
3. Local application imports

```python
# Good import example
from __future__ import annotations

from datetime import datetime, timezone
from typing import List, Optional, Protocol

from polyclean.posts_contract import Post, PostStoragePort
```

### Formatting

- Line length: 100 characters maximum
- Use black for formatting (the isort profile is set to "black")
- No trailing whitespace
- Use f-strings for string formatting

### Type Hints

- Always use type hints for function parameters and return types
- Use `Optional[X]` instead of `X | None` for compatibility
- Use `from __future__ import annotations` to enable postponed evaluation

```python
# Good
async def get_by_id(self, post_id: int) -> Optional[Post]: ...

# Good - using Protocol for interfaces
class PostStoragePort(Protocol):
async def save(self, post: Post) -> Post: ...
async def get_by_id(self, post_id: int) -> Optional[Post]: ...
```

### Naming Conventions

- Classes: PascalCase (e.g., `CreatePostFlow`, `SQLitePostAdapter`)
- Functions/methods: snake_case (e.g., `get_by_id`, `publish_post`)
- Private methods: prefix with underscore (e.g., `_storage`)
- Constants: SCREAMING_SNAKE_CASE
- Files: snake_case (e.g., `test_create_post_flow.py`)

### Dataclasses for Entities

Use `@dataclass` for simple data containers:

```python
from dataclasses import dataclass
from datetime import datetime
from typing import Optional

@dataclass
class Post:
id: Optional[int]
content: str
image_url: str
created_at: datetime
instagram_post_id: Optional[str]
posted: bool = False

def mark_as_posted(self, instagram_id: str) -> None:
self.posted = True
self.instagram_post_id = instagram_id
```

### Protocols for Ports (Interfaces)

Define interfaces using `Protocol` from `typing`:

```python
from typing import Protocol

class InstagramPort(Protocol):
async def publish_post(self, image_url: str, caption: str) -> str: ...
async def validate_connection(self) -> bool: ...
```

### Async/Await

- Use `async`/`await` for I/O-bound operations
- Always mark async test functions with `@pytest.mark.asyncio`
- Use `pytest-asyncio` for async test support

```python
@pytest.mark.asyncio
async def test_create_post_flow_success(fake_post_storage):
flow = CreatePostFlow(fake_post_storage)
result = await flow.flow(content="Hello", image_url="https://example.com/img.jpg")
assert result["success"] is True
```

### Error Handling

- Use exceptions for error conditions
- Return dict with `success` key for flow operations that can fail
- Raise `HTTPException` in FastAPI endpoints for HTTP error responses

```python
# In flows, return error dict
if not content or not image_url:
return {"success": False, "message": "Invalid input"}

# In API endpoints, raise HTTPException
if not result["success"]:
raise HTTPException(status_code=400, detail=result["message"])
```

### Dependency Injection

- Pass dependencies through constructor injection
- Use protocols/types for dependency abstraction

```python
class CreatePostFlow:
def __init__(self, storage: PostStoragePort):
self._storage = storage
```

## Project Structure

```text
polyclean/
├── components/ # Reusable building blocks
│ └── polyclean/
│ ├── create_post_flow/
│ ├── publish_post_flow/
│ ├── posts_contract/ # Entities and ports
│ ├── instagram_contract/ # Instagram interface
│ ├── sqlite_post_adapter/ # SQLite implementation
│ ├── instagram_publish_adapter/
│ └── rest_adapter_lib/
├── bases/ # Application entry points
│ └── polyclean/
│ └── publishing_api/
├── test/ # Tests mirroring component structure
│ ├── flows/
│ ├── adapters/
│ ├── contracts/
│ ├── architecture/
│ └── fakes/ # Test doubles
└── projects/ # Additional project configs
```

## Architecture Patterns

This project follows Clean Architecture with these layers:

1. **Entities** - Domain objects (in `*_contract` components)
2. **Use Cases** - Business logic (in `*_flow` components)
3. **Ports** - Interface definitions (Protocol classes in `*_contract` components)
4. **Adapters** - External service implementations (in `*_adapter` components)

## Configuration

- Python version: 3.12+
- Dependencies managed with `uv`
- Linting: trunk check (runs ruff, black, isort, etc.)
- Type checking: disabled in this project
- Testing: pytest with pytest-asyncio
21 changes: 13 additions & 8 deletions bases/polyclean/publishing_api/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
from contextlib import asynccontextmanager
from typing import AsyncIterator

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
Expand All @@ -22,25 +23,29 @@ def create_app(storage: PostStoragePort, instagram: InstagramPort) -> FastAPI:
publish_post_flow = PublishPostFlow(storage, instagram)

@asynccontextmanager
async def lifespan(app: FastAPI):
if hasattr(storage, "initialize"):
await storage.initialize()
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# Using getattr + None check to support storage implementations
# with optional lifecycle methods (initialize/close)
initialize = getattr(storage, "initialize", None)
if initialize is not None:
await initialize()
yield
if hasattr(storage, "close"):
await storage.close()
close = getattr(storage, "close", None)
if close is not None:
await close()

app = FastAPI(title="PolyClean Publishing API", lifespan=lifespan)

app.add_middleware(
CORSMiddleware,
CORSMiddleware, # type: ignore[arg-type]
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

@app.post("/posts")
async def create_post(req: CreatePostRequest):
async def create_post(req: CreatePostRequest) -> dict:
result = await create_post_flow.flow(
content=req.content, image_url=req.image_url
)
Expand All @@ -49,7 +54,7 @@ async def create_post(req: CreatePostRequest):
return result

@app.post("/posts/{post_id}/publish")
async def publish_post(post_id: int):
async def publish_post(post_id: int) -> dict:
result = await publish_post_flow.flow(post_id)
if not result["success"]:
raise HTTPException(status_code=400, detail=result["message"])
Expand Down
2 changes: 1 addition & 1 deletion components/polyclean/create_post_flow/flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@


class CreatePostFlow:
def __init__(self, storage: PostStoragePort):
def __init__(self, storage: PostStoragePort) -> None:
self._storage = storage

async def flow(self, content: str, image_url: str) -> dict:
Expand Down
2 changes: 1 addition & 1 deletion components/polyclean/instagram_publish_adapter/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class InstagramGraphAdapter(InstagramPort):
Defaults to stub mode unless INSTAGRAM_REAL_API=true.
"""

def __init__(self, access_token: str, business_account_id: str):
def __init__(self, access_token: str, business_account_id: str) -> None:
self._access_token = access_token
self._business_account_id = business_account_id
self._base_url = "https://graph.facebook.com/v18.0"
Expand Down
2 changes: 1 addition & 1 deletion components/polyclean/publish_post_flow/flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@


class PublishPostFlow:
def __init__(self, storage: PostStoragePort, instagram: InstagramPort):
def __init__(self, storage: PostStoragePort, instagram: InstagramPort) -> None:
self._storage = storage
self._instagram = instagram

Expand Down
2 changes: 1 addition & 1 deletion components/polyclean/sqlite_post_adapter/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@


class SQLitePostAdapter(PostStoragePort):
def __init__(self, db_path: str = "posts.db"):
def __init__(self, db_path: str = "posts.db") -> None:
self._db_path = db_path
self._conn: Optional[aiosqlite.Connection] = None

Expand Down
8 changes: 2 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,6 @@ dependencies = [
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.ruff]
line-length = 100
target-version = "py310"
lint.select = ["E", "F", "ANN"]

[tool.hatch.build]
dev-mode-dirs = ["components", "bases", "development", "."]

Expand All @@ -45,8 +40,9 @@ dev = [
"polylith-cli>=1.0.0",
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"pytest-cov>=7.0.0",
"ruff>=0.5.0",
"mypy>=1.10.0",
"grimp>=3.0.0",
"types-requests>=2.31.0",
"ty>=0.0.21",
]
Loading