Compare commits

...

14 commits
v0.5.0 ... main

Author SHA1 Message Date
rus07tam
006c0a494d
Merge pull request #12 from rus07tam/release/v0.6.0
Release/v0.6.0
2025-11-26 17:17:42 +03:00
rus07tam
36d9da10bc feat: add OptionalField 2025-11-26 14:06:10 +00:00
rus07tam
7e8b57793f feat: add ListField 2025-11-26 14:02:48 +00:00
rus07tam
2149fcf308 refactor: add Unset annotation 2025-11-26 14:00:22 +00:00
rus07tam
fee08f3609 feat: add UniqueType.unwrap_or() 2025-11-26 13:58:06 +00:00
rus07tam
2cc15bf250 feat: add ret() 2025-11-26 13:48:31 +00:00
rus07tam
3d132251fc refactor: merge side.py and calls.py to funcs.py 2025-11-26 13:46:49 +00:00
rus07tam
420f7626fb feat: add support default_factory in AutoField 2025-11-26 13:33:16 +00:00
rus07tam
1e82a457ac feat: add default_factory in PrivProperty 2025-11-26 13:26:21 +00:00
rus07tam
12c0f42f99 fix: renaming methods to more logical ones 2025-11-24 15:24:45 +00:00
rus07tam
e61dd387d4 feat!: remove broken plugin system 2025-11-24 15:05:07 +00:00
rus07tam
34b5a1272b refactor: small changes 2025-11-24 15:01:44 +00:00
rus07tam
af4974075c fix: sync license in pyproject.toml with LICENSE file 2025-11-24 14:55:29 +00:00
rus07tam
631c416723 types: add py.typed file 2025-11-24 14:54:46 +00:00
25 changed files with 196 additions and 319 deletions

View file

@ -1,88 +0,0 @@
from typing import final
from pydantic import Field
from snakia.core.ecs import Component
from snakia.core.ecs.system import System
from snakia.core.engine import Engine
from snakia.core.es import Event
from snakia.core.loader import Meta, Plugin, PluginProcessor
from snakia.types import Version
class HealthComponent(Component):
max_value: int = Field(default=100, ge=0)
value: int = Field(default=100, ge=0)
class DamageComponent(Component):
damage: int = Field(ge=0)
ticks: int = Field(default=1, ge=0)
class HealComponent(Component):
heal: int = Field(ge=0)
ticks: int = Field(default=1, ge=0)
class DeathEvent(Event):
entity: int = Field()
class HealthProcessor(PluginProcessor):
def process(self, system: System) -> None:
for entity, (heal, health) in system.get_components(
HealComponent, HealthComponent
):
health.value += heal.heal
heal.ticks -= 1
if heal.ticks <= 0:
system.remove_component(entity, HealComponent)
for entity, (damage, health) in system.get_components(
DamageComponent, HealthComponent
):
health.value -= damage.damage
damage.ticks -= 1
if damage.ticks <= 0:
system.remove_component(entity, DamageComponent)
if health.value <= 0:
system.remove_component(entity, HealthComponent)
self.plugin.dispatcher.publish(DeathEvent(entity=entity))
@final
class HealthPlugin(
Plugin,
meta=Meta(
name="health",
author="snakia",
version=Version.from_args(1, 0, 0),
subscribers=(),
processors=(HealthProcessor,),
),
):
def on_load(self) -> None:
pass
def on_unload(self) -> None:
pass
def main() -> None:
engine = Engine()
engine.loader.register(HealthPlugin)
engine.loader.load_all()
@engine.dispatcher.on(DeathEvent)
def on_death(event: DeathEvent) -> None:
print(f"Entity: {event.entity} is death!")
player = engine.system.create_entity()
engine.system.add_component(player, HealthComponent())
engine.system.add_component(player, DamageComponent(damage=10, ticks=10))
engine.start()
if __name__ == "__main__":
main()

0
py.typed Normal file
View file

View file

@ -26,7 +26,7 @@ dependencies = [
"types-networkx>=3.5.0.20251106",
"typing-extensions>=4.15.0",
]
license = "CC0-1.0"
license = "Unlicense"
license-files = ["LICENSE"]
[project.urls]

View file

@ -3,14 +3,12 @@ from typing import Final
from .ecs import System
from .es import Dispatcher
from .loader.loader import Loader
class Engine:
__slots__ = (
"system",
"dispatcher",
"loader",
"__system_thread",
"__dispatcher_thread",
)
@ -18,12 +16,13 @@ class Engine:
def __init__(self) -> None:
self.system: Final = System()
self.dispatcher: Final = Dispatcher()
self.loader: Final = Loader(self)
self.__system_thread: threading.Thread | None = None
self.__dispatcher_thread: threading.Thread | None = None
def start(self) -> None:
self.__system_thread = threading.Thread(target=self.system.start, daemon=False)
self.__system_thread = threading.Thread(
target=self.system.start, daemon=False
)
self.__dispatcher_thread = threading.Thread(
target=self.dispatcher.start, daemon=False
)

View file

@ -1,6 +0,0 @@
from .loadable import Loadable
from .meta import Meta
from .plugin import Plugin
from .plugin_processor import PluginProcessor
__all__ = ["Loadable", "Meta", "Plugin", "PluginProcessor"]

View file

@ -1,18 +0,0 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from snakia.core.engine import Engine
class Loadable(ABC):
@abstractmethod
def __init__(self, engine: Engine) -> None: ...
@abstractmethod
def load(self) -> None: ...
@abstractmethod
def unload(self) -> None: ...

View file

@ -1,26 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Callable, Final
if TYPE_CHECKING:
from snakia.core.engine import Engine
from snakia.core.loader import Loadable
class Loader:
__slots__ = ("__engine", "__loadables")
def __init__(self, engine: Engine) -> None:
self.__engine: Final = engine
self.__loadables: Final[list[Loadable]] = []
def register(self, loadable: Callable[[Engine], Loadable]) -> None:
self.__loadables.append(loadable(self.__engine))
def load_all(self) -> None:
for loadable in self.__loadables:
loadable.load()
def unload_all(self) -> None:
for loadable in reversed(self.__loadables):
loadable.unload()

View file

@ -1,35 +0,0 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
from snakia.core.es import Event, Subscriber
from snakia.types import Version
from .plugin_processor import PluginProcessor
class Meta(BaseModel):
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
name: str = Field(
default="unknown",
min_length=4,
max_length=32,
pattern="^[a-z0-9_]{4,32}$",
)
author: str = Field(
default="unknown",
min_length=4,
max_length=32,
pattern="^[a-z0-9_]{4,32}$",
)
version: Version = Field(default_factory=lambda: Version(major=1, minor=0, patch=0))
subscribers: tuple[tuple[type[Event], Subscriber[Event]], ...] = Field(
default_factory=tuple
)
processors: tuple[type[PluginProcessor], ...] = Field(default_factory=tuple)
@property
def id(self) -> str:
return f"{self.author}.{self.name}"

View file

@ -1,72 +0,0 @@
from __future__ import annotations
from abc import abstractmethod
from typing import TYPE_CHECKING, ClassVar, Final, final
from snakia.core.ecs import System
from snakia.core.es import Dispatcher
from .loadable import Loadable
from .meta import Meta
if TYPE_CHECKING:
from snakia.core.engine import Engine
class Plugin(Loadable):
__meta: ClassVar[Meta]
@final
def __init__(self, engine: Engine) -> None:
self.__engine: Final = engine
@final
@property
def meta(self) -> Meta:
"""The plugin's metadata."""
return self.__meta
@final
@property
def dispatcher(self) -> Dispatcher:
return self.__engine.dispatcher
@final
@property
def system(self) -> System:
return self.__engine.system
@final
def load(self) -> None:
for processor in self.meta.processors:
self.__engine.system.add_processor(processor(self))
for event_type, subscriber in self.meta.subscribers:
self.__engine.dispatcher.subscribe(event_type, subscriber)
self.on_load()
@final
def unload(self) -> None:
for processor in self.meta.processors:
self.__engine.system.remove_processor(processor)
for event_type, subscriber in self.meta.subscribers:
self.__engine.dispatcher.unsubscribe(event_type, subscriber)
self.on_unload()
@abstractmethod
def on_load(self) -> None:
pass
@abstractmethod
def on_unload(self) -> None:
pass
if TYPE_CHECKING:
@final
def __init_subclass__(cls, meta: Meta) -> None:
pass
else:
def __init_subclass__(cls, meta: Meta) -> None:
cls.meta = meta

View file

@ -1,14 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Final, final
from snakia.core.ecs import Processor
if TYPE_CHECKING:
from .plugin import Plugin
class PluginProcessor(Processor):
@final
def __init__(self, plugin: Plugin) -> None:
self.plugin: Final = plugin

View file

@ -29,8 +29,7 @@ class BaseBindable(Generic[T]):
def value(self) -> T:
if self.has_value:
return self.__value
else:
return self.default_value
return self.default_value
@property
def has_value(self) -> bool:

View file

@ -95,7 +95,7 @@ def combine(
)
def subscriber(_: ValueChanged[Any]) -> None:
combined.set(combiner(*[*map(lambda s: s.value, sources)]))
combined.set(combiner(*map(lambda s: s.value, sources)))
for source in sources:
if isinstance(source, Bindable):
@ -185,7 +185,7 @@ def async_combine(
)
async def subscriber(_: ValueChanged[Any]) -> None:
result = await combiner(*[*map(lambda s: s.value, sources)])
result = await combiner(*map(lambda s: s.value, sources))
await combined.set(result)
for source in sources:

View file

@ -3,6 +3,8 @@ from .bool import BoolField
from .field import Field
from .float import FloatField
from .int import IntField
from .list import ListField
from .optional import OptionalField
from .str import StrField
__all__ = [
@ -11,5 +13,7 @@ __all__ = [
"BoolField",
"FloatField",
"IntField",
"ListField",
"OptionalField",
"StrField",
]

View file

@ -1,5 +1,9 @@
import pickle
from typing import Final, Generic, TypeVar
from typing import Callable, Final, Generic, TypeVar, overload
from typing_extensions import Self
from snakia.types import Unset
from .field import Field
@ -9,15 +13,40 @@ T = TypeVar("T")
class AutoField(Field[T], Generic[T]):
__slots__ = ("__target_type",)
def __init__(self, default_value: T, *, target_type: type[T] | None = None) -> None:
super().__init__(default_value)
self.__target_type: Final = target_type
@overload
def __init__(
self, default_value: T, *, target_type: type[T] | Unset = Unset()
) -> None: ...
@overload
def __init__(
self,
*,
default_factory: Callable[[Self], T],
target_type: type[T] | Unset = Unset(),
) -> None: ...
def __init__(
self,
default_value: T | Unset = Unset(),
*,
default_factory: Callable[[Self], T] | Unset = Unset(),
target_type: type[T] | Unset = Unset(),
) -> None:
if not Unset.itis(default_factory):
super().__init__(default_factory=Unset.unwrap(default_factory))
elif not Unset.itis(default_value):
super().__init__(Unset.unwrap(default_value))
else:
super().__init__()
self.__target_type: Final[type] = Unset.unwrap_or(target_type, object)
def serialize(self, value: T, /) -> bytes:
return pickle.dumps(value)
def deserialize(self, serialized: bytes, /) -> T:
value = pickle.loads(serialized)
if not isinstance(value, self.__target_type or object):
return self.default_value
if not isinstance(value, self.__target_type):
return self._get_default()
return value # type: ignore

View file

@ -1,7 +1,14 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Callable, Final, Generic, TypeVar, final
from typing import (
TYPE_CHECKING,
Any,
Callable,
Generic,
TypeVar,
final,
)
from snakia.property.priv_property import PrivProperty
from snakia.utils import inherit
@ -11,10 +18,6 @@ R = TypeVar("R")
class Field(ABC, PrivProperty[T], Generic[T]):
def __init__(self, default_value: T) -> None:
self.default_value: Final[T] = default_value
super().__init__(default_value)
@abstractmethod
def serialize(self, value: T, /) -> bytes:
"""Serialize a value
@ -42,14 +45,18 @@ class Field(ABC, PrivProperty[T], Generic[T]):
serialize: Callable[[Field[R], R], bytes],
deserialize: Callable[[Field[R], bytes], R],
) -> type[Field[R]]:
return inherit(cls, {"serialize": serialize, "deserialize": deserialize})
return inherit(
cls, {"serialize": serialize, "deserialize": deserialize}
)
@final
@staticmethod
def get_fields(class_: type[Any] | Any, /) -> dict[str, Field[Any]]:
if not isinstance(class_, type):
class_ = class_.__class__
return {k: v for k, v in class_.__dict__.items() if isinstance(v, Field)}
return {
k: v for k, v in class_.__dict__.items() if isinstance(v, Field)
}
if TYPE_CHECKING:

38
src/snakia/field/list.py Normal file
View file

@ -0,0 +1,38 @@
from typing import Callable, Final, Iterable, TypeVar
from typing_extensions import Self
from .field import Field
T = TypeVar("T")
class ListField(Field[list[T]]):
def __init__(
self,
field: Field[T],
*,
length_size: int = 1,
default_factory: Callable[[Self], Iterable[T]] = lambda _: (),
) -> None:
self.length_size: Final[int] = length_size
self.field: Final = field
super().__init__(default_factory=lambda s: [*default_factory(s)])
def serialize(self, items: list[T], /) -> bytes:
result = b""
for item in items:
value = self.field.serialize(item)
length_prefix = len(value).to_bytes(self.length_size, "big")
result += length_prefix + value
return result
def deserialize(self, serialized: bytes, /) -> list[T]:
result = []
while serialized:
length = int.from_bytes(serialized[: self.length_size], "big")
serialized = serialized[self.length_size :]
item = self.field.deserialize(serialized[:length])
serialized = serialized[length:]
result.append(item)
return result

View file

@ -0,0 +1,28 @@
from typing import Final, TypeVar
from .field import Field
T = TypeVar("T")
class OptionalField(Field[T | None]):
def __init__(
self,
field: Field[T],
*,
none_value: bytes = b"",
) -> None:
super().__init__(None)
self.none_value: Final = none_value
self.field: Final = field
def serialize(self, value: T | None, /) -> bytes:
if value is None:
return self.none_value
return self.field.serialize(value)
def deserialize(self, serialized: bytes, /) -> T | None:
if serialized == self.none_value:
return None
return self.field.deserialize(serialized)

View file

@ -4,6 +4,8 @@ from .bool import BoolField as bool
from .field import Field as field
from .float import FloatField as float
from .int import IntField as int
from .list import ListField as list
from .optional import OptionalField as optional
from .str import StrField as str
__all__ = [
@ -12,5 +14,7 @@ __all__ = [
"field",
"float",
"int",
"list",
"optional",
"str",
]

View file

@ -1,22 +1,44 @@
from typing import Any, Generic, TypeVar
from typing import Any, Callable, Final, Generic, TypeVar, overload
from typing_extensions import Self
from snakia.types import Unset
T = TypeVar("T")
class PrivProperty(Generic[T]):
__slots__ = "__name", "__default_value"
__slots__ = "__name", "__default_value", "__default_factory"
__name: str
def __init__(self, default_value: T | None = None) -> None:
self.__default_value: T | None = default_value
@overload
def __init__(self) -> None: ...
@overload
def __init__(self, default_value: T) -> None: ...
@overload
def __init__(self, *, default_factory: Callable[[Self], T]) -> None: ...
def __init__(
self,
default_value: T | Unset = Unset(),
default_factory: Callable[[Self], T] | Unset = Unset(),
) -> None:
self.__default_value: Final[T | Unset] = default_value
self.__default_factory: Final[Callable[[Self], T] | Unset] = default_factory
def _get_default(self: Self) -> T:
return Unset.map(
self.__default_factory,
lambda f: f(self),
lambda _: Unset.unwrap(self.__default_value),
)
def __set_name__(self, owner: type, name: str) -> None:
self.__name = f"_{owner.__name__}__{name}"
def __get__(self, instance: Any, owner: type | None = None, /) -> T:
if self.__default_value:
return getattr(instance, self.__name, self.__default_value)
if not hasattr(instance, self.__name):
setattr(instance, self.__name, self._get_default())
return getattr(instance, self.__name) # type: ignore
def __set__(self, instance: Any, value: T, /) -> None:

View file

@ -12,7 +12,7 @@ MARKERS_ATTR = "__snakia_markers__"
def _get_all_markers(obj: Any) -> dict[type["Marker"], "Marker"]:
return get_or_set_attr(obj, MARKERS_ATTR, dict[type[Marker], Marker]())
return get_or_set_attr(obj, MARKERS_ATTR, dict())
class Marker:

View file

@ -49,24 +49,29 @@ class UniqueType(type):
raise TypeError(f"{cls} not unwrapped")
return value # type: ignore
def unwrap_or(cls: type[T], value: V | type[T] | T, default: R, /) -> V | R:
if value is cls or isinstance(value, cls):
return default
return value # type: ignore
def map(
cls: type[T],
value: V | type[T] | T,
and_then: Callable[[V], R],
or_else: Callable[[type[T]], R],
or_else: Callable[[V], R],
and_then: Callable[[type[T]], R],
) -> R:
if value is cls or isinstance(value, cls):
return or_else(cls)
return and_then(value) # type: ignore
return and_then(cls)
return or_else(value) # type: ignore
def and_then(
def or_else(
cls: type[T], value: V | type[T] | T, func: Callable[[V], R]
) -> type[T] | R:
if value is cls or isinstance(value, cls):
return cls
return func(value) # type: ignore
def or_else(
def and_then(
cls: type[T], value: V | type[T] | T, func: Callable[[type[T]], R]
) -> R | V:
if value is cls or isinstance(value, cls):

View file

@ -1,10 +1,9 @@
from .attrs import get_attrs, get_or_set_attr
from .calls import call, caller
from .exceptions import catch, throw
from .frames import frame
from .funcs import call, caller, ret, side, side_func
from .gil import GIL_ENABLED, nolock
from .inherit import inherit
from .side import side, side_func
from .this import this
from .to_async import to_async
@ -17,6 +16,7 @@ __all__ = [
"frame",
"inherit",
"nolock",
"ret",
"side",
"side_func",
"this",

View file

@ -1,12 +0,0 @@
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
T = TypeVar("T")
def call(f: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
return f(*args, **kwargs)
def caller(f: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> Callable[..., T]:
return lambda *_, **__: f(*args, **kwargs)

24
src/snakia/utils/funcs.py Normal file
View file

@ -0,0 +1,24 @@
from typing import Any, Callable, ParamSpec, TypeVar
P = ParamSpec("P")
T = TypeVar("T")
def call(f: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
return f(*args, **kwargs)
def caller(f: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> Callable[..., T]:
return lambda *_, **__: f(*args, **kwargs)
def side(value: T, *_: Any, **__: Any) -> T:
return value
def side_func(value: T, *_: Any, **__: Any) -> Callable[..., T]:
return lambda *_, **__: value
def ret() -> Callable[[T], T]:
return lambda x: x

View file

@ -1,11 +0,0 @@
from typing import Any, Callable, TypeVar
T = TypeVar("T")
def side(value: T, *_: Any, **__: Any) -> T:
return value
def side_func(value: T, *_: Any, **__: Any) -> Callable[..., T]:
return lambda *_, **__: value