Skip to content

Commit 67d3981

Browse files
JacobCoffeeclaude
andauthored
fix: update bot service imports to remove monolith dependencies (#109)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3589528 commit 67d3981

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+3184
-2
lines changed

Makefile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ coverage: ## Run the tests and generate coverage report
117117
@$(UV) run --no-sync coverage html
118118
@$(UV) run --no-sync coverage xml
119119

120-
check-all: lint test fmt-check coverage ## Run all linting, tests, and coverage checks
120+
check-all: lint type-check fmt test ## Run all linting, formatting, and tests
121+
122+
ci: check-all ## Run all checks for CI
121123

122124
# =============================================================================
123125
# Docs

pyproject.toml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ module-root = "" # Flat layout: byte_bot/ at root, not src/byte_bot/
7272
members = [
7373
"packages/byte-common",
7474
"services/api",
75+
"services/bot",
7576
]
7677

7778
[tool.uv]
@@ -177,7 +178,22 @@ filterwarnings = [
177178
[tool.ty]
178179

179180
[tool.ty.environment]
180-
extra-paths = ["byte_bot/", "tests/", "packages/byte-common/src/", "packages/byte-common/tests/", "services/api/src/"]
181+
extra-paths = ["byte_bot/", "tests/", "packages/byte-common/src/", "packages/byte-common/tests/", "services/api/src/", "services/bot/src/"]
182+
183+
# Disable checking for old monolith code that will be removed in Phase 1.2
184+
# and API service which has separate import issues
185+
[tool.ty.src]
186+
exclude = [
187+
"byte_bot/__init__.py",
188+
"byte_bot/app.py",
189+
"byte_bot/cli.py",
190+
"byte_bot/utils.py",
191+
"byte_bot/__metadata__.py",
192+
"byte_bot/byte/**/*.py",
193+
"byte_bot/server/**/*.py",
194+
"services/api/**/*.py",
195+
"docs/conf.py",
196+
]
181197

182198
[tool.slotscheck]
183199
strict-imports = false

services/bot/pyproject.toml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
[project]
2+
name = "byte-bot-service"
3+
version = "0.2.0"
4+
description = "Discord bot service for Byte Bot"
5+
authors = [
6+
{ name = "Jacob Coffee", email = "jacob@z7x.org" },
7+
]
8+
dependencies = [
9+
"byte-common",
10+
"discord-py>=2.3.2",
11+
"httpx>=0.25.0",
12+
"pydantic>=2.5.2",
13+
"pydantic-settings>=2.1.0",
14+
"python-dotenv>=1.0.0",
15+
"anyio>=4.1.0",
16+
]
17+
requires-python = ">=3.12,<4.0"
18+
license = { text = "MIT" }
19+
20+
[project.scripts]
21+
byte-bot = "byte_bot.__main__:main"
22+
23+
[build-system]
24+
requires = ["uv_build>=0.9.11,<0.10.0"]
25+
build-backend = "uv_build"
26+
27+
[tool.uv]
28+
package = true
29+
30+
[tool.uv.sources]
31+
byte-common = { workspace = true }
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Byte Bot - Discord bot service."""
2+
3+
__version__ = "0.2.0"
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Bot service entrypoint."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
7+
__all__ = ("main",)
8+
9+
10+
def main() -> None:
11+
"""Run the Discord bot."""
12+
try:
13+
from byte_bot.bot import run_bot # noqa: PLC0415
14+
except ImportError as exc:
15+
print( # noqa: T201
16+
"Could not load required libraries. "
17+
"Please check your installation and make sure you activated any necessary virtual environment.",
18+
)
19+
print(exc) # noqa: T201
20+
sys.exit(1)
21+
22+
# Run the bot
23+
run_bot()
24+
25+
26+
if __name__ == "__main__":
27+
main()
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
"""HTTP client for communicating with the Byte API service."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import TYPE_CHECKING, Any, Self
7+
8+
import httpx
9+
10+
from byte_common.schemas.guild import CreateGuildRequest, GuildSchema, UpdateGuildRequest
11+
12+
if TYPE_CHECKING:
13+
from uuid import UUID
14+
15+
__all__ = ("APIError", "ByteAPIClient")
16+
17+
logger = logging.getLogger(__name__)
18+
19+
# HTTP status codes
20+
HTTP_NOT_FOUND = 404
21+
22+
23+
class APIError(Exception):
24+
"""Exception raised when API calls fail."""
25+
26+
def __init__(self, message: str, status_code: int | None = None) -> None:
27+
"""Initialize API error.
28+
29+
Args:
30+
message: Error message
31+
status_code: HTTP status code if available
32+
"""
33+
super().__init__(message)
34+
self.status_code = status_code
35+
36+
37+
class ByteAPIClient:
38+
"""HTTP client for Byte API service.
39+
40+
This client handles all communication between the bot service and the API service,
41+
replacing direct database access.
42+
"""
43+
44+
def __init__(self, base_url: str, timeout: float = 10.0) -> None:
45+
"""Initialize API client.
46+
47+
Args:
48+
base_url: Base URL of the API service (e.g., "http://localhost:8000")
49+
timeout: Request timeout in seconds
50+
"""
51+
self.base_url = base_url.rstrip("/")
52+
self.client = httpx.AsyncClient(
53+
base_url=self.base_url,
54+
timeout=timeout,
55+
headers={"Content-Type": "application/json"},
56+
)
57+
58+
async def close(self) -> None:
59+
"""Close the HTTP client."""
60+
await self.client.aclose()
61+
62+
async def __aenter__(self) -> Self:
63+
"""Async context manager entry."""
64+
return self
65+
66+
async def __aexit__(self, *args: object) -> None:
67+
"""Async context manager exit."""
68+
await self.close()
69+
70+
# Guild Management
71+
72+
async def create_guild(
73+
self,
74+
guild_id: int,
75+
guild_name: str,
76+
prefix: str = "!",
77+
**kwargs: Any,
78+
) -> GuildSchema:
79+
"""Create a new guild.
80+
81+
Args:
82+
guild_id: Discord guild ID
83+
guild_name: Discord guild name
84+
prefix: Command prefix
85+
**kwargs: Additional guild configuration
86+
87+
Returns:
88+
Created guild schema
89+
90+
Raises:
91+
APIError: If the API request fails
92+
"""
93+
request = CreateGuildRequest(
94+
guild_id=guild_id,
95+
guild_name=guild_name,
96+
prefix=prefix,
97+
**kwargs,
98+
)
99+
100+
try:
101+
response = await self.client.post(
102+
"/api/guilds",
103+
json=request.model_dump(),
104+
)
105+
response.raise_for_status()
106+
return GuildSchema.model_validate(response.json())
107+
except httpx.HTTPStatusError as e:
108+
msg = f"Failed to create guild: {e.response.text}"
109+
logger.exception(msg, extra={"guild_id": guild_id, "status_code": e.response.status_code})
110+
raise APIError(msg, status_code=e.response.status_code) from e
111+
except httpx.RequestError as e:
112+
msg = f"Failed to connect to API service: {e!s}"
113+
logger.exception(msg, extra={"guild_id": guild_id})
114+
raise APIError(msg) from e
115+
116+
async def get_guild(self, guild_id: int) -> GuildSchema | None:
117+
"""Get guild by Discord ID.
118+
119+
Args:
120+
guild_id: Discord guild ID
121+
122+
Returns:
123+
Guild schema if found, None otherwise
124+
125+
Raises:
126+
APIError: If the API request fails (excluding 404)
127+
"""
128+
try:
129+
response = await self.client.get(f"/api/guilds/{guild_id}")
130+
131+
if response.status_code == HTTP_NOT_FOUND:
132+
return None
133+
134+
response.raise_for_status()
135+
return GuildSchema.model_validate(response.json())
136+
except httpx.HTTPStatusError as e:
137+
msg = f"Failed to get guild: {e.response.text}"
138+
logger.exception(msg, extra={"guild_id": guild_id, "status_code": e.response.status_code})
139+
raise APIError(msg, status_code=e.response.status_code) from e
140+
except httpx.RequestError as e:
141+
msg = f"Failed to connect to API service: {e!s}"
142+
logger.exception(msg, extra={"guild_id": guild_id})
143+
raise APIError(msg) from e
144+
145+
async def update_guild(
146+
self,
147+
guild_id: UUID,
148+
**updates: Any,
149+
) -> GuildSchema:
150+
"""Update guild configuration.
151+
152+
Args:
153+
guild_id: Guild UUID (not Discord ID)
154+
**updates: Fields to update
155+
156+
Returns:
157+
Updated guild schema
158+
159+
Raises:
160+
APIError: If the API request fails
161+
"""
162+
request = UpdateGuildRequest(**updates)
163+
164+
try:
165+
response = await self.client.patch(
166+
f"/api/guilds/{guild_id}",
167+
json=request.model_dump(exclude_unset=True),
168+
)
169+
response.raise_for_status()
170+
return GuildSchema.model_validate(response.json())
171+
except httpx.HTTPStatusError as e:
172+
msg = f"Failed to update guild: {e.response.text}"
173+
logger.exception(msg, extra={"guild_id": guild_id, "status_code": e.response.status_code})
174+
raise APIError(msg, status_code=e.response.status_code) from e
175+
except httpx.RequestError as e:
176+
msg = f"Failed to connect to API service: {e!s}"
177+
logger.exception(msg, extra={"guild_id": guild_id})
178+
raise APIError(msg) from e
179+
180+
async def delete_guild(self, guild_id: UUID) -> None:
181+
"""Delete a guild.
182+
183+
Args:
184+
guild_id: Guild UUID (not Discord ID)
185+
186+
Raises:
187+
APIError: If the API request fails
188+
"""
189+
try:
190+
response = await self.client.delete(f"/api/guilds/{guild_id}")
191+
response.raise_for_status()
192+
except httpx.HTTPStatusError as e:
193+
msg = f"Failed to delete guild: {e.response.text}"
194+
logger.exception(msg, extra={"guild_id": guild_id, "status_code": e.response.status_code})
195+
raise APIError(msg, status_code=e.response.status_code) from e
196+
except httpx.RequestError as e:
197+
msg = f"Failed to connect to API service: {e!s}"
198+
logger.exception(msg, extra={"guild_id": guild_id})
199+
raise APIError(msg) from e
200+
201+
async def get_or_create_guild(
202+
self,
203+
guild_id: int,
204+
guild_name: str,
205+
prefix: str = "!",
206+
) -> GuildSchema:
207+
"""Get guild or create if it doesn't exist.
208+
209+
Args:
210+
guild_id: Discord guild ID
211+
guild_name: Discord guild name
212+
prefix: Command prefix
213+
214+
Returns:
215+
Guild schema
216+
217+
Raises:
218+
APIError: If the API request fails
219+
"""
220+
# Try to get existing guild
221+
guild = await self.get_guild(guild_id)
222+
if guild is not None:
223+
return guild
224+
225+
# Create new guild
226+
return await self.create_guild(
227+
guild_id=guild_id,
228+
guild_name=guild_name,
229+
prefix=prefix,
230+
)
231+
232+
# Health Check
233+
234+
async def health_check(self) -> dict[str, Any]:
235+
"""Check API service health.
236+
237+
Returns:
238+
Health check response
239+
240+
Raises:
241+
APIError: If the API is unhealthy
242+
"""
243+
try:
244+
response = await self.client.get("/health")
245+
response.raise_for_status()
246+
return response.json()
247+
except (httpx.HTTPStatusError, httpx.RequestError) as e:
248+
msg = f"API health check failed: {e!s}"
249+
logger.exception(msg)
250+
raise APIError(msg) from e

0 commit comments

Comments
 (0)