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
57 changes: 46 additions & 11 deletions .github/instructions/python.instructions.md
Original file line number Diff line number Diff line change
@@ -1,37 +1,73 @@
---
applyTo: '**/*.py'
---
Provide project context and coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.

## 🛠️Coding Instructions for Python in This Repository

Follow these rules **strictly** when generating Python code:

### 1. Python Version
### Python Version

* Use Python 3.13: Ensure all code uses features and syntax compatible with Python 3.13.

### 2. **Type Annotations**
### Type Annotations

* Always use full type annotations for all functions and class attributes.
***Exception**: Do **not** add return type annotations in `test_*` functions.

### 3. **Code Style & Formatting**
### Documentation with Annotated Types

* Use `annotated_types.doc()` for parameter and return type documentation instead of traditional docstring Args/Returns sections
* **Apply documentation only for non-obvious parameters/returns**:
- Document complex behaviors that can't be deduced from parameter name and type
- Document validation rules, side effects, or special handling
- Skip documentation for self-explanatory parameters (e.g., `engine: AsyncEngine`, `product_name: ProductName`)
* **Import**: Always add `from annotated_types import doc` when using documentation annotations

**Examples:**
```python
from typing import Annotated
from annotated_types import doc

async def process_users(
engine: AsyncEngine, # No doc needed - self-explanatory
filter_statuses: Annotated[
list[Status] | None,
doc("Only returns users with these statuses")
] = None,
limit: int = 50, # No doc needed - obvious
) -> Annotated[
tuple[list[dict], int],
doc("(user records, total count)")
]:
"""Process users with filtering.
Raises:
ValueError: If no filters provided
"""
```

* **Docstring conventions**:
- Keep docstrings **concise**, focusing on overall function purpose
- Include `Raises:` section for exceptions
- Avoid repeating information already captured in type annotations
- Most information should be deducible from function name, parameter names, types, and annotations

### Code Style & Formatting

* Follow [Python Coding Conventions](../../docs/coding-conventions.md) **strictly**.
* Format code with `black` and `ruff`.
* Lint code with `ruff` and `pylint`.

### 4. **Library Compatibility**
### Library Compatibility

Ensure compatibility with the following library versions:

* `sqlalchemy` ≥ 2.x
* `pydantic` ≥ 2.x
* `fastapi` ≥ 0.100


### 5. **Code Practices**
### Code Practices

* Use `f-string` formatting for all string interpolation except for logging message strings.
* Use **relative imports** within the same package/module.
Expand All @@ -40,13 +76,12 @@ Ensure compatibility with the following library versions:
* Place **all imports at the top** of the file.
* Document functions when the code is not self-explanatory or if asked explicitly.


### 6. **JSON Serialization**
### JSON Serialization

* Prefer `json_dumps` / `json_loads` from `common_library.json_serialization` instead of the built-in `json.dumps` / `json.loads`.
* When using Pydantic models, prefer methods like `model.model_dump_json()` for serialization.

### 7. **aiohttp Framework**
### aiohttp Framework

* **Application Keys**: Always use `web.AppKey` for type-safe application storage instead of string keys
- Define keys with specific types: `APP_MY_KEY: Final = web.AppKey("APP_MY_KEY", MySpecificType)`
Expand All @@ -58,6 +93,6 @@ Ensure compatibility with the following library versions:
* **Error Handling**: Use the established exception handling decorators and patterns
* **Route Definitions**: Use `web.RouteTableDef()` and organize routes logically within modules

### 8. **Running tests**
### Running tests
* Use `--keep-docker-up` flag when testing to keep docker containers up between sessions.
* Always activate the python virtual environment before running pytest.
2 changes: 1 addition & 1 deletion api/specs/web-server/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def as_query(model_class: type[BaseModel]) -> type[BaseModel]:
for field_name, field_info in model_class.model_fields.items():

field_default = field_info.default
assert not field_info.default_factory # nosec
assert not field_info.default_factory, f"got {field_info=}" # nosec
query_kwargs = {
"alias": field_info.alias,
"title": field_info.title,
Expand Down
76 changes: 47 additions & 29 deletions packages/models-library/src/models_library/invitations.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
from datetime import datetime, timezone
from typing import Final
from datetime import UTC, datetime
from typing import Annotated, Final

from pydantic import BaseModel, EmailStr, Field, PositiveInt, field_validator
from pydantic import (
AfterValidator,
BaseModel,
EmailStr,
Field,
PositiveInt,
field_validator,
)

from .products import ProductName

Expand All @@ -11,29 +18,40 @@
class InvitationInputs(BaseModel):
"""Input data necessary to create an invitation"""

issuer: str = Field(
...,
description="Identifies who issued the invitation. E.g. an email, a service name etc. NOTE: it will be trimmed if exceeds maximum",
min_length=1,
max_length=_MAX_LEN,
)
guest: EmailStr = Field(
...,
description="Invitee's email. Note that the registration can ONLY be used with this email",
)
trial_account_days: PositiveInt | None = Field(
default=None,
description="If set, this invitation will activate a trial account."
"Sets the number of days from creation until the account expires",
)
extra_credits_in_usd: PositiveInt | None = Field(
default=None,
description="If set, the account's primary wallet will add extra credits corresponding to this ammount in USD",
)
product: ProductName | None = Field(
default=None,
description="If None, it will use INVITATIONS_DEFAULT_PRODUCT",
)
issuer: Annotated[
str,
Field(
description="Identifies who issued the invitation. E.g. an email, a service name etc. NOTE: it will be trimmed if exceeds maximum",
min_length=1,
max_length=_MAX_LEN,
),
]
guest: Annotated[
EmailStr,
AfterValidator(lambda v: v.lower()),
Field(
description="Invitee's email. Note that the registration can ONLY be used with this email",
),
]
trial_account_days: Annotated[
PositiveInt | None,
Field(
description="If set, this invitation will activate a trial account."
"Sets the number of days from creation until the account expires",
),
] = None
extra_credits_in_usd: Annotated[
PositiveInt | None,
Field(
description="If set, the account's primary wallet will add extra credits corresponding to this ammount in USD",
),
] = None
product: Annotated[
ProductName | None,
Field(
description="If None, it will use INVITATIONS_DEFAULT_PRODUCT",
),
] = None

@field_validator("issuer", mode="before")
@classmethod
Expand All @@ -44,10 +62,10 @@ def trim_long_issuers_to_max_length(cls, v):


class InvitationContent(InvitationInputs):
"""Data in an invitation"""
"""Data within an invitation"""

# avoid using default to mark exactly the time
created: datetime = Field(..., description="Timestamp for creation")
created: Annotated[datetime, Field(description="Timestamp for creation")]

def as_invitation_inputs(self) -> InvitationInputs:
return self.model_validate(
Expand All @@ -62,6 +80,6 @@ def create_from_inputs(
kwargs = invitation_inputs.model_dump(exclude_none=True)
kwargs.setdefault("product", default_product)
return cls(
created=datetime.now(tz=timezone.utc),
created=datetime.now(tz=UTC),
**kwargs,
)
67 changes: 67 additions & 0 deletions packages/models-library/src/models_library/list_operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""API List operation

- Ordering: https://google.aip.dev/132#ordering


SEE ALSO:
- batch_operations.py
"""

from enum import Enum
from typing import TYPE_CHECKING, Annotated, Generic, TypeVar

from annotated_types import doc
from pydantic import BaseModel


class OrderDirection(str, Enum):
ASC = "asc"
DESC = "desc"


if TYPE_CHECKING:
from typing import Protocol

class LiteralField(Protocol):
"""Protocol for Literal string types"""

def __str__(self) -> str: ...

TField = TypeVar("TField", bound=LiteralField)
else:
TField = TypeVar("TField", bound=str)


class OrderClause(BaseModel, Generic[TField]):
field: TField
direction: OrderDirection = OrderDirection.ASC


def check_ordering_list(
order_by: list[tuple[TField, OrderDirection]]
) -> Annotated[
list[tuple[TField, OrderDirection]],
doc("Validated list with duplicates removed and preserving first occurrence order"),
]:
"""Validates ordering list and removes duplicate entries.

Raises:
ValueError: If a field appears with conflicting directions
"""
seen_fields: dict[TField, OrderDirection] = {}
unique_order_by = []

for field, direction in order_by:
if field in seen_fields:
# Field already seen - check if direction matches
if seen_fields[field] != direction:
msg = f"Field '{field}' appears with conflicting directions: {seen_fields[field].value} and {direction.value}"
raise ValueError(msg)
# Same field and direction - skip duplicate
continue

# First time seeing this field
seen_fields[field] = direction
unique_order_by.append((field, direction))

return unique_order_by
Loading
Loading