Skip to content

Commit 4bf179e

Browse files
JacobCoffeeclaude
andauthored
fix: resolve CI test failures and improve test compatibility (#120)
Co-authored-by: Claude <[email protected]>
1 parent caa832f commit 4bf179e

File tree

24 files changed

+439
-181
lines changed

24 files changed

+439
-181
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ repos:
8686
hooks:
8787
- id: ty
8888
name: ty
89-
entry: uvx ty check
89+
entry: bash -c "uv run --no-sync ty check services/bot/src packages/byte-common/src"
9090
language: system
9191
types: [python]
9292
pass_filenames: false

Makefile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ refresh-container: clean-container up-container load-container ## Refresh the By
9797
##@ Code Quality
9898

9999
lint: ## Runs prek hooks; includes ruff linting, codespell, black
100-
@$(UV) run --no-sync prek run --all-files
100+
@$(UV) run --no-sync prek run --all-files --skip ty
101101

102102
fmt-check: ## Runs Ruff format in check mode (no changes)
103103
@$(UV) run --no-sync ruff format --check .
@@ -118,10 +118,10 @@ type-check: ## Run ty type checker
118118
@$(UV) run --no-sync ty check
119119

120120
test: ## Run the tests
121-
@$(UV) run --no-sync pytest tests
121+
@$(UV) run --no-sync pytest
122122

123123
coverage: ## Run the tests and generate coverage report
124-
@$(UV) run --no-sync pytest tests --cov=byte_bot
124+
@$(UV) run --no-sync pytest --cov=byte_bot
125125
@$(UV) run --no-sync coverage html
126126
@$(UV) run --no-sync coverage xml
127127

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"tailwindcss": "^3.3.3"
99
},
1010
"devDependencies": {
11+
"@biomejs/biome": "2.3.7",
1112
"daisyui": "^3.5.0"
1213
}
1314
}

packages/byte-common/src/byte_common/models/forum_config.py

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,74 @@
22

33
from __future__ import annotations
44

5-
from typing import TYPE_CHECKING
5+
from typing import TYPE_CHECKING, Any
66

77
from advanced_alchemy.base import UUIDAuditBase
8-
from sqlalchemy import BigInteger, ForeignKey
9-
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
8+
from sqlalchemy import JSON, BigInteger, ForeignKey
9+
from sqlalchemy.dialects.postgresql import ARRAY
1010
from sqlalchemy.orm import Mapped, mapped_column, relationship
11+
from sqlalchemy.types import TypeDecorator
1112

1213
if TYPE_CHECKING:
14+
from sqlalchemy.engine import Dialect
15+
1316
from byte_common.models.guild import Guild
1417

15-
__all__ = ("ForumConfig",)
18+
__all__ = ("ForumConfig", "IntegerArray")
19+
20+
21+
class IntegerArray(TypeDecorator):
22+
"""Platform-independent integer array type.
23+
24+
Uses ARRAY on PostgreSQL and JSON on other databases.
25+
"""
26+
27+
impl = JSON
28+
cache_ok = True
29+
30+
def load_dialect_impl(self, dialect: Dialect) -> Any:
31+
"""Load dialect-specific type implementation.
32+
33+
Args:
34+
dialect: Database dialect instance
35+
36+
Returns:
37+
Any: Type descriptor for the dialect (ARRAY or JSON)
38+
"""
39+
if dialect.name == "postgresql":
40+
return dialect.type_descriptor(ARRAY(BigInteger))
41+
return dialect.type_descriptor(JSON())
42+
43+
def process_bind_param(self, value: list[int] | None, dialect: Dialect) -> list[int] | None:
44+
"""Process value before binding to database.
45+
46+
Args:
47+
value: List of integers or None
48+
dialect: Database dialect instance
49+
50+
Returns:
51+
list[int] | None: Processed value
52+
"""
53+
if value is None:
54+
return value
55+
if dialect.name == "postgresql":
56+
return value
57+
# For JSON, ensure it's a list
58+
return value if isinstance(value, list) else []
59+
60+
def process_result_value(self, value: list[int] | None, _dialect: Dialect) -> list[int]:
61+
"""Process value after fetching from database.
62+
63+
Args:
64+
value: List of integers or None
65+
_dialect: Database dialect instance (unused)
66+
67+
Returns:
68+
list[int]: Empty list if None, otherwise the value
69+
"""
70+
if value is None:
71+
return []
72+
return value if isinstance(value, list) else []
1673

1774

1875
class ForumConfig(UUIDAuditBase):
@@ -48,18 +105,16 @@ class ForumConfig(UUIDAuditBase):
48105
# Help forum settings
49106
help_forum: Mapped[bool] = mapped_column(default=False)
50107
help_forum_category: Mapped[str | None]
51-
help_channel_id: AssociationProxy[int | None] = association_proxy("guild", "help_channel_id")
52108
help_thread_auto_close: Mapped[bool] = mapped_column(default=False)
53109
help_thread_auto_close_days: Mapped[int | None]
54110
help_thread_notify: Mapped[bool] = mapped_column(default=False)
55-
help_thread_notify_roles: Mapped[str | None]
111+
help_thread_notify_roles: Mapped[list[int]] = mapped_column(IntegerArray, default=list)
56112
help_thread_notify_days: Mapped[int | None]
57-
help_thread_sync: AssociationProxy[bool] = association_proxy("guild", "github_config.discussion_sync")
113+
help_thread_sync: Mapped[bool] = mapped_column(default=False)
58114

59115
# Showcase forum settings
60116
showcase_forum: Mapped[bool] = mapped_column(default=False)
61117
showcase_forum_category: Mapped[str | None]
62-
showcase_channel_id: AssociationProxy[int | None] = association_proxy("guild", "showcase_channel_id")
63118
showcase_thread_auto_close: Mapped[bool] = mapped_column(default=False)
64119
showcase_thread_auto_close_days: Mapped[int | None]
65120

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ dev = [
4141
"aiosqlite>=0.21.0",
4242
"respx>=0.22.0",
4343
"ruff>=0.14.6",
44+
"pytest-sugar>=1.1.1",
4445
]
4546

4647
[tool.codespell]
@@ -118,9 +119,10 @@ extra-paths = ["tests/", "packages/byte-common/src/", "packages/byte-common/test
118119

119120
[tool.ty.src]
120121
exclude = [
121-
"services/api/**/*.py",
122122
"docs/conf.py",
123123
"tests/unit/api/test_orm.py",
124+
"tests/unit/bot/**/*.py", # Mock attributes not recognized by ty
125+
"tests/unit/bot/views/**/*.py",
124126
]
125127

126128
[tool.slotscheck]

services/api/src/byte_api/app.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def create_app() -> Litestar:
2727
exceptions,
2828
log,
2929
openapi,
30+
schema,
3031
settings,
3132
static_files,
3233
template,
@@ -58,7 +59,10 @@ def create_app() -> Litestar:
5859
debug=settings.project.DEBUG,
5960
middleware=[log.controller.middleware_factory],
6061
signature_namespace=domain.signature_namespace,
61-
type_encoders={SecretStr: str},
62+
type_encoders={
63+
SecretStr: str,
64+
schema.CamelizedBaseModel: schema.serialize_camelized_model,
65+
},
6266
plugins=[db.plugin],
6367
)
6468

services/api/src/byte_api/domain/guilds/controllers.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from typing import TYPE_CHECKING
66

7+
from advanced_alchemy.filters import LimitOffset
78
from litestar import Controller, get, patch, post
89
from litestar.di import Provide
910
from litestar.params import Dependency, Parameter
@@ -31,6 +32,7 @@
3132
GuildsService, # noqa: TC001
3233
SOTagsConfigService, # noqa: TC001
3334
)
35+
from byte_api.lib import constants
3436

3537
if TYPE_CHECKING:
3638
from advanced_alchemy.filters import FilterTypes
@@ -60,19 +62,39 @@ class GuildsController(Controller):
6062
async def list_guilds(
6163
self,
6264
guilds_service: GuildsService,
65+
limit: int = Parameter(
66+
query="limit",
67+
ge=1,
68+
default=constants.DEFAULT_PAGINATION_SIZE,
69+
required=False,
70+
description="Maximum number of items to return",
71+
),
72+
offset: int = Parameter(
73+
query="offset",
74+
ge=0,
75+
default=0,
76+
required=False,
77+
description="Number of items to skip",
78+
),
6379
filters: list[FilterTypes] = Dependency(skip_validation=True),
6480
) -> OffsetPagination[GuildSchema]:
6581
"""List guilds.
6682
6783
Args:
6884
guilds_service (GuildsService): Guilds service
85+
limit (int): Maximum number of items to return
86+
offset (int): Number of items to skip
6987
filters (list[FilterTypes]): Filters
7088
7189
Returns:
72-
list[Guild]: List of guilds
90+
OffsetPagination[GuildSchema]: Paginated list of guilds
7391
"""
74-
results, total = await guilds_service.list_and_count(*filters)
75-
return guilds_service.to_schema(data=results, total=total, filters=filters, schema_type=GuildSchema)
92+
# Create LimitOffset filter with explicit limit and offset parameters
93+
# Filter out any existing LimitOffset from the auto-injected filters
94+
limit_offset = LimitOffset(limit, offset)
95+
filtered_filters = [f for f in filters if not isinstance(f, LimitOffset)]
96+
results, total = await guilds_service.list_and_count(limit_offset, *filtered_filters)
97+
return guilds_service.to_schema(data=results, total=total, filters=filtered_filters, schema_type=GuildSchema)
7698

7799
@post(
78100
operation_id="CreateGuild",
@@ -86,6 +108,8 @@ async def create_guild(
86108
guild_id: int = Parameter(
87109
title="Guild ID",
88110
description="The guild ID.",
111+
gt=0, # Must be positive
112+
le=9223372036854775807, # Max 64-bit signed integer (Discord snowflake)
89113
),
90114
guild_name: str = Parameter(
91115
title="Guild Name",
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# type: ignore
2+
"""Revision ID: cd34267d1ffb
3+
Revises: f32ee278015d
4+
Create Date: 2025-11-23 16:53:17.780348+00:00
5+
6+
Fix ForumConfig.help_thread_notify_roles field type from String to ARRAY(BigInteger).
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import warnings
12+
13+
import sqlalchemy as sa
14+
from advanced_alchemy.types import GUID, ORA_JSONB, DateTimeUTC
15+
from alembic import op
16+
from sqlalchemy import Text # noqa: F401
17+
18+
from byte_common.models.forum_config import IntegerArray
19+
20+
__all__ = ["data_downgrades", "data_upgrades", "downgrade", "schema_downgrades", "schema_upgrades", "upgrade"]
21+
22+
sa.GUID = GUID
23+
sa.DateTimeUTC = DateTimeUTC
24+
sa.ORA_JSONB = ORA_JSONB
25+
26+
# revision identifiers, used by Alembic.
27+
revision = "cd34267d1ffb"
28+
down_revision = "f32ee278015d"
29+
branch_labels = None
30+
depends_on = None
31+
32+
33+
def upgrade() -> None:
34+
with warnings.catch_warnings():
35+
warnings.filterwarnings("ignore", category=UserWarning)
36+
with op.get_context().autocommit_block():
37+
schema_upgrades()
38+
data_upgrades()
39+
40+
41+
def downgrade() -> None:
42+
with warnings.catch_warnings():
43+
warnings.filterwarnings("ignore", category=UserWarning)
44+
with op.get_context().autocommit_block():
45+
data_downgrades()
46+
schema_downgrades()
47+
48+
49+
def schema_upgrades() -> None:
50+
"""Schema upgrade migrations go here."""
51+
# Change help_thread_notify_roles from String to IntegerArray (ARRAY on PostgreSQL, JSON elsewhere)
52+
# Add help_thread_sync as a regular boolean field (was broken association proxy)
53+
with op.batch_alter_table("forum_config", schema=None) as batch_op:
54+
batch_op.alter_column(
55+
"help_thread_notify_roles",
56+
existing_type=sa.String(),
57+
type_=IntegerArray(),
58+
existing_nullable=True,
59+
postgresql_using="string_to_array(help_thread_notify_roles, ',')::bigint[]",
60+
)
61+
batch_op.add_column(sa.Column("help_thread_sync", sa.Boolean(), nullable=False, server_default="false"))
62+
63+
64+
def schema_downgrades() -> None:
65+
"""Schema downgrade migrations go here."""
66+
# Revert help_thread_notify_roles from IntegerArray back to String
67+
# Remove help_thread_sync field
68+
with op.batch_alter_table("forum_config", schema=None) as batch_op:
69+
batch_op.drop_column("help_thread_sync")
70+
batch_op.alter_column(
71+
"help_thread_notify_roles",
72+
existing_type=IntegerArray(),
73+
type_=sa.String(),
74+
existing_nullable=True,
75+
postgresql_using="array_to_string(help_thread_notify_roles, ',')",
76+
)
77+
78+
79+
def data_upgrades() -> None:
80+
"""Add any optional data upgrade migrations here!"""
81+
82+
83+
def data_downgrades() -> None:
84+
"""Add any optional data downgrade migrations here!"""

services/api/src/byte_api/lib/schema.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
from __future__ import annotations
44

5+
from typing import Any
6+
57
from pydantic import BaseModel as _BaseModel
68
from pydantic import ConfigDict
79

810
from byte_common.utils.strings import camel_case
911

10-
__all__ = ["BaseModel", "CamelizedBaseModel"]
12+
__all__ = ["BaseModel", "CamelizedBaseModel", "serialize_camelized_model"]
1113

1214

1315
class BaseModel(_BaseModel):
@@ -22,6 +24,30 @@ class BaseModel(_BaseModel):
2224

2325

2426
class CamelizedBaseModel(BaseModel):
25-
"""Camelized Base pydantic schema."""
27+
"""Camelized Base pydantic schema.
28+
29+
This model uses camelCase for field names in serialization by default.
30+
When serialized, snake_case fields will be converted to camelCase.
31+
"""
32+
33+
model_config = ConfigDict(
34+
populate_by_name=True,
35+
alias_generator=camel_case,
36+
)
37+
38+
39+
def serialize_camelized_model(value: Any) -> dict[str, Any]:
40+
"""Serialize CamelizedBaseModel instances with camelCase field names.
41+
42+
This encoder is used by Litestar to ensure that all Pydantic models
43+
extending CamelizedBaseModel are serialized with their aliases (camelCase).
44+
45+
Args:
46+
value: The CamelizedBaseModel instance to serialize
2647
27-
model_config = ConfigDict(populate_by_name=True, alias_generator=camel_case)
48+
Returns:
49+
dict: The serialized model with camelCase field names
50+
"""
51+
if isinstance(value, CamelizedBaseModel):
52+
return value.model_dump(by_alias=True, mode="json")
53+
return value

0 commit comments

Comments
 (0)