schedule-module 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: schedule-module
3
+ Version: 0.1.0
4
+ Summary: A reusable scheduling capability module
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: asyncpg>=0.28.0
7
+ Requires-Dist: pydantic>=2.0.0
8
+ Requires-Dist: python-dateutil>=2.8.2
9
+ Requires-Dist: pytz>=2023.3
10
+ Requires-Dist: sqlalchemy[asyncio]>=2.0.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest-asyncio>=0.21.1; extra == 'dev'
13
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
14
+ Requires-Dist: testcontainers[postgres]>=3.7.1; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Schedule Module
18
+
19
+ A reusable, production-grade scheduling capability module designed for both human developers and AI agents.
20
+ It provides a robust scheduling system that allows projects to create, manage, and query events, routines, and time-based records across systems using a strictly structured PostgreSQL database.
21
+
22
+ ## Architecture & Principles
23
+
24
+ - **Installability**: Can be initialized easily via `make initial`.
25
+ - **Backward Compatibility**: Migrations are versioned and immutable.
26
+ - **Agent-First Design**: Includes MCP integration and YAML action definitions for native AI consumption.
27
+ - **Human Usability**: Clean API, strongly typed Pydantic models.
28
+
29
+ ## Usage
30
+
31
+ ### 1. Installation
32
+
33
+ To initialize the database locally via Docker Compose, simply run:
34
+ ```bash
35
+ make initial
36
+ ```
37
+
38
+ This will:
39
+ 1. Spin up a PostgreSQL container using `docker-compose.yml`.
40
+ 2. Apply the initial schema and tables (`/db/ddl`).
41
+ 3. Run all pending migrations (`/db/migrations`).
42
+
43
+ ### 2. Python API
44
+
45
+ Use the `Schedule` service to interact with your data:
46
+
47
+ ```python
48
+ from datetime import datetime, timezone
49
+ import asyncio
50
+ from uuid import uuid4
51
+ from src import Schedule, ScheduledRoutineCreate, RecurrencyType
52
+
53
+ async def main():
54
+ async with Schedule() as schedule_service:
55
+ routine = ScheduledRoutineCreate(
56
+ name="Daily Standup",
57
+ start_date_time=datetime.now(timezone.utc),
58
+ end_date_time=datetime.now(timezone.utc),
59
+ recurrency=RecurrencyType.daily
60
+ )
61
+ created_routine = await schedule_service.add_schedule(routine)
62
+ print("Created Routine ID:", created_routine.id)
63
+
64
+ if __name__ == "__main__":
65
+ asyncio.run(main())
66
+ ```
67
+
68
+ ### 3. Agent Integration
69
+
70
+ Refer to `agent/mcp.yaml` and `agent/actions.yaml` for using this module as a tool in AI workflows via MCP (Model Context Protocol).
71
+
72
+ ## Infrastructure
73
+
74
+ We include boilerplate configurations for deployments:
75
+ - `infra/docker-compose.yml`: For local deployments
76
+ - `infra/terraform`: Example Terraform modules for AWS RDS Postgres deployments
77
+
78
+ ## Publishing
79
+
80
+ The module includes a `.github/workflows/publish.yml` that automatically publishes to npm and PyPI whenever changes are merged into the `main` branch.
@@ -0,0 +1,13 @@
1
+ src/__init__.py,sha256=QaiQhviZkGg-UmK0q983LvQqSK29465_YbDSZTDx9rc,435
2
+ src/cli.py,sha256=YYTanku6vtKLFIfAzNidbwgdexXaCC4gmEoiq4ZwoTs,3558
3
+ src/core/models.py,sha256=kX1TF9PLuPVPzBO8FfWVveu7RwH3QgTHMrjJEVbOzF8,1847
4
+ src/db/connection.py,sha256=PROYZ8DNOjtZK4Hnph004dz_FvyG6TWbG_dBf-5TlKQ,531
5
+ src/repositories/category_repository.py,sha256=yf3VeEtz4XhCK_RpuH4qbwTJmnZt452Jb7j0FQ_LSpo,1325
6
+ src/repositories/date_register_repository.py,sha256=4aZHTQMYcaDqV1SKw5Azn0yF6pnETIEgjaVraPz1JrQ,2558
7
+ src/repositories/schedule_repository.py,sha256=kVZWdQyBnXlXLSHJpHaORHsrHTVRdtyoDF1QRyRGuW8,1561
8
+ src/services/schedule_service.py,sha256=orNpYotng0wpmNOR9Rf_n_Cxcq1M-vxCk4JWIdNcxcg,2297
9
+ src/utils/date_utils.py,sha256=NxniUVhMMvqUMDcqRrOA6CRDvD1PM6vSpevbgJ3apas,412
10
+ schedule_module-0.1.0.dist-info/METADATA,sha256=t79b60rb967yLsyEFgWGftxSFTOKtX7kmfo3LA3G7nE,2739
11
+ schedule_module-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ schedule_module-0.1.0.dist-info/entry_points.txt,sha256=5WpYPWq6sopoTteFpk_MOz9SS5BnFfPoUdpJ6jBXNWc,53
13
+ schedule_module-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ schedule-module-cli = src.cli:main
src/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ from .services.schedule_service import Schedule
2
+ from .core.models import (
3
+ RecurrencyType, RegisterType,
4
+ Category, CategoryCreate,
5
+ ScheduledRoutine, ScheduledRoutineCreate,
6
+ DateRegister, DateRegisterCreate
7
+ )
8
+
9
+ __all__ = [
10
+ "Schedule",
11
+ "RecurrencyType",
12
+ "RegisterType",
13
+ "Category",
14
+ "CategoryCreate",
15
+ "ScheduledRoutine",
16
+ "ScheduledRoutineCreate",
17
+ "DateRegister",
18
+ "DateRegisterCreate"
19
+ ]
src/cli.py ADDED
@@ -0,0 +1,109 @@
1
+ import argparse
2
+ import asyncio
3
+ import os
4
+ import re
5
+ import asyncpg
6
+ from src.db.connection import DATABASE_URL
7
+
8
+ ASYNC_PG_URL = DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
9
+
10
+ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
11
+ DB_DIR = os.path.join(BASE_DIR, "db")
12
+
13
+ async def run_sql_file(conn, filepath):
14
+ with open(filepath, "r") as f:
15
+ sql = f.read()
16
+ if sql.strip():
17
+ await conn.execute(sql)
18
+
19
+ async def init_schema_version(conn):
20
+ # Ensure the table exists
21
+ await conn.execute("""
22
+ CREATE SCHEMA IF NOT EXISTS schedule_module;
23
+ CREATE TABLE IF NOT EXISTS schedule_module.schema_version (
24
+ version INT PRIMARY KEY,
25
+ name VARCHAR(255) NOT NULL,
26
+ executed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
27
+ );
28
+ """)
29
+
30
+ async def get_applied_migrations(conn):
31
+ rows = await conn.fetch("SELECT version FROM schedule_module.schema_version")
32
+ return {row['version'] for row in rows}
33
+
34
+ async def apply_migrations(conn):
35
+ await init_schema_version(conn)
36
+ applied = await get_applied_migrations(conn)
37
+
38
+ migrations_dir = os.path.join(DB_DIR, "migrations")
39
+ files = sorted(os.listdir(migrations_dir))
40
+
41
+ for filename in files:
42
+ if not filename.endswith(".sql"):
43
+ continue
44
+
45
+ match = re.match(r"^(\d+)_", filename)
46
+ if match:
47
+ version = int(match.group(1))
48
+ if version not in applied:
49
+ print(f"Applying migration: {filename}")
50
+ filepath = os.path.join(migrations_dir, filename)
51
+ await run_sql_file(conn, filepath)
52
+ await conn.execute(
53
+ "INSERT INTO schedule_module.schema_version (version, name) VALUES ($1, $2)",
54
+ version, filename
55
+ )
56
+ else:
57
+ print(f"Skipping incorrectly formatted migration file: {filename}")
58
+
59
+ async def bootstrap():
60
+ print("Running bootstrap...")
61
+ conn = await asyncpg.connect(ASYNC_PG_URL)
62
+ try:
63
+ # Run DDLs
64
+ ddl_dir = os.path.join(DB_DIR, "ddl")
65
+ for filename in sorted(os.listdir(ddl_dir)):
66
+ if filename.endswith(".sql") and filename != "schema.sql":
67
+ print(f"Applying DDL: {filename}")
68
+ await run_sql_file(conn, os.path.join(ddl_dir, filename))
69
+
70
+ # Apply Migrations
71
+ await apply_migrations(conn)
72
+ finally:
73
+ await conn.close()
74
+ print("Bootstrap complete.")
75
+
76
+ async def seed():
77
+ print("Running seed...")
78
+ conn = await asyncpg.connect(ASYNC_PG_URL)
79
+ try:
80
+ seed_path = os.path.join(DB_DIR, "bootstrap", "seed.sql")
81
+ if os.path.exists(seed_path):
82
+ await run_sql_file(conn, seed_path)
83
+ print("Seed complete.")
84
+ else:
85
+ print("No seed.sql found.")
86
+ finally:
87
+ await conn.close()
88
+
89
+ def main():
90
+ parser = argparse.ArgumentParser(description="Schedule Module CLI")
91
+ parser.add_argument("command", choices=["bootstrap", "migrate", "seed"], help="Command to execute")
92
+
93
+ args = parser.parse_args()
94
+
95
+ if args.command == "bootstrap":
96
+ asyncio.run(bootstrap())
97
+ elif args.command == "migrate":
98
+ async def _migrate():
99
+ conn = await asyncpg.connect(ASYNC_PG_URL)
100
+ try:
101
+ await apply_migrations(conn)
102
+ finally:
103
+ await conn.close()
104
+ asyncio.run(_migrate())
105
+ elif args.command == "seed":
106
+ asyncio.run(seed())
107
+
108
+ if __name__ == "__main__":
109
+ main()
src/core/models.py ADDED
@@ -0,0 +1,71 @@
1
+ from enum import Enum
2
+ from typing import Optional
3
+ from datetime import datetime
4
+ from uuid import UUID
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ class RecurrencyType(str, Enum):
8
+ daily = 'daily'
9
+ weekly = 'weekly'
10
+ biweekly = 'biweekly'
11
+ monthly = 'monthly'
12
+ custom_days = 'custom_days'
13
+
14
+ class RegisterType(str, Enum):
15
+ event = 'event'
16
+ schedule = 'schedule'
17
+
18
+ class CategoryBase(BaseModel):
19
+ name: str
20
+ description: Optional[str] = None
21
+ color: Optional[str] = None
22
+ icon: Optional[str] = None
23
+ image: Optional[str] = None
24
+ note: Optional[str] = None
25
+
26
+ class CategoryCreate(CategoryBase):
27
+ pass
28
+
29
+ class Category(CategoryBase):
30
+ id: UUID
31
+ creation_date_time: datetime
32
+ update_date_time: datetime
33
+ model_config = ConfigDict(from_attributes=True)
34
+
35
+ class ScheduledRoutineBase(BaseModel):
36
+ name: str
37
+ category_id: Optional[UUID] = None
38
+ start_date_time: datetime
39
+ end_date_time: datetime
40
+ description: Optional[str] = None
41
+ note: Optional[str] = None
42
+ recurrency: RecurrencyType
43
+
44
+ class ScheduledRoutineCreate(ScheduledRoutineBase):
45
+ pass
46
+
47
+ class ScheduledRoutine(ScheduledRoutineBase):
48
+ id: UUID
49
+ creation_date_time: datetime
50
+ update_date_time: datetime
51
+ model_config = ConfigDict(from_attributes=True)
52
+
53
+ class DateRegisterBase(BaseModel):
54
+ type: RegisterType
55
+ name: str
56
+ start_date_time: datetime
57
+ end_date_time: datetime
58
+ description: Optional[str] = None
59
+ note: Optional[str] = None
60
+ category_id: Optional[UUID] = None
61
+ scheduled_routine_id: Optional[UUID] = None
62
+ relationship_field: Optional[str] = None
63
+
64
+ class DateRegisterCreate(DateRegisterBase):
65
+ pass
66
+
67
+ class DateRegister(DateRegisterBase):
68
+ id: UUID
69
+ creation_date_time: datetime
70
+ update_date_time: datetime
71
+ model_config = ConfigDict(from_attributes=True)
src/db/connection.py ADDED
@@ -0,0 +1,17 @@
1
+ import os
2
+ from sqlalchemy.ext.asyncio import create_async_engine
3
+
4
+ # Database settings
5
+ DB_HOST = os.getenv("DB_HOST", "localhost")
6
+ DB_PORT = os.getenv("DB_PORT", "5432")
7
+ DB_USER = os.getenv("DB_USER", "postgres")
8
+ DB_PASS = os.getenv("DB_PASS", "postgres")
9
+ DB_NAME = os.getenv("DB_NAME", "postgres")
10
+
11
+ DATABASE_URL = f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
12
+
13
+ engine = create_async_engine(DATABASE_URL, echo=False)
14
+
15
+ async def get_connection():
16
+ async with engine.begin() as conn:
17
+ yield conn
@@ -0,0 +1,35 @@
1
+ from uuid import UUID
2
+ from sqlalchemy import text
3
+ from sqlalchemy.ext.asyncio import AsyncConnection
4
+ from typing import Optional, List
5
+ from src.core.models import CategoryCreate, Category
6
+
7
+ class CategoryRepository:
8
+ def __init__(self, conn: AsyncConnection):
9
+ self.conn = conn
10
+
11
+ async def create(self, category: CategoryCreate) -> Category:
12
+ query = text("""
13
+ INSERT INTO schedule_module.category
14
+ (name, description, color, icon, image, note)
15
+ VALUES (:name, :description, :color, :icon, :image, :note)
16
+ RETURNING *
17
+ """)
18
+ result = await self.conn.execute(query, {
19
+ "name": category.name,
20
+ "description": category.description,
21
+ "color": category.color,
22
+ "icon": category.icon,
23
+ "image": category.image,
24
+ "note": category.note
25
+ })
26
+ row = result.fetchone()
27
+ return Category.model_validate(dict(row._mapping))
28
+
29
+ async def get_by_id(self, category_id: UUID) -> Optional[Category]:
30
+ query = text("SELECT * FROM schedule_module.category WHERE id = :id")
31
+ result = await self.conn.execute(query, {"id": category_id})
32
+ row = result.fetchone()
33
+ if row:
34
+ return Category.model_validate(dict(row._mapping))
35
+ return None
@@ -0,0 +1,49 @@
1
+ from uuid import UUID
2
+ from sqlalchemy import text
3
+ from sqlalchemy.ext.asyncio import AsyncConnection
4
+ from typing import Optional, List
5
+ from datetime import datetime
6
+ from src.core.models import DateRegisterCreate, DateRegister
7
+
8
+ class DateRegisterRepository:
9
+ def __init__(self, conn: AsyncConnection):
10
+ self.conn = conn
11
+
12
+ async def create(self, register: DateRegisterCreate) -> DateRegister:
13
+ query = text("""
14
+ INSERT INTO schedule_module.date_register
15
+ (type, name, start_date_time, end_date_time, description, note, category_id, scheduled_routine_id, relationship_field)
16
+ VALUES (:type, :name, :start_date_time, :end_date_time, :description, :note, :category_id, :scheduled_routine_id, :relationship_field)
17
+ RETURNING *
18
+ """)
19
+ result = await self.conn.execute(query, {
20
+ "type": register.type.value,
21
+ "name": register.name,
22
+ "start_date_time": register.start_date_time,
23
+ "end_date_time": register.end_date_time,
24
+ "description": register.description,
25
+ "note": register.note,
26
+ "category_id": register.category_id,
27
+ "scheduled_routine_id": register.scheduled_routine_id,
28
+ "relationship_field": register.relationship_field
29
+ })
30
+ row = result.fetchone()
31
+ return DateRegister.model_validate(dict(row._mapping))
32
+
33
+ async def get_by_relationship(self, relationship_field: str) -> List[DateRegister]:
34
+ query = text("SELECT * FROM schedule_module.date_register WHERE relationship_field = :rel")
35
+ result = await self.conn.execute(query, {"rel": relationship_field})
36
+ return [DateRegister.model_validate(dict(row._mapping)) for row in result.fetchall()]
37
+
38
+ async def get_by_schedule(self, scheduled_routine_id: UUID) -> List[DateRegister]:
39
+ query = text("SELECT * FROM schedule_module.date_register WHERE scheduled_routine_id = :sch")
40
+ result = await self.conn.execute(query, {"sch": scheduled_routine_id})
41
+ return [DateRegister.model_validate(dict(row._mapping)) for row in result.fetchall()]
42
+
43
+ async def get_dates_from_range(self, start: datetime, end: datetime) -> List[DateRegister]:
44
+ query = text("""
45
+ SELECT * FROM schedule_module.date_register
46
+ WHERE start_date_time < :end AND end_date_time > :start
47
+ """)
48
+ result = await self.conn.execute(query, {"start": start, "end": end})
49
+ return [DateRegister.model_validate(dict(row._mapping)) for row in result.fetchall()]
@@ -0,0 +1,36 @@
1
+ from uuid import UUID
2
+ from sqlalchemy import text
3
+ from sqlalchemy.ext.asyncio import AsyncConnection
4
+ from typing import Optional
5
+ from src.core.models import ScheduledRoutineCreate, ScheduledRoutine
6
+
7
+ class ScheduleRepository:
8
+ def __init__(self, conn: AsyncConnection):
9
+ self.conn = conn
10
+
11
+ async def create(self, routine: ScheduledRoutineCreate) -> ScheduledRoutine:
12
+ query = text("""
13
+ INSERT INTO schedule_module.scheduled_routine
14
+ (name, category_id, start_date_time, end_date_time, description, note, recurrency)
15
+ VALUES (:name, :category_id, :start_date_time, :end_date_time, :description, :note, :recurrency)
16
+ RETURNING *
17
+ """)
18
+ result = await self.conn.execute(query, {
19
+ "name": routine.name,
20
+ "category_id": routine.category_id,
21
+ "start_date_time": routine.start_date_time,
22
+ "end_date_time": routine.end_date_time,
23
+ "description": routine.description,
24
+ "note": routine.note,
25
+ "recurrency": routine.recurrency.value
26
+ })
27
+ row = result.fetchone()
28
+ return ScheduledRoutine.model_validate(dict(row._mapping))
29
+
30
+ async def get_by_id(self, routine_id: UUID) -> Optional[ScheduledRoutine]:
31
+ query = text("SELECT * FROM schedule_module.scheduled_routine WHERE id = :id")
32
+ result = await self.conn.execute(query, {"id": routine_id})
33
+ row = result.fetchone()
34
+ if row:
35
+ return ScheduledRoutine.model_validate(dict(row._mapping))
36
+ return None
@@ -0,0 +1,56 @@
1
+ from uuid import UUID
2
+ from typing import List, Optional
3
+ from datetime import datetime
4
+ from sqlalchemy.ext.asyncio import AsyncConnection
5
+
6
+ from src.core.models import (
7
+ ScheduledRoutineCreate, ScheduledRoutine,
8
+ DateRegisterCreate, DateRegister, RegisterType,
9
+ CategoryCreate, Category
10
+ )
11
+ from src.repositories.schedule_repository import ScheduleRepository
12
+ from src.repositories.date_register_repository import DateRegisterRepository
13
+ from src.repositories.category_repository import CategoryRepository
14
+ from src.db.connection import engine
15
+
16
+ class Schedule:
17
+ """Primary service class for the Schedule Module"""
18
+
19
+ def __init__(self, conn: Optional[AsyncConnection] = None):
20
+ self._conn = conn
21
+
22
+ async def __aenter__(self):
23
+ if self._conn is None:
24
+ self._engine_conn = engine.begin()
25
+ self.conn = await self._engine_conn.__aenter__()
26
+ else:
27
+ self.conn = self._conn
28
+ self._init_repos()
29
+ return self
30
+
31
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
32
+ if self._conn is None:
33
+ await self._engine_conn.__aexit__(exc_type, exc_val, exc_tb)
34
+
35
+ def _init_repos(self):
36
+ self.schedule_repo = ScheduleRepository(self.conn)
37
+ self.date_register_repo = DateRegisterRepository(self.conn)
38
+ self.category_repo = CategoryRepository(self.conn)
39
+
40
+ async def add_category(self, category: CategoryCreate) -> Category:
41
+ return await self.category_repo.create(category)
42
+
43
+ async def add_date_record(self, register: DateRegisterCreate) -> DateRegister:
44
+ return await self.date_register_repo.create(register)
45
+
46
+ async def add_schedule(self, routine: ScheduledRoutineCreate) -> ScheduledRoutine:
47
+ return await self.schedule_repo.create(routine)
48
+
49
+ async def get_records_for_relationship(self, relationship_field: str) -> List[DateRegister]:
50
+ return await self.date_register_repo.get_by_relationship(relationship_field)
51
+
52
+ async def get_records_for_schedule(self, schedule_id: UUID) -> List[DateRegister]:
53
+ return await self.date_register_repo.get_by_schedule(schedule_id)
54
+
55
+ async def get_dates_from_range(self, start: datetime, end: datetime) -> List[DateRegister]:
56
+ return await self.date_register_repo.get_dates_from_range(start, end)
@@ -0,0 +1,12 @@
1
+ from datetime import datetime
2
+ import pytz
3
+
4
+ def normalize_to_utc(dt: datetime) -> datetime:
5
+ """Normalize datetime to UTC."""
6
+ if dt.tzinfo is None:
7
+ return dt.replace(tzinfo=pytz.UTC)
8
+ return dt.astimezone(pytz.UTC)
9
+
10
+ def is_overlap(start1: datetime, end1: datetime, start2: datetime, end2: datetime) -> bool:
11
+ """Check if two date ranges overlap."""
12
+ return start1 < end2 and start2 < end1