Skip to content
Merged
48 changes: 48 additions & 0 deletions docs/python/pythonism.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Python-isms that are used often


## Find if list has *any* matches

This is a common problem. Say you have a list of objects with a `parent_id` attribute.
You want to see if there is at least one object in the list where `parent_id` is `117`.

Python offers a one-liner to do this by abusing the [`next()`](https://docs.python.org/3/library/functions.html#next)
function.

```python
# my_objects is a list of objects with parent_id
found_match = next(iter([x for x in my_list if x.parent_id == 117]))

if found_match:
...
```

- `[x for x in my_list if x.parent_id == 117]` is a python list comprehension that
makes a list of all objects matching the `if` statement.
- `iter()` converts the list to an iterator.
- `next()` grabs the next item in the iterator.

This is an alternative to:

```python
found_match: bool = False

for my_object in my_list:
if my_object.parent_id == 117:
found_match = True
break

if found_match:
...
```

##### Pros

- One line
- Easy to read for people familiar with this trick

##### Cons

- On massive lists, it is better to do the looping method. This is because the list comprehension creates a second
list of **all** matches before calling `next()` on it.
- Looping can be more legible for people unfamiliar with the trick.
2 changes: 1 addition & 1 deletion kirovy/constants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

CNCNET_INI_SECTION = "CnCNet"
CNCNET_INI_MAP_ID_KEY = "ID"
CNCNET_INI_PARENT_ID_KEY = "ParentID"
CNCNET_INI_MAP_PARENT_ID_KEY = "ParentID"


class CncnetUserGroup:
Expand Down
10 changes: 10 additions & 0 deletions kirovy/constants/api_codes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import enum


class UploadApiCodes(enum.StrEnum):
GAME_SLUG_DOES_NOT_EXIST = "game-slug-does-not-exist"
MISSING_GAME_SLUG = "missing-game-slug"
FILE_TO_LARGE = "file-too-large"
EMPTY_UPLOAD = "where-file"
DUPLICATE_MAP = "duplicate-map"
FILE_EXTENSION_NOT_SUPPORTED = "file-extension-not-supported"
29 changes: 29 additions & 0 deletions kirovy/exception_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from rest_framework import status
from rest_framework.views import exception_handler

from kirovy.exceptions.view_exceptions import KirovyValidationError
from kirovy.objects import ui_objects
from kirovy.response import KirovyResponse


def kirovy_exception_handler(exception: Exception, context) -> KirovyResponse[ui_objects.ErrorResponseData] | None:
"""Exception handler to deal with our custom exception types.

This gets called via the setting ``REST_FRAMEWORK['EXCEPTION_HANDLER']``.
:attr:`kirovy.settings._base.REST_FRAMEWORK`

.. note::

`The DRF docs <https://www.django-rest-framework.org/api-guide/exceptions/#custom-exception-handling>`_

:param exception:
The raised exception.
:param context:
:return:
Returns the ``KirovyResponse`` if the exception is one we defined.
Otherwise, it calls the base DRF exception handler :func:`rest_framework.views.exception_handler`.
"""
if isinstance(exception, KirovyValidationError):
return KirovyResponse(exception.as_error_response_data(), status=status.HTTP_400_BAD_REQUEST)

return exception_handler(exception, context)
1 change: 1 addition & 0 deletions kirovy/exceptions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class InvalidMimeType(ValidationError):
class InvalidMapFile(ValidationError):
"""Raised when a map can't be parsed or if it's missing a header."""

# TODO: Unify with kirovy.exceptions.view_exceptions.KirovyValidationError
pass


Expand Down
31 changes: 31 additions & 0 deletions kirovy/exceptions/view_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from rest_framework import status
from rest_framework.exceptions import APIException as _DRFAPIException
from django.utils.translation import gettext_lazy as _

from kirovy import typing as _t
from kirovy.objects import ui_objects


class KirovyValidationError(_DRFAPIException):
"""A custom exception that easily converts to the standard ``ErrorResponseData``

See: :class:`kirovy.objects.ui_objects.ErrorResponseData`

This exception is meant to be used within serializers or views.
"""

status_code = status.HTTP_400_BAD_REQUEST
default_detail = _("Invalid input.")
default_code = "invalid"
additional: _t.DictStrAny | None = None
code: str | None
detail: str | None

def __init__(self, detail: str | None = None, code: str | None = None, additional: _t.DictStrAny | None = None):
super().__init__(detail=detail, code=code)
self.code = str(code) if code else self.default_code
self.detail = str(detail) if detail else self.default_detail
self.additional = additional

def as_error_response_data(self) -> ui_objects.ErrorResponseData:
return ui_objects.ErrorResponseData(message=self.detail, code=self.code, additional=self.additional)
15 changes: 15 additions & 0 deletions kirovy/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from structlog import get_logger
import orjson
import typing as t


def default_json_encode_object(value: object) -> str:
json_func: t.Callable[[object], str] | None = getattr(value, "__json__", None)
if json_func and callable(json_func):
return json_func(value)

stringy: bool = type(value).__str__ is not object.__str__ # Check if this object implements __str__
if stringy:
return str(value)

return f"cannot-json-encode--{type(value).__name__}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.2.20 on 2025-03-10 06:47

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("kirovy", "0009_mappreview"),
]

operations = [
migrations.AddField(
model_name="cncmapfile",
name="hash_sha1",
field=models.CharField(max_length=50, null=True),
),
migrations.AddField(
model_name="mappreview",
name="hash_sha1",
field=models.CharField(max_length=50, null=True),
),
]
5 changes: 2 additions & 3 deletions kirovy/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import typing

from django.db.models import UUIDField, Model
from django.db.models import Model

from .cnc_game import CncGame, CncFileExtension
from .cnc_map import CncMap, CncMapFile, MapCategory
Expand All @@ -10,5 +10,4 @@


class SupportsBan(typing.Protocol):
def set_ban(self, is_banned: bool, banned_by: CncUser) -> None:
...
def set_ban(self, is_banned: bool, banned_by: CncUser) -> None: ...
17 changes: 5 additions & 12 deletions kirovy/models/cnc_game.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ def is_valid_extension(extension: str) -> None:
Raised for invalid file extension strings.
"""
if not extension.isalnum():
raise exceptions.InvalidFileExtension(
f'"{extension}" is not a valid file extension. Must be alpha only.'
)
raise exceptions.InvalidFileExtension(f'"{extension}" is not a valid file extension. Must be alpha only.')


class CncFileExtension(CncNetBaseModel):
Expand All @@ -45,9 +43,7 @@ class ExtensionTypes(models.TextChoices):
IMAGE = "image", "image"
"""This file extension represents some kind of image uploaded by a user to display on the website."""

extension = models.CharField(
max_length=32, unique=True, validators=[is_valid_extension], blank=False
)
extension = models.CharField(max_length=32, unique=True, validators=[is_valid_extension], blank=False)
"""The actual file extension. Case insensitive but ``.lower()`` will be called all over."""

about = models.CharField(max_length=2048, null=True, blank=False)
Expand All @@ -61,9 +57,8 @@ class ExtensionTypes(models.TextChoices):
)

def save(self, *args, **kwargs):
is_valid_extension(
self.extension
) # force validator on save instead from a view.
self.extension = self.extension.lower() # Force lowercase
is_valid_extension(self.extension) # force validator on save instead from a view.
super().save(*args, **kwargs)

@property
Expand Down Expand Up @@ -94,9 +89,7 @@ class CncGame(CncNetBaseModel):
Does not affect temporary uploads via the multiplayer lobby.
"""

compatible_with_parent_maps = models.BooleanField(
default=False, null=False, blank=False
)
compatible_with_parent_maps = models.BooleanField(default=False, null=False, blank=False)
"""If true then the maps from the parent game work in this game. e.g. RA2 maps work in YR."""

parent_game = models.ForeignKey("self", models.PROTECT, null=True, default=None)
Expand Down
28 changes: 11 additions & 17 deletions kirovy/models/cnc_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ class MapCategory(CncNetBaseModel):
slug = models.CharField(max_length=16)
"""Unique slug for URLs, auto-generated from the :attr:`~kirovy.models.cnc_map.MapCategory.name`."""

def _set_slug_from_name(
self, update_fields: t.Optional[t.List[str]] = None
) -> t.Optional[t.List[str]]:
def _set_slug_from_name(self, update_fields: t.Optional[t.List[str]] = None) -> t.Optional[t.List[str]]:
"""Sets ``self.slug`` based on ``self.name``.

:param update_fields:
Expand All @@ -27,9 +25,7 @@ def _set_slug_from_name(
The ``update_fields`` for ``.save()``.
"""
new_slug: str = text_utils.slugify(self.name, allow_unicode=False)[:16]
new_slug = new_slug.rstrip(
"-"
) # Remove trailing hyphens if the 16th character was unlucky.
new_slug = new_slug.rstrip("-") # Remove trailing hyphens if the 16th character was unlucky.

if new_slug != self.slug:
self.slug = new_slug
Expand Down Expand Up @@ -98,9 +94,7 @@ class CncMap(cnc_user.CncNetUserOwnedModel):
or searches. Won't have an owner until the client supports logging in.
"""

is_reviewed = models.BooleanField(
default=False, help_text="If true, this map was reviewed by a staff member."
)
is_reviewed = models.BooleanField(default=False, help_text="If true, this map was reviewed by a staff member.")

is_banned = models.BooleanField(
default=False,
Expand Down Expand Up @@ -130,10 +124,7 @@ def next_version_number(self) -> int:
The current latest version, plus one.
"""
previous_version: CncMapFile = (
CncMapFile.objects.filter(cnc_map_id=self.id)
.order_by("-version")
.only("version")
.first()
CncMapFile.objects.filter(cnc_map_id=self.id).order_by("-version").only("version").first()
)
if not previous_version:
return 1
Expand Down Expand Up @@ -168,7 +159,12 @@ def set_ban(self, is_banned: bool, banned_by: cnc_user.CncUser) -> None:


class CncMapFile(file_base.CncNetFileBaseModel):
"""Represents the actual map file that a Command & Conquer game reads."""
"""Represents the actual map file that a Command & Conquer game reads.

.. warning::

``name`` is auto-generated for this file subclass.
"""

width = models.IntegerField()
height = models.IntegerField()
Expand All @@ -182,9 +178,7 @@ class CncMapFile(file_base.CncNetFileBaseModel):

class Meta:
constraints = [
models.UniqueConstraint(
fields=["cnc_map_id", "version"], name="unique_map_version"
),
models.UniqueConstraint(fields=["cnc_map_id", "version"], name="unique_map_version"),
]

def save(self, *args, **kwargs):
Expand Down
Loading