initial commit
This commit is contained in:
commit
19c9b9537d
115 changed files with 4940 additions and 0 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
use flake
|
||||||
50
.github/workflows/docs-publish.yml
vendored
Normal file
50
.github/workflows/docs-publish.yml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
name: Documentation publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-docs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.12"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up uv
|
||||||
|
uses: astral-sh/setup-uv@v7
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
activate-environment: "false"
|
||||||
|
enable-cache: "auto"
|
||||||
|
cache-dependency-glob: |
|
||||||
|
**/pyproject.toml
|
||||||
|
**/uv.lock
|
||||||
|
restore-cache: "true"
|
||||||
|
save-cache: "true"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --all-groups
|
||||||
|
- name: Install Sphinx
|
||||||
|
run: uv pip install sphinx sphinx-rtd-theme sphinx-autodoc-typehints
|
||||||
|
- name: Build documentation
|
||||||
|
run: uv run sphinx-build -b html docs/source docs/_build/html
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: docs/_build/html
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
33
.github/workflows/pylint.yml
vendored
Normal file
33
.github/workflows/pylint.yml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
name: Pylint
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.12"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Set up uv
|
||||||
|
uses: astral-sh/setup-uv@v7
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
activate-environment: "true"
|
||||||
|
enable-cache: "auto"
|
||||||
|
cache-dependency-glob: |
|
||||||
|
**/pyproject.toml
|
||||||
|
**/uv.lock
|
||||||
|
restore-cache: "true"
|
||||||
|
save-cache: "true"
|
||||||
|
- name: Install project dependencies
|
||||||
|
run: |
|
||||||
|
uv sync --all-groups
|
||||||
|
- name: Install pylint
|
||||||
|
run: |
|
||||||
|
uv pip install pylint
|
||||||
|
- name: Lint code with pylint
|
||||||
|
run: |
|
||||||
|
uv run pylint src/snakia
|
||||||
59
.github/workflows/pypi-publish.yml
vendored
Normal file
59
.github/workflows/pypi-publish.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
name: PyPi publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.12"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv with all available options
|
||||||
|
uses: astral-sh/setup-uv@v7
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
activate-environment: "false"
|
||||||
|
enable-cache: "auto"
|
||||||
|
cache-dependency-glob: |
|
||||||
|
**/pyproject.toml
|
||||||
|
**/uv.lock
|
||||||
|
restore-cache: "true"
|
||||||
|
save-cache: "true"
|
||||||
|
- run: uv build
|
||||||
|
|
||||||
|
- name: Upload distributions
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-dists
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
pypi-publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- release-build
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
environment:
|
||||||
|
name: pypi
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Retrieve release distributions
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-dists
|
||||||
|
path: dist/
|
||||||
|
|
||||||
|
- name: Publish release distributions to PyPI
|
||||||
|
uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
|
with:
|
||||||
|
packages-dir: dist/
|
||||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# Others
|
||||||
|
.direnv
|
||||||
|
.mypy_cache
|
||||||
|
.python-version
|
||||||
|
.vscode
|
||||||
|
_autosummary
|
||||||
121
LICENSE
Normal file
121
LICENSE
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
Creative Commons Legal Code
|
||||||
|
|
||||||
|
CC0 1.0 Universal
|
||||||
|
|
||||||
|
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
|
||||||
|
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
|
||||||
|
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
|
||||||
|
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
|
||||||
|
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
|
||||||
|
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
|
||||||
|
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
|
||||||
|
HEREUNDER.
|
||||||
|
|
||||||
|
Statement of Purpose
|
||||||
|
|
||||||
|
The laws of most jurisdictions throughout the world automatically confer
|
||||||
|
exclusive Copyright and Related Rights (defined below) upon the creator
|
||||||
|
and subsequent owner(s) (each and all, an "owner") of an original work of
|
||||||
|
authorship and/or a database (each, a "Work").
|
||||||
|
|
||||||
|
Certain owners wish to permanently relinquish those rights to a Work for
|
||||||
|
the purpose of contributing to a commons of creative, cultural and
|
||||||
|
scientific works ("Commons") that the public can reliably and without fear
|
||||||
|
of later claims of infringement build upon, modify, incorporate in other
|
||||||
|
works, reuse and redistribute as freely as possible in any form whatsoever
|
||||||
|
and for any purposes, including without limitation commercial purposes.
|
||||||
|
These owners may contribute to the Commons to promote the ideal of a free
|
||||||
|
culture and the further production of creative, cultural and scientific
|
||||||
|
works, or to gain reputation or greater distribution for their Work in
|
||||||
|
part through the use and efforts of others.
|
||||||
|
|
||||||
|
For these and/or other purposes and motivations, and without any
|
||||||
|
expectation of additional consideration or compensation, the person
|
||||||
|
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
|
||||||
|
is an owner of Copyright and Related Rights in the Work, voluntarily
|
||||||
|
elects to apply CC0 to the Work and publicly distribute the Work under its
|
||||||
|
terms, with knowledge of his or her Copyright and Related Rights in the
|
||||||
|
Work and the meaning and intended legal effect of CC0 on those rights.
|
||||||
|
|
||||||
|
1. Copyright and Related Rights. A Work made available under CC0 may be
|
||||||
|
protected by copyright and related or neighboring rights ("Copyright and
|
||||||
|
Related Rights"). Copyright and Related Rights include, but are not
|
||||||
|
limited to, the following:
|
||||||
|
|
||||||
|
i. the right to reproduce, adapt, distribute, perform, display,
|
||||||
|
communicate, and translate a Work;
|
||||||
|
ii. moral rights retained by the original author(s) and/or performer(s);
|
||||||
|
iii. publicity and privacy rights pertaining to a person's image or
|
||||||
|
likeness depicted in a Work;
|
||||||
|
iv. rights protecting against unfair competition in regards to a Work,
|
||||||
|
subject to the limitations in paragraph 4(a), below;
|
||||||
|
v. rights protecting the extraction, dissemination, use and reuse of data
|
||||||
|
in a Work;
|
||||||
|
vi. database rights (such as those arising under Directive 96/9/EC of the
|
||||||
|
European Parliament and of the Council of 11 March 1996 on the legal
|
||||||
|
protection of databases, and under any national implementation
|
||||||
|
thereof, including any amended or successor version of such
|
||||||
|
directive); and
|
||||||
|
vii. other similar, equivalent or corresponding rights throughout the
|
||||||
|
world based on applicable law or treaty, and any national
|
||||||
|
implementations thereof.
|
||||||
|
|
||||||
|
2. Waiver. To the greatest extent permitted by, but not in contravention
|
||||||
|
of, applicable law, Affirmer hereby overtly, fully, permanently,
|
||||||
|
irrevocably and unconditionally waives, abandons, and surrenders all of
|
||||||
|
Affirmer's Copyright and Related Rights and associated claims and causes
|
||||||
|
of action, whether now known or unknown (including existing as well as
|
||||||
|
future claims and causes of action), in the Work (i) in all territories
|
||||||
|
worldwide, (ii) for the maximum duration provided by applicable law or
|
||||||
|
treaty (including future time extensions), (iii) in any current or future
|
||||||
|
medium and for any number of copies, and (iv) for any purpose whatsoever,
|
||||||
|
including without limitation commercial, advertising or promotional
|
||||||
|
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
|
||||||
|
member of the public at large and to the detriment of Affirmer's heirs and
|
||||||
|
successors, fully intending that such Waiver shall not be subject to
|
||||||
|
revocation, rescission, cancellation, termination, or any other legal or
|
||||||
|
equitable action to disrupt the quiet enjoyment of the Work by the public
|
||||||
|
as contemplated by Affirmer's express Statement of Purpose.
|
||||||
|
|
||||||
|
3. Public License Fallback. Should any part of the Waiver for any reason
|
||||||
|
be judged legally invalid or ineffective under applicable law, then the
|
||||||
|
Waiver shall be preserved to the maximum extent permitted taking into
|
||||||
|
account Affirmer's express Statement of Purpose. In addition, to the
|
||||||
|
extent the Waiver is so judged Affirmer hereby grants to each affected
|
||||||
|
person a royalty-free, non transferable, non sublicensable, non exclusive,
|
||||||
|
irrevocable and unconditional license to exercise Affirmer's Copyright and
|
||||||
|
Related Rights in the Work (i) in all territories worldwide, (ii) for the
|
||||||
|
maximum duration provided by applicable law or treaty (including future
|
||||||
|
time extensions), (iii) in any current or future medium and for any number
|
||||||
|
of copies, and (iv) for any purpose whatsoever, including without
|
||||||
|
limitation commercial, advertising or promotional purposes (the
|
||||||
|
"License"). The License shall be deemed effective as of the date CC0 was
|
||||||
|
applied by Affirmer to the Work. Should any part of the License for any
|
||||||
|
reason be judged legally invalid or ineffective under applicable law, such
|
||||||
|
partial invalidity or ineffectiveness shall not invalidate the remainder
|
||||||
|
of the License, and in such case Affirmer hereby affirms that he or she
|
||||||
|
will not (i) exercise any of his or her remaining Copyright and Related
|
||||||
|
Rights in the Work or (ii) assert any associated claims and causes of
|
||||||
|
action with respect to the Work, in either case contrary to Affirmer's
|
||||||
|
express Statement of Purpose.
|
||||||
|
|
||||||
|
4. Limitations and Disclaimers.
|
||||||
|
|
||||||
|
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
||||||
|
surrendered, licensed or otherwise affected by this document.
|
||||||
|
b. Affirmer offers the Work as-is and makes no representations or
|
||||||
|
warranties of any kind concerning the Work, express, implied,
|
||||||
|
statutory or otherwise, including without limitation warranties of
|
||||||
|
title, merchantability, fitness for a particular purpose, non
|
||||||
|
infringement, or the absence of latent or other defects, accuracy, or
|
||||||
|
the present or absence of errors, whether or not discoverable, all to
|
||||||
|
the greatest extent permissible under applicable law.
|
||||||
|
c. Affirmer disclaims responsibility for clearing rights of other persons
|
||||||
|
that may apply to the Work or any use thereof, including without
|
||||||
|
limitation any person's Copyright and Related Rights in the Work.
|
||||||
|
Further, Affirmer disclaims responsibility for obtaining any necessary
|
||||||
|
consents, permissions or other rights required for any use of the
|
||||||
|
Work.
|
||||||
|
d. Affirmer understands and acknowledges that Creative Commons is not a
|
||||||
|
party to this document and has no duty or obligation with respect to
|
||||||
|
this CC0 or use of the Work.
|
||||||
871
README.md
Normal file
871
README.md
Normal file
|
|
@ -0,0 +1,871 @@
|
||||||
|
# 🐍 Snakia Framework
|
||||||
|
|
||||||
|
**Snakia** is a modern Python framework for creating applications with Entity-Component-System (ECS) architecture, event system, and reactive programming. Built with performance (maybe) and modularity in mind, Snakia provides a clean API for developing complex applications ranging from games to terminal user interfaces.
|
||||||
|
|
||||||
|
## 📋 Table of Contents
|
||||||
|
|
||||||
|
- [🎯 Roadmap & TODO](#-roadmap--todo)
|
||||||
|
- [🚀 Installation](#-installation)
|
||||||
|
- [🚀 Quick Start](#-quick-start)
|
||||||
|
- [🏗️ Architecture](#️-architecture)
|
||||||
|
- [⚙️ Core](#️-core)
|
||||||
|
- [🎯 ECS System](#-ecs-system)
|
||||||
|
- [📡 Event System (ES)](#-event-system-es)
|
||||||
|
- [🔌 Plugin System](#-plugin-system)
|
||||||
|
- [🎨 TUI System](#-tui-system)
|
||||||
|
- [⚡ Reactive Programming (RX)](#-reactive-programming-rx)
|
||||||
|
- [🛠️ Utilities](#️-utilities)
|
||||||
|
- [🎭 Decorators](#-decorators)
|
||||||
|
- [🏷️ Properties](#-properties)
|
||||||
|
- [🌐 Platform Abstraction](#-platform-abstraction)
|
||||||
|
- [📦 Examples](#-examples)
|
||||||
|
- [🤝 Contributing](#-contributing)
|
||||||
|
- [🆘 Support](#-support)
|
||||||
|
- [📄 License](#-license)
|
||||||
|
|
||||||
|
### ✨ Key Features
|
||||||
|
|
||||||
|
- 🏗️ **ECS Architecture** - Flexible entity-component-system for scalable game/app logic
|
||||||
|
- 📡 **Event System** - Asynchronous event handling with filters and priorities
|
||||||
|
- 🔌 **Plugin System** - Modular plugin architecture for extensibility
|
||||||
|
- 🎨 **TUI Framework** - Rich terminal user interface with reactive widgets
|
||||||
|
- ⚡ **Reactive Programming** - Observable data streams and reactive bindings
|
||||||
|
- 🛠️ **Rich Utilities** - Decorators, properties, platform abstraction, and more
|
||||||
|
- 🎯 **Type Safety** - Full type hints and Pydantic integration
|
||||||
|
|
||||||
|
> ⚠️ **Experimental Framework**
|
||||||
|
> This framework is currently in **beta/experimental stage**. Not all features are fully implemented, there might be bugs, and the API is subject to change. Use at your own risk! 🚧
|
||||||
|
|
||||||
|
## 🚀 Installation
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Python** >= 3.12
|
||||||
|
- **pip** or **uv** (recommended) package manager
|
||||||
|
|
||||||
|
### Install from PyPi (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install snakia
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/RuJect/Snakia.git
|
||||||
|
cd Snakia
|
||||||
|
|
||||||
|
# Install with pip
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# Or with uv (recommended)
|
||||||
|
uv sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Roadmap & TODO
|
||||||
|
|
||||||
|
Here's what we're working on to make Snakia even better:
|
||||||
|
|
||||||
|
- [ ] Plugin Isolation: restrict plugin access to only events and components statically defined in manifest
|
||||||
|
- [ ] Async & Multithreading: implement proper async/await support and multithreading capabilities
|
||||||
|
- [ ] Platform Support: expand platform abstraction to support more operating systems
|
||||||
|
- [ ] Random Implementations: add various random generations implementations
|
||||||
|
- [ ] TUI Widgets: create more ready-to-use TUI widgets and components
|
||||||
|
- [ ] Code Documentation: add comprehensive docstrings and inline comments
|
||||||
|
- [ ] Documentation: create detailed API documentation and tutorials
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.engine import Engine
|
||||||
|
from snakia.core.loader import Meta, Plugin, PluginProcessor
|
||||||
|
from snakia.core.ecs import Component
|
||||||
|
from snakia.types import Version
|
||||||
|
|
||||||
|
# Creating a component
|
||||||
|
class HealthComponent(Component):
|
||||||
|
value: int = 100
|
||||||
|
max_value: int = 100
|
||||||
|
|
||||||
|
# Creating a processor
|
||||||
|
class HealthProcessor(PluginProcessor):
|
||||||
|
def process(self, system):
|
||||||
|
for entity, (health,) in system.get_components(HealthComponent):
|
||||||
|
if health.value <= 0:
|
||||||
|
print(f"Entity {entity} died!")
|
||||||
|
|
||||||
|
# Creating a plugin
|
||||||
|
class HealthPlugin(Plugin, meta=Meta(
|
||||||
|
name="health",
|
||||||
|
version=Version.from_args(1, 0, 0),
|
||||||
|
processors=(HealthProcessor,)
|
||||||
|
)):
|
||||||
|
def on_load(self): pass
|
||||||
|
def on_unload(self): pass
|
||||||
|
|
||||||
|
# Starting the engine
|
||||||
|
engine = Engine()
|
||||||
|
engine.loader.register(HealthPlugin)
|
||||||
|
engine.loader.load_all()
|
||||||
|
engine.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
Snakia is built on a modular architecture with clear separation of concerns:
|
||||||
|
|
||||||
|
```plaintext
|
||||||
|
Snakia/
|
||||||
|
├── core/ # Framework core
|
||||||
|
│ ├── engine.py # Main engine
|
||||||
|
│ ├── ecs/ # Entity-Component-System
|
||||||
|
│ ├── es/ # Event System
|
||||||
|
│ ├── loader/ # Plugin loading system
|
||||||
|
│ ├── rx/ # Reactive programming
|
||||||
|
│ └── tui/ # Terminal User Interface
|
||||||
|
├── decorators/ # Decorators
|
||||||
|
├── property/ # Property system
|
||||||
|
├── platform/ # Platform abstraction
|
||||||
|
├── utils/ # Utilities
|
||||||
|
├── random/ # Random number generation
|
||||||
|
├── field/ # Typed fields
|
||||||
|
└── types/ # Special types
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ Core
|
||||||
|
|
||||||
|
### Engine
|
||||||
|
|
||||||
|
The central component of the framework that coordinates all systems:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.engine import Engine
|
||||||
|
|
||||||
|
engine = Engine()
|
||||||
|
# Systems:
|
||||||
|
# - engine.system - ECS system
|
||||||
|
# - engine.dispatcher - Event system
|
||||||
|
# - engine.loader - Plugin loader
|
||||||
|
|
||||||
|
engine.start() # Start all systems
|
||||||
|
engine.stop() # Stop all systems
|
||||||
|
engine.update() # Update systems
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 ECS System
|
||||||
|
|
||||||
|
Entity-Component-System architecture for creating flexible and performant applications.
|
||||||
|
|
||||||
|
### Component
|
||||||
|
|
||||||
|
Base class for all components:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.ecs import Component
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
class PositionComponent(Component):
|
||||||
|
x: float = Field(default=0.0)
|
||||||
|
y: float = Field(default=0.0)
|
||||||
|
|
||||||
|
class VelocityComponent(Component):
|
||||||
|
vx: float = Field(default=0.0)
|
||||||
|
vy: float = Field(default=0.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Processor
|
||||||
|
|
||||||
|
Processors handle components in the system:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.ecs import Processor, System
|
||||||
|
|
||||||
|
class MovementProcessor(Processor):
|
||||||
|
def process(self, system: System) -> None:
|
||||||
|
# Get all entities with Position and Velocity
|
||||||
|
for entity, (pos, vel) in system.get_components(
|
||||||
|
PositionComponent, VelocityComponent
|
||||||
|
):
|
||||||
|
pos.x += vel.vx
|
||||||
|
pos.y += vel.vy
|
||||||
|
```
|
||||||
|
|
||||||
|
### System
|
||||||
|
|
||||||
|
Entity and component management:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Creating an entity with components
|
||||||
|
entity = system.create_entity(
|
||||||
|
PositionComponent(x=10, y=20),
|
||||||
|
VelocityComponent(vx=1, vy=0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Adding a component to an existing entity
|
||||||
|
system.add_component(entity, HealthComponent(value=100))
|
||||||
|
|
||||||
|
# Getting entity components
|
||||||
|
pos, vel = system.get_components_of_entity(
|
||||||
|
entity, PositionComponent, VelocityComponent
|
||||||
|
)
|
||||||
|
|
||||||
|
# Checking for components
|
||||||
|
if system.has_components(entity, PositionComponent, VelocityComponent):
|
||||||
|
print("Entity has position and velocity")
|
||||||
|
|
||||||
|
# Removing a component
|
||||||
|
system.remove_component(entity, VelocityComponent)
|
||||||
|
|
||||||
|
# Deleting an entity
|
||||||
|
system.delete_entity(entity)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📡 Event System (ES)
|
||||||
|
|
||||||
|
Asynchronous event system with filter and priority support.
|
||||||
|
|
||||||
|
### Event
|
||||||
|
|
||||||
|
Base class for events:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.es import Event
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
class PlayerDiedEvent(Event):
|
||||||
|
player_id: int = Field()
|
||||||
|
cause: str = Field(default="unknown")
|
||||||
|
ttl: int = Field(default=10) # Event lifetime
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handler
|
||||||
|
|
||||||
|
Event handlers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.es import Handler, Action
|
||||||
|
|
||||||
|
def on_player_died(event: PlayerDiedEvent) -> Action | None:
|
||||||
|
print(f"Player {event.player_id} died from {event.cause}")
|
||||||
|
return Action.move(1) # Move to next handler
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter
|
||||||
|
|
||||||
|
Event filters:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.es import Filter
|
||||||
|
|
||||||
|
def only_important_deaths(event: PlayerDiedEvent) -> bool:
|
||||||
|
return event.cause in ["boss", "pvp"]
|
||||||
|
|
||||||
|
# Using a filter
|
||||||
|
@dispatcher.on(PlayerDiedEvent, filter=only_important_deaths)
|
||||||
|
def handle_important_death(event: PlayerDiedEvent):
|
||||||
|
print("Important death occurred!")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dispatcher
|
||||||
|
|
||||||
|
Central event dispatcher:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.es import Dispatcher, Subscriber
|
||||||
|
|
||||||
|
dispatcher = Dispatcher()
|
||||||
|
|
||||||
|
# Subscribing to an event
|
||||||
|
dispatcher.subscribe(PlayerDiedEvent, Subscriber(
|
||||||
|
handler=on_player_died,
|
||||||
|
filter=only_important_deaths,
|
||||||
|
priority=10
|
||||||
|
))
|
||||||
|
|
||||||
|
# Decorator for subscription
|
||||||
|
@dispatcher.on(PlayerDiedEvent, priority=5)
|
||||||
|
def handle_death(event: PlayerDiedEvent):
|
||||||
|
print("Death handled!")
|
||||||
|
|
||||||
|
# Publishing an event
|
||||||
|
dispatcher.publish(PlayerDiedEvent(player_id=123, cause="boss"))
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔌 Plugin System
|
||||||
|
|
||||||
|
Modular system for loading and managing plugins.
|
||||||
|
|
||||||
|
### Plugin
|
||||||
|
|
||||||
|
Base class for plugins:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.loader import Meta, Plugin, PluginProcessor
|
||||||
|
from snakia.types import Version
|
||||||
|
|
||||||
|
class MyProcessor(PluginProcessor):
|
||||||
|
def process(self, system):
|
||||||
|
# Processor logic
|
||||||
|
pass
|
||||||
|
|
||||||
|
class MyPlugin(Plugin, meta=Meta(
|
||||||
|
name="my_plugin",
|
||||||
|
author="developer",
|
||||||
|
version=Version.from_args(1, 0, 0),
|
||||||
|
processors=(MyProcessor,),
|
||||||
|
subscribers=()
|
||||||
|
)):
|
||||||
|
def on_load(self):
|
||||||
|
print("Plugin loaded!")
|
||||||
|
|
||||||
|
def on_unload(self):
|
||||||
|
print("Plugin unloaded!")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Meta
|
||||||
|
|
||||||
|
Plugin metadata:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.loader import Meta
|
||||||
|
from snakia.core.es import Subscriber
|
||||||
|
|
||||||
|
meta = Meta(
|
||||||
|
name="plugin_name",
|
||||||
|
author="author_name",
|
||||||
|
version=Version.from_args(1, 0, 0),
|
||||||
|
processors=(Processor1, Processor2),
|
||||||
|
subscribers=(
|
||||||
|
(EventType, Subscriber(handler, filter, priority)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loader
|
||||||
|
|
||||||
|
Plugin loader:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.loader import Loader
|
||||||
|
|
||||||
|
loader = Loader(engine)
|
||||||
|
|
||||||
|
# Registering a plugin
|
||||||
|
loader.register(MyPlugin)
|
||||||
|
|
||||||
|
# Loading all plugins
|
||||||
|
loader.load_all()
|
||||||
|
|
||||||
|
# Unloading all plugins
|
||||||
|
loader.unload_all()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 TUI System
|
||||||
|
|
||||||
|
System for creating text-based user interfaces.
|
||||||
|
|
||||||
|
### Widget
|
||||||
|
|
||||||
|
Base class for widgets:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.tui import Widget, Canvas, CanvasChar
|
||||||
|
from snakia.core.rx import Bindable
|
||||||
|
|
||||||
|
class MyWidget(Widget):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.text = self.state("Hello World")
|
||||||
|
self.color = self.state(CanvasChar(fg_color="red"))
|
||||||
|
|
||||||
|
def on_render(self) -> Canvas:
|
||||||
|
canvas = Canvas(20, 5)
|
||||||
|
canvas.draw_text(0, 0, self.text.value, self.color.value)
|
||||||
|
return canvas
|
||||||
|
```
|
||||||
|
|
||||||
|
### Canvas
|
||||||
|
|
||||||
|
Drawing canvas:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.tui import Canvas, CanvasChar
|
||||||
|
|
||||||
|
canvas = Canvas(80, 24)
|
||||||
|
|
||||||
|
# Drawing text
|
||||||
|
canvas.draw_text(10, 5, "Hello", CanvasChar(fg_color="blue"))
|
||||||
|
|
||||||
|
# Drawing rectangle
|
||||||
|
canvas.draw_rect(0, 0, 20, 10, CanvasChar("█", fg_color="green"))
|
||||||
|
|
||||||
|
# Filling area
|
||||||
|
canvas.draw_filled_rect(5, 5, 10, 5, CanvasChar(" ", bg_color="red"))
|
||||||
|
|
||||||
|
# Lines
|
||||||
|
canvas.draw_line_h(0, 0, 20, CanvasChar("-"))
|
||||||
|
canvas.draw_line_v(0, 0, 10, CanvasChar("|"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### CanvasChar
|
||||||
|
|
||||||
|
Character with attributes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.tui import CanvasChar
|
||||||
|
|
||||||
|
char = CanvasChar(
|
||||||
|
char="A",
|
||||||
|
fg_color="red", # Text color
|
||||||
|
bg_color="blue", # Background color
|
||||||
|
bold=True, # Bold
|
||||||
|
italic=False, # Italic
|
||||||
|
underline=True # Underline
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Renderer
|
||||||
|
|
||||||
|
Screen rendering:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.tui import RenderContext
|
||||||
|
from snakia.core.tui.render import ANSIRenderer
|
||||||
|
import sys
|
||||||
|
|
||||||
|
class StdoutTarget:
|
||||||
|
def write(self, text: str): sys.stdout.write(text)
|
||||||
|
def flush(self): sys.stdout.flush()
|
||||||
|
|
||||||
|
renderer = ANSIRenderer(StdoutTarget())
|
||||||
|
|
||||||
|
with RenderContext(renderer) as ctx:
|
||||||
|
ctx.render(widget.render())
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ready-made Widgets
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.tui.widgets import (
|
||||||
|
TextWidget, BoxWidget,
|
||||||
|
HorizontalSplitWidget, VerticalSplitWidget
|
||||||
|
)
|
||||||
|
|
||||||
|
# Text widget
|
||||||
|
text = TextWidget("Hello", CanvasChar(fg_color="red", bold=True))
|
||||||
|
|
||||||
|
# Box widget
|
||||||
|
box = BoxWidget(10, 5, CanvasChar("█", fg_color="yellow"))
|
||||||
|
|
||||||
|
# Splitters
|
||||||
|
h_split = HorizontalSplitWidget([text1, text2], "|")
|
||||||
|
v_split = VerticalSplitWidget([h_split, box], "-")
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚡ Reactive Programming (RX)
|
||||||
|
|
||||||
|
Reactive programming system for creating responsive interfaces.
|
||||||
|
|
||||||
|
### Bindable
|
||||||
|
|
||||||
|
Reactive variables:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.rx import Bindable, ValueChanged
|
||||||
|
|
||||||
|
# Creating a reactive variable
|
||||||
|
counter = Bindable(0)
|
||||||
|
|
||||||
|
# Subscribing to changes
|
||||||
|
def on_change(event: ValueChanged[int]):
|
||||||
|
print(f"Counter changed from {event.old_value} to {event.new_value}")
|
||||||
|
|
||||||
|
counter.subscribe(on_change)
|
||||||
|
|
||||||
|
# Changing value
|
||||||
|
counter.set(5) # Will call on_change
|
||||||
|
counter(10) # Alternative syntax
|
||||||
|
```
|
||||||
|
|
||||||
|
### AsyncBindable
|
||||||
|
|
||||||
|
Asynchronous reactive variables:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.rx import AsyncBindable
|
||||||
|
|
||||||
|
async_counter = AsyncBindable(0)
|
||||||
|
|
||||||
|
async def async_handler(event: ValueChanged[int]):
|
||||||
|
print(f"Async counter: {event.new_value}")
|
||||||
|
|
||||||
|
await async_counter.subscribe(async_handler, run_now=True)
|
||||||
|
await async_counter.set(42)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Operators
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.rx import map, filter, combine, merge
|
||||||
|
|
||||||
|
# Transformation
|
||||||
|
doubled = map(counter, lambda x: x * 2)
|
||||||
|
|
||||||
|
# Filtering
|
||||||
|
even_only = filter(counter, lambda x: x % 2 == 0)
|
||||||
|
|
||||||
|
# Combining
|
||||||
|
combined = combine(counter, doubled, lambda a, b: a + b)
|
||||||
|
|
||||||
|
# Merging streams
|
||||||
|
merged = merge(counter, async_counter)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Utilities
|
||||||
|
|
||||||
|
### to_async
|
||||||
|
|
||||||
|
Converting synchronous functions to asynchronous:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.utils import to_async
|
||||||
|
|
||||||
|
def sync_function(x):
|
||||||
|
return x * 2
|
||||||
|
|
||||||
|
async_function = to_async(sync_function)
|
||||||
|
result = await async_function(5)
|
||||||
|
```
|
||||||
|
|
||||||
|
### nolock
|
||||||
|
|
||||||
|
Performance optimization:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.utils import nolock
|
||||||
|
|
||||||
|
def busy_loop():
|
||||||
|
while running:
|
||||||
|
# Work
|
||||||
|
nolock() # Release GIL
|
||||||
|
```
|
||||||
|
|
||||||
|
### inherit
|
||||||
|
|
||||||
|
Simplified inheritance:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.utils import inherit
|
||||||
|
|
||||||
|
class Base:
|
||||||
|
def method(self): pass
|
||||||
|
|
||||||
|
class Derived(inherit(Base)):
|
||||||
|
def method(self):
|
||||||
|
super().method()
|
||||||
|
# Additional logic
|
||||||
|
```
|
||||||
|
|
||||||
|
### this
|
||||||
|
|
||||||
|
Reference to current object:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.utils import this
|
||||||
|
|
||||||
|
def func():
|
||||||
|
return this() # Returns `<function func at ...>`
|
||||||
|
```
|
||||||
|
|
||||||
|
### throw
|
||||||
|
|
||||||
|
Throwing exceptions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.utils import throw
|
||||||
|
|
||||||
|
def validate(value):
|
||||||
|
if value < 0:
|
||||||
|
throw(ValueError("Value must be positive"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### frame
|
||||||
|
|
||||||
|
Working with frames:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.utils import frame
|
||||||
|
|
||||||
|
def process_frame():
|
||||||
|
current_frame = frame()
|
||||||
|
# Process frame
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎭 Decorators
|
||||||
|
|
||||||
|
### inject_replace
|
||||||
|
|
||||||
|
Method replacement:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.decorators import inject_replace
|
||||||
|
|
||||||
|
class Original:
|
||||||
|
def method(self): return "original"
|
||||||
|
|
||||||
|
@inject_replace(Original, "method")
|
||||||
|
def new_method(self): return "replaced"
|
||||||
|
```
|
||||||
|
|
||||||
|
### inject_before / inject_after
|
||||||
|
|
||||||
|
Hooks before and after execution:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.decorators import inject_before, inject_after
|
||||||
|
|
||||||
|
@inject_before(MyClass, "method")
|
||||||
|
def before_hook(self): print("Before method")
|
||||||
|
|
||||||
|
@inject_after(MyClass, "method")
|
||||||
|
def after_hook(self): print("After method")
|
||||||
|
```
|
||||||
|
|
||||||
|
### singleton
|
||||||
|
|
||||||
|
Singleton pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.decorators import singleton
|
||||||
|
|
||||||
|
@singleton
|
||||||
|
class Database:
|
||||||
|
def __init__(self):
|
||||||
|
self.connection = "connected"
|
||||||
|
```
|
||||||
|
|
||||||
|
### pass_exceptions
|
||||||
|
|
||||||
|
Exception handling:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.decorators import pass_exceptions
|
||||||
|
|
||||||
|
@pass_exceptions(ValueError, TypeError)
|
||||||
|
def risky_function():
|
||||||
|
# Code that might throw exceptions
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏷️ Properties
|
||||||
|
|
||||||
|
### readonly
|
||||||
|
|
||||||
|
Read-only property:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.property import readonly
|
||||||
|
|
||||||
|
|
||||||
|
class Currency:
|
||||||
|
@readonly
|
||||||
|
def rate(self) -> int:
|
||||||
|
return 100
|
||||||
|
|
||||||
|
|
||||||
|
currency = Currency()
|
||||||
|
currency.rate = 200
|
||||||
|
print(currency.rate) # Output: 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### initonly
|
||||||
|
|
||||||
|
Initialization-only property:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.property import initonly
|
||||||
|
|
||||||
|
|
||||||
|
class Person:
|
||||||
|
name = initonly[str]("name")
|
||||||
|
|
||||||
|
|
||||||
|
bob = Person()
|
||||||
|
bob.name = "Bob"
|
||||||
|
print(bob.name) # Output: "Bob"
|
||||||
|
bob.name = "not bob"
|
||||||
|
print(bob.name) # Output: "Bob"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🏛️ classproperty
|
||||||
|
|
||||||
|
Class property:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.property import classproperty
|
||||||
|
|
||||||
|
class MyClass:
|
||||||
|
@classproperty
|
||||||
|
def class_value(cls):
|
||||||
|
return "class_value"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌐 Platform Abstraction
|
||||||
|
|
||||||
|
### 🖥️ PlatformOS
|
||||||
|
|
||||||
|
Operating system abstraction:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.platform import PlatformOS, OS
|
||||||
|
|
||||||
|
# Detecting current OS
|
||||||
|
current_os = OS.current()
|
||||||
|
|
||||||
|
if current_os == PlatformOS.LINUX:
|
||||||
|
print("Running on Linux")
|
||||||
|
elif current_os == PlatformOS.ANDROID:
|
||||||
|
print("Running on Android")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🏗️ PlatformLayer
|
||||||
|
|
||||||
|
Platform layers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.platform import LinuxLayer, AndroidLayer
|
||||||
|
|
||||||
|
# Linux layer
|
||||||
|
linux_layer = LinuxLayer()
|
||||||
|
|
||||||
|
# Android layer
|
||||||
|
android_layer = AndroidLayer()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 Examples
|
||||||
|
|
||||||
|
### Health System
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.engine import Engine
|
||||||
|
from snakia.core.ecs import Component
|
||||||
|
from snakia.core.es import Event
|
||||||
|
from snakia.core.loader import Meta, Plugin, PluginProcessor
|
||||||
|
from snakia.types import Version
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
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 DeathEvent(Event):
|
||||||
|
entity: int = Field()
|
||||||
|
|
||||||
|
class HealthProcessor(PluginProcessor):
|
||||||
|
def process(self, system):
|
||||||
|
# Processing damage
|
||||||
|
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))
|
||||||
|
|
||||||
|
class HealthPlugin(Plugin, meta=Meta(
|
||||||
|
name="health",
|
||||||
|
version=Version.from_args(1, 0, 0),
|
||||||
|
processors=(HealthProcessor,)
|
||||||
|
)):
|
||||||
|
def on_load(self): pass
|
||||||
|
def on_unload(self): pass
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
engine = Engine()
|
||||||
|
engine.loader.register(HealthPlugin)
|
||||||
|
engine.loader.load_all()
|
||||||
|
|
||||||
|
# Creating a player
|
||||||
|
player = engine.system.create_entity(
|
||||||
|
HealthComponent(value=100, max_value=100)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dealing damage
|
||||||
|
engine.system.add_component(player, DamageComponent(damage=25, ticks=1))
|
||||||
|
|
||||||
|
engine.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
### TUI Application
|
||||||
|
|
||||||
|
```python
|
||||||
|
from snakia.core.tui import CanvasChar, RenderContext
|
||||||
|
from snakia.core.tui.render import ANSIRenderer
|
||||||
|
from snakia.core.tui.widgets import TextWidget, BoxWidget, VerticalSplitWidget
|
||||||
|
import sys
|
||||||
|
|
||||||
|
class StdoutTarget:
|
||||||
|
def write(self, text: str): sys.stdout.write(text)
|
||||||
|
def flush(self): sys.stdout.flush()
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Creating widgets
|
||||||
|
title = TextWidget("Snakia TUI", CanvasChar(fg_color="cyan", bold=True))
|
||||||
|
content = TextWidget("Welcome to Snakia!", CanvasChar(fg_color="white"))
|
||||||
|
box = BoxWidget(20, 5, CanvasChar("█", fg_color="green"))
|
||||||
|
|
||||||
|
# Layout
|
||||||
|
layout = VerticalSplitWidget([title, content, box], "-")
|
||||||
|
|
||||||
|
# Rendering
|
||||||
|
renderer = ANSIRenderer(StdoutTarget())
|
||||||
|
|
||||||
|
with RenderContext(renderer) as ctx:
|
||||||
|
ctx.render(layout.render())
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
We welcome contributions to Snakia development! Whether you're fixing bugs, adding features, or improving documentation, your help is appreciated.
|
||||||
|
|
||||||
|
### How to Contribute
|
||||||
|
|
||||||
|
1. **Fork** the repository
|
||||||
|
2. **Create** a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. **Make** your changes
|
||||||
|
4. **Add** tests if applicable
|
||||||
|
5. **Commit** your changes (`git commit -m 'Add amazing feature'`)
|
||||||
|
6. **Push** to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
7. **Open** a Pull Request
|
||||||
|
|
||||||
|
### Development Guidelines
|
||||||
|
|
||||||
|
- Add type hints to all new code
|
||||||
|
- Write clear commit messages
|
||||||
|
- Update documentation for new features
|
||||||
|
- Test your changes thoroughly
|
||||||
|
|
||||||
|
## 🆘 Support
|
||||||
|
|
||||||
|
Need help? We're here to assist you!
|
||||||
|
|
||||||
|
- 🐛 **Bug Reports** - [GitHub Issues](https://github.com/RuJect/Snakia/issues)
|
||||||
|
- 💬 **Community Chat** - [RuJect Community Telegram](https://t.me/RuJect_Community)
|
||||||
|
- 📧 **Direct Contact** - mailto:rus07tam.uwu@gmail.com
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
See the `LICENSE` file for details.
|
||||||
20
docs/Makefile
Normal file
20
docs/Makefile
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Minimal makefile for Sphinx documentation
|
||||||
|
#
|
||||||
|
|
||||||
|
# You can set these variables from the command line, and also
|
||||||
|
# from the environment for the first two.
|
||||||
|
SPHINXOPTS ?=
|
||||||
|
SPHINXBUILD ?= sphinx-build
|
||||||
|
SOURCEDIR = source
|
||||||
|
BUILDDIR = build
|
||||||
|
|
||||||
|
# Put it first so that "make" without argument is like "make help".
|
||||||
|
help:
|
||||||
|
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
|
|
||||||
|
.PHONY: help Makefile
|
||||||
|
|
||||||
|
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||||
|
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||||
|
%: Makefile
|
||||||
|
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||||
35
docs/make.bat
Normal file
35
docs/make.bat
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
@ECHO OFF
|
||||||
|
|
||||||
|
pushd %~dp0
|
||||||
|
|
||||||
|
REM Command file for Sphinx documentation
|
||||||
|
|
||||||
|
if "%SPHINXBUILD%" == "" (
|
||||||
|
set SPHINXBUILD=sphinx-build
|
||||||
|
)
|
||||||
|
set SOURCEDIR=source
|
||||||
|
set BUILDDIR=build
|
||||||
|
|
||||||
|
%SPHINXBUILD% >NUL 2>NUL
|
||||||
|
if errorlevel 9009 (
|
||||||
|
echo.
|
||||||
|
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||||
|
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||||
|
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||||
|
echo.may add the Sphinx directory to PATH.
|
||||||
|
echo.
|
||||||
|
echo.If you don't have Sphinx installed, grab it from
|
||||||
|
echo.https://www.sphinx-doc.org/
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if "%1" == "" goto help
|
||||||
|
|
||||||
|
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||||
|
goto end
|
||||||
|
|
||||||
|
:help
|
||||||
|
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||||
|
|
||||||
|
:end
|
||||||
|
popd
|
||||||
15
docs/source/api.rst
Normal file
15
docs/source/api.rst
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
API Reference
|
||||||
|
=============
|
||||||
|
|
||||||
|
.. autosummary::
|
||||||
|
:toctree: _autosummary
|
||||||
|
:recursive:
|
||||||
|
|
||||||
|
snakia.core
|
||||||
|
snakia.decorators
|
||||||
|
snakia.field
|
||||||
|
snakia.platform
|
||||||
|
snakia.property
|
||||||
|
snakia.random
|
||||||
|
snakia.types
|
||||||
|
snakia.utils
|
||||||
34
docs/source/conf.py
Normal file
34
docs/source/conf.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(
|
||||||
|
0, str((Path(__file__).parent.parent.parent / "src").resolve())
|
||||||
|
)
|
||||||
|
|
||||||
|
project = "Snakia"
|
||||||
|
copyright = "2025, RuJect"
|
||||||
|
author = "RuJect"
|
||||||
|
|
||||||
|
extensions = [
|
||||||
|
"sphinx.ext.autodoc",
|
||||||
|
"sphinx.ext.autosummary",
|
||||||
|
"sphinx.ext.viewcode",
|
||||||
|
"sphinx_autodoc_typehints",
|
||||||
|
]
|
||||||
|
|
||||||
|
autosummary_generate = True
|
||||||
|
autosummary_imported_members = True
|
||||||
|
|
||||||
|
autodoc_default_options = {
|
||||||
|
"members": True,
|
||||||
|
"undoc-members": True,
|
||||||
|
"private-members": False,
|
||||||
|
"special-members": "__init__",
|
||||||
|
"inherited-members": True,
|
||||||
|
"show-inheritance": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
templates_path = ["_templates"]
|
||||||
|
|
||||||
|
html_theme = "sphinx_rtd_theme"
|
||||||
|
html_static_path = ["_static"]
|
||||||
15
docs/source/index.rst
Normal file
15
docs/source/index.rst
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
Welcome to Snakia!
|
||||||
|
==================
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
:caption: Contents:
|
||||||
|
|
||||||
|
api
|
||||||
|
|
||||||
|
Indices and tables
|
||||||
|
==================
|
||||||
|
|
||||||
|
* :ref:`genindex`
|
||||||
|
* :ref:`modindex`
|
||||||
|
* :ref:`search`
|
||||||
88
examples/health_plugin.py
Normal file
88
examples/health_plugin.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
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()
|
||||||
35
examples/tui.py
Normal file
35
examples/tui.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from snakia.core.tui import CanvasChar, RenderContext
|
||||||
|
from snakia.core.tui.render import ANSIRenderer
|
||||||
|
from snakia.core.tui.widgets import (BoxWidget, HorizontalSplitWidget,
|
||||||
|
TextWidget, VerticalSplitWidget)
|
||||||
|
|
||||||
|
|
||||||
|
class StdoutTarget:
|
||||||
|
def write(self, text: str) -> None:
|
||||||
|
sys.stdout.write(text)
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
text1 = TextWidget("Hello", CanvasChar(fg_color="red", bold=True))
|
||||||
|
text2 = TextWidget("World", CanvasChar(fg_color="blue", bold=True))
|
||||||
|
text3 = TextWidget("Snakia", CanvasChar(fg_color="green", bold=True))
|
||||||
|
|
||||||
|
box1 = BoxWidget(10, 3, CanvasChar("█", fg_color="yellow"))
|
||||||
|
box2 = BoxWidget(8, 5, CanvasChar("█", fg_color="magenta"))
|
||||||
|
|
||||||
|
horizontal_split = HorizontalSplitWidget([text1, text2, text3], "|")
|
||||||
|
vertical_split = VerticalSplitWidget([horizontal_split, box1, box2], "-")
|
||||||
|
|
||||||
|
renderer = ANSIRenderer(StdoutTarget())
|
||||||
|
|
||||||
|
with RenderContext(renderer) as ctx:
|
||||||
|
ctx.render(vertical_split.render())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1761114652,
|
||||||
|
"narHash": "sha256-f/QCJM/YhrV/lavyCVz8iU3rlZun6d+dAiC3H+CDle4=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "01f116e4df6a15f4ccdffb1bcd41096869fb385c",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
39
flake.nix
Normal file
39
flake.nix
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
description = "Snakia dev shell";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{
|
||||||
|
nixpkgs,
|
||||||
|
flake-utils,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
python312
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
black
|
||||||
|
nixfmt
|
||||||
|
uv
|
||||||
|
isort
|
||||||
|
mypy
|
||||||
|
python312Packages.pylint
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
40
pyproject.toml
Normal file
40
pyproject.toml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[project]
|
||||||
|
name = "snakia"
|
||||||
|
version = "0.4.0"
|
||||||
|
description = "Modern python framework"
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [
|
||||||
|
{ name = "rus07tam", email = "rus07tam@gmail.com" }
|
||||||
|
]
|
||||||
|
keywords = ["python3", "event system", "ecs", "reactive programming"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
|
"Programming Language :: Python :: Free Threading",
|
||||||
|
]
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"networkx>=3.4.2",
|
||||||
|
"pydantic>=2.12.3",
|
||||||
|
]
|
||||||
|
license = "CC0-1.0"
|
||||||
|
license-files = ["LICENSE"]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/ruject/snakia"
|
||||||
|
Repository = "https://github.com/ruject/snakia"
|
||||||
|
"Issue Tracker" = "https://github.com/ruject/snakia/issues"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["uv_build>=0.8.14,<0.9.0"]
|
||||||
|
build-backend = "uv_build"
|
||||||
|
|
||||||
|
[tool.pylint.'master']
|
||||||
|
init-hook = "import sys; sys.path.append('.venv/lib/python3.12/site-packages')"
|
||||||
|
disable = ["C0114", "C0115", "C0116", "R0801"]
|
||||||
|
max-args = 8
|
||||||
|
max-positional-arguments = 7
|
||||||
|
min-public-methods = 1
|
||||||
17
requirements.txt
Normal file
17
requirements.txt
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# This file was autogenerated by uv via the following command:
|
||||||
|
# uv pip compile pyproject.toml -o requirements.txt
|
||||||
|
annotated-types==0.7.0
|
||||||
|
# via pydantic
|
||||||
|
networkx==3.5
|
||||||
|
# via snakia (pyproject.toml)
|
||||||
|
pydantic==2.12.3
|
||||||
|
# via snakia (pyproject.toml)
|
||||||
|
pydantic-core==2.41.4
|
||||||
|
# via pydantic
|
||||||
|
typing-extensions==4.15.0
|
||||||
|
# via
|
||||||
|
# pydantic
|
||||||
|
# pydantic-core
|
||||||
|
# typing-inspection
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
# via pydantic
|
||||||
3
src/snakia/__init__.py
Normal file
3
src/snakia/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
Snakia framework
|
||||||
|
"""
|
||||||
0
src/snakia/core/__init__.py
Normal file
0
src/snakia/core/__init__.py
Normal file
5
src/snakia/core/ecs/__init__.py
Normal file
5
src/snakia/core/ecs/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from .component import Component
|
||||||
|
from .processor import Processor
|
||||||
|
from .system import System
|
||||||
|
|
||||||
|
__all__ = ["Processor", "Component", "System"]
|
||||||
7
src/snakia/core/ecs/component.py
Normal file
7
src/snakia/core/ecs/component.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from abc import ABC
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Component(ABC, BaseModel):
|
||||||
|
pass
|
||||||
25
src/snakia/core/ecs/processor.py
Normal file
25
src/snakia/core/ecs/processor.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .system import System
|
||||||
|
|
||||||
|
|
||||||
|
class Processor(ABC):
|
||||||
|
"""
|
||||||
|
A processor is a class that processes the system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
before: ClassVar[tuple[type[Processor], ...]] = ()
|
||||||
|
after: ClassVar[tuple[type[Processor], ...]] = ()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def process(self, system: System) -> None:
|
||||||
|
"""
|
||||||
|
Processes the system. Called once per update.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
system (System): The system to process.
|
||||||
|
"""
|
||||||
309
src/snakia/core/ecs/system.py
Normal file
309
src/snakia/core/ecs/system.py
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from itertools import count
|
||||||
|
from typing import Any, cast, overload
|
||||||
|
|
||||||
|
import networkx as nx # type: ignore
|
||||||
|
|
||||||
|
from snakia.utils import nolock
|
||||||
|
|
||||||
|
from .component import Component
|
||||||
|
from .processor import Processor
|
||||||
|
|
||||||
|
|
||||||
|
class System:
|
||||||
|
"""
|
||||||
|
A system is a collection of entities and components that can be processed by processors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__processors: list[Processor]
|
||||||
|
__components: dict[type[Component], set[int]]
|
||||||
|
__entitites: dict[int, dict[type[Component], Component]]
|
||||||
|
__entity_counter: count[int]
|
||||||
|
__dead_entities: set[int]
|
||||||
|
__is_running: bool
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.__processors = []
|
||||||
|
self.__components = defaultdict(set)
|
||||||
|
self.__entitites = defaultdict(dict)
|
||||||
|
self.__entity_counter = count(start=1)
|
||||||
|
self.__dead_entities = set()
|
||||||
|
self.__is_running = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""Returns True if the system is running."""
|
||||||
|
return self.__is_running
|
||||||
|
|
||||||
|
def full_reset(self) -> None:
|
||||||
|
"""Resets the system to its initial state."""
|
||||||
|
self.__processors = []
|
||||||
|
self.__components = defaultdict(set)
|
||||||
|
self.__entitites = defaultdict(dict)
|
||||||
|
self.__entity_counter = count(start=1)
|
||||||
|
self.__dead_entities = set()
|
||||||
|
|
||||||
|
def get_processor[P: Processor](
|
||||||
|
self, processor_type: type[P], /
|
||||||
|
) -> P | None:
|
||||||
|
"""Returns the first processor of the given type."""
|
||||||
|
for processor in self.__processors:
|
||||||
|
if isinstance(processor, processor_type):
|
||||||
|
return processor
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_processor(self, proccessor: Processor) -> None:
|
||||||
|
"""Adds a processor to the system."""
|
||||||
|
self.__processors.append(proccessor)
|
||||||
|
self._sort_processors()
|
||||||
|
|
||||||
|
def remove_processor(self, processor_type: type[Processor]) -> None:
|
||||||
|
"""Removes a processor from the system."""
|
||||||
|
for processor in self.__processors:
|
||||||
|
if isinstance(processor, processor_type):
|
||||||
|
self.__processors.remove(processor)
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_components[A: Component](
|
||||||
|
self, __c1: type[A], /
|
||||||
|
) -> Iterable[tuple[int, tuple[A]]]: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_components[A: Component, B: Component](
|
||||||
|
self, __c1: type[A], __c2: type[B], /
|
||||||
|
) -> Iterable[tuple[int, tuple[A, B]]]: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_components[A: Component, B: Component, C: Component](
|
||||||
|
self, __c1: type[A], __c2: type[B], __c3: type[C], /
|
||||||
|
) -> Iterable[tuple[int, tuple[A, B, C]]]: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_components[A: Component, B: Component, C: Component, D: Component](
|
||||||
|
self, __c1: type[A], __c2: type[B], __c3: type[C], __c4: type[D], /
|
||||||
|
) -> Iterable[tuple[int, tuple[A, B, C, D]]]: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_components[
|
||||||
|
A: Component,
|
||||||
|
B: Component,
|
||||||
|
C: Component,
|
||||||
|
D: Component,
|
||||||
|
E: Component,
|
||||||
|
](
|
||||||
|
self,
|
||||||
|
__c1: type[A],
|
||||||
|
__c2: type[B],
|
||||||
|
__c3: type[C],
|
||||||
|
__c4: type[D],
|
||||||
|
__c5: type[E],
|
||||||
|
/,
|
||||||
|
) -> Iterable[tuple[int, tuple[A, B, C, D]]]: ...
|
||||||
|
|
||||||
|
def get_components(
|
||||||
|
self, *component_types: type[Component]
|
||||||
|
) -> Iterable[tuple[int, tuple[Component, ...]]]:
|
||||||
|
"""Returns all entities with the given components."""
|
||||||
|
entity_set = set.intersection(
|
||||||
|
*(
|
||||||
|
self.__components[component_type]
|
||||||
|
for component_type in component_types
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for entity in entity_set:
|
||||||
|
yield (
|
||||||
|
entity,
|
||||||
|
tuple(
|
||||||
|
self.__entitites[entity][component_type]
|
||||||
|
for component_type in component_types
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_components_of_entity[A: Component](
|
||||||
|
self, entity: int, __c1: type[A], /
|
||||||
|
) -> tuple[A | None]: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_components_of_entity[A: Component, B: Component](
|
||||||
|
self, entity: int, __c1: type[A], __c2: type[B], /
|
||||||
|
) -> tuple[A | None, B | None]: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_components_of_entity[A: Component, B: Component, C: Component](
|
||||||
|
self, entity: int, __c1: type[A], __c2: type[B], __c3: type[C], /
|
||||||
|
) -> tuple[A | None, B | None, C | None]: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_components_of_entity[
|
||||||
|
A: Component,
|
||||||
|
B: Component,
|
||||||
|
C: Component,
|
||||||
|
D: Component,
|
||||||
|
](
|
||||||
|
self,
|
||||||
|
entity: int,
|
||||||
|
__c1: type[A],
|
||||||
|
__c2: type[B],
|
||||||
|
__c3: type[C],
|
||||||
|
__c4: type[D],
|
||||||
|
/,
|
||||||
|
) -> tuple[A | None, B | None, C | None, D | None]: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_components_of_entity[
|
||||||
|
A: Component,
|
||||||
|
B: Component,
|
||||||
|
C: Component,
|
||||||
|
D: Component,
|
||||||
|
E: Component,
|
||||||
|
](
|
||||||
|
self,
|
||||||
|
entity: int,
|
||||||
|
__c1: type[A],
|
||||||
|
__c2: type[B],
|
||||||
|
__c3: type[C],
|
||||||
|
__c4: type[D],
|
||||||
|
__c5: type[E],
|
||||||
|
/,
|
||||||
|
) -> tuple[A | None, B | None, C | None, D | None, E | None]: ...
|
||||||
|
|
||||||
|
def get_components_of_entity(
|
||||||
|
self, entity: int, /, *component_types: type[Component]
|
||||||
|
) -> tuple[Any, ...]:
|
||||||
|
"""Returns the components of the given entity."""
|
||||||
|
entity_dict = self.__entitites[entity]
|
||||||
|
return (
|
||||||
|
*(
|
||||||
|
entity_dict.get(component_type, None)
|
||||||
|
for component_type in component_types
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_component[C: Component](
|
||||||
|
self, component_type: type[C], /
|
||||||
|
) -> Iterable[tuple[int, C]]:
|
||||||
|
"""Returns all entities with the given component."""
|
||||||
|
for entity in self.__components[component_type].copy():
|
||||||
|
yield entity, cast(C, self.__entitites[entity][component_type])
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_component_of_entity[C: Component](
|
||||||
|
self, entity: int, component_type: type[C], /
|
||||||
|
) -> C | None: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_component_of_entity[C: Component, D: Any](
|
||||||
|
self, entity: int, component_type: type[C], /, default: D
|
||||||
|
) -> C | D: ...
|
||||||
|
|
||||||
|
def get_component_of_entity(
|
||||||
|
self,
|
||||||
|
entity: int,
|
||||||
|
component_type: type[Component],
|
||||||
|
/,
|
||||||
|
default: Any = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Returns the component of the given entity."""
|
||||||
|
return self.__entitites[entity].get(component_type, default)
|
||||||
|
|
||||||
|
def add_component(self, entity: int, component: Component) -> None:
|
||||||
|
"""Adds a component to an entity."""
|
||||||
|
component_type = type(component)
|
||||||
|
self.__components[component_type].add(entity)
|
||||||
|
self.__entitites[entity][component_type] = component
|
||||||
|
|
||||||
|
def has_component(
|
||||||
|
self, entity: int, component_type: type[Component]
|
||||||
|
) -> bool:
|
||||||
|
"""Returns True if the entity has the given component."""
|
||||||
|
return component_type in self.__entitites[entity]
|
||||||
|
|
||||||
|
def has_components(
|
||||||
|
self, entity: int, *component_types: type[Component]
|
||||||
|
) -> bool:
|
||||||
|
"""Returns True if the entity has all the given components."""
|
||||||
|
components_dict = self.__entitites[entity]
|
||||||
|
return all(
|
||||||
|
comp_type in components_dict for comp_type in component_types
|
||||||
|
)
|
||||||
|
|
||||||
|
def remove_component[C: Component](
|
||||||
|
self, entity: int, component_type: type[C]
|
||||||
|
) -> C | None:
|
||||||
|
"""Removes a component from an entity."""
|
||||||
|
self.__components[component_type].discard(entity)
|
||||||
|
if not self.__components[component_type]:
|
||||||
|
del self.__components[component_type]
|
||||||
|
return self.__entitites[entity].pop(component_type) # type: ignore
|
||||||
|
|
||||||
|
def create_entity(self, *components: Component) -> int:
|
||||||
|
"""Creates an entity with the given components."""
|
||||||
|
entity = next(self.__entity_counter)
|
||||||
|
if entity not in self.__entitites:
|
||||||
|
self.__entitites[entity] = {}
|
||||||
|
for component in components:
|
||||||
|
component_type = type(component)
|
||||||
|
self.__components[component_type].add(entity)
|
||||||
|
if component_type not in self.__entitites[entity]:
|
||||||
|
self.__entitites[entity][component_type] = component
|
||||||
|
return entity
|
||||||
|
|
||||||
|
def delete_entity(self, entity: int, immediate: bool = False) -> None:
|
||||||
|
"""Deletes an entity."""
|
||||||
|
if immediate:
|
||||||
|
for component_type in self.__entitites[entity]:
|
||||||
|
self.__components[component_type].discard(entity)
|
||||||
|
if not self.__components[component_type]:
|
||||||
|
del self.__components[component_type]
|
||||||
|
del self.__entitites[entity]
|
||||||
|
else:
|
||||||
|
self.__dead_entities.add(entity)
|
||||||
|
|
||||||
|
def entity_exists(self, entity: int) -> bool:
|
||||||
|
"""Returns True if the entity exists."""
|
||||||
|
return (
|
||||||
|
entity in self.__entitites and entity not in self.__dead_entities
|
||||||
|
)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Starts the system."""
|
||||||
|
self.__is_running = True
|
||||||
|
while self.__is_running:
|
||||||
|
self.update()
|
||||||
|
nolock()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stops the system."""
|
||||||
|
self.__is_running = False
|
||||||
|
|
||||||
|
def update(self) -> None:
|
||||||
|
"""Updates the system."""
|
||||||
|
self._clear_dead_entities()
|
||||||
|
for processor in self.__processors:
|
||||||
|
processor.process(self)
|
||||||
|
|
||||||
|
def _clear_dead_entities(self) -> None:
|
||||||
|
for entity in self.__dead_entities:
|
||||||
|
self.delete_entity(entity, immediate=True)
|
||||||
|
self.__dead_entities = set()
|
||||||
|
|
||||||
|
def _sort_processors(self) -> None:
|
||||||
|
processors = self.__processors
|
||||||
|
graph: nx.DiGraph[Processor] = nx.DiGraph()
|
||||||
|
for p in processors:
|
||||||
|
graph.add_node(p)
|
||||||
|
for p in processors:
|
||||||
|
for after_cls in p.after:
|
||||||
|
for q in processors:
|
||||||
|
if isinstance(q, after_cls):
|
||||||
|
graph.add_edge(q, p)
|
||||||
|
for before_cls in p.before:
|
||||||
|
for q in processors:
|
||||||
|
if isinstance(q, before_cls):
|
||||||
|
graph.add_edge(p, q)
|
||||||
|
sorted_processors = list(nx.topological_sort(graph))
|
||||||
|
self.__processors = sorted_processors
|
||||||
37
src/snakia/core/engine.py
Normal file
37
src/snakia/core/engine.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import threading
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from .ecs import System
|
||||||
|
from .es import Dispatcher
|
||||||
|
from .loader.loader import Loader
|
||||||
|
|
||||||
|
|
||||||
|
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.__dispatcher_thread = threading.Thread(
|
||||||
|
target=self.dispatcher.start, daemon=False
|
||||||
|
)
|
||||||
|
self.__system_thread.start()
|
||||||
|
self.__dispatcher_thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if self.__system_thread is not None:
|
||||||
|
self.system.stop()
|
||||||
|
self.__system_thread.join()
|
||||||
|
if self.__dispatcher_thread is not None:
|
||||||
|
self.dispatcher.stop()
|
||||||
|
self.__dispatcher_thread.join()
|
||||||
|
|
||||||
|
def update(self) -> None:
|
||||||
|
self.system.update()
|
||||||
|
self.dispatcher.update()
|
||||||
16
src/snakia/core/es/__init__.py
Normal file
16
src/snakia/core/es/__init__.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from .action import Action
|
||||||
|
from .dispatcher import Dispatcher
|
||||||
|
from .event import Event
|
||||||
|
from .filter import Filter
|
||||||
|
from .handler import Handler
|
||||||
|
from .subscriber import Subscriber
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"Event",
|
||||||
|
"Action",
|
||||||
|
"Filter",
|
||||||
|
"Handler",
|
||||||
|
"Handler",
|
||||||
|
"Subscriber",
|
||||||
|
"Dispatcher",
|
||||||
|
)
|
||||||
38
src/snakia/core/es/action.py
Normal file
38
src/snakia/core/es/action.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Action(BaseModel):
|
||||||
|
move: int = Field(default=1)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def stop(cls) -> Self:
|
||||||
|
"""Skip all handlers."""
|
||||||
|
return cls(move=2**8)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def go_start(cls) -> Self:
|
||||||
|
"""Go to the first handler."""
|
||||||
|
return cls(move=-(2**8))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def next(cls, count: int = 1) -> Self:
|
||||||
|
"""Skip one handler."""
|
||||||
|
return cls(move=count)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def prev(cls, count: int = 1) -> Self:
|
||||||
|
"""Go back one handler."""
|
||||||
|
return cls(move=-count)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def skip(cls, count: int = 1) -> Self:
|
||||||
|
"""Skip n handlers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
count (int): The number of handlers to skip.
|
||||||
|
"""
|
||||||
|
return cls(move=count + 1)
|
||||||
108
src/snakia/core/es/dispatcher.py
Normal file
108
src/snakia/core/es/dispatcher.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import queue
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Callable, Final
|
||||||
|
|
||||||
|
from snakia.utils import nolock
|
||||||
|
|
||||||
|
from .event import Event
|
||||||
|
from .filter import Filter
|
||||||
|
from .handler import Handler
|
||||||
|
from .subscriber import Subscriber
|
||||||
|
|
||||||
|
|
||||||
|
class Dispatcher:
|
||||||
|
"""
|
||||||
|
Event dispatcher
|
||||||
|
"""
|
||||||
|
|
||||||
|
__running: bool
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.__queue: Final = queue.Queue[Event]()
|
||||||
|
self.__subscribers: Final[
|
||||||
|
dict[type[Event], list[Subscriber[Event]]]
|
||||||
|
] = defaultdict(list)
|
||||||
|
self.__running = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""Returns True if the dispatcher is running."""
|
||||||
|
return self.__running
|
||||||
|
|
||||||
|
def subscribe[T: Event](
|
||||||
|
self, event_type: type[T], subscriber: Subscriber[T]
|
||||||
|
) -> None:
|
||||||
|
"""Subscribe to an event type."""
|
||||||
|
self.__subscribers[event_type].append(subscriber) # type: ignore
|
||||||
|
|
||||||
|
def unsubscribe[T: Event](
|
||||||
|
self, event_type: type[T], subscriber: Subscriber[T]
|
||||||
|
) -> None:
|
||||||
|
"""Unsubscribe from an event type."""
|
||||||
|
for sub in self.__subscribers[event_type].copy():
|
||||||
|
if sub.handler != subscriber.handler:
|
||||||
|
continue
|
||||||
|
if sub.priority != subscriber.priority:
|
||||||
|
continue
|
||||||
|
self.__subscribers[event_type].remove(sub)
|
||||||
|
|
||||||
|
def on[T: Event](
|
||||||
|
self,
|
||||||
|
event: type[T],
|
||||||
|
filter: Filter[T] | None = None, # noqa: W0622 # pylint: disable=W0622
|
||||||
|
priority: int = -1,
|
||||||
|
) -> Callable[[Handler[T]], Handler[T]]:
|
||||||
|
"""Decorator to subscribe to an event."""
|
||||||
|
|
||||||
|
def wrapper(handler: Handler[T]) -> Handler[T]:
|
||||||
|
self.subscribe(event, Subscriber(handler, filter, priority))
|
||||||
|
return handler
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
def publish(self, event: Event) -> None:
|
||||||
|
"""Add an event to the queue."""
|
||||||
|
self.__queue.put(event)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start the update loop."""
|
||||||
|
self.__running = True
|
||||||
|
while self.__running:
|
||||||
|
self.update()
|
||||||
|
nolock()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the update loop."""
|
||||||
|
self.__running = False
|
||||||
|
|
||||||
|
def update(self, block: bool = False) -> None:
|
||||||
|
"""Update the dispatcher."""
|
||||||
|
try:
|
||||||
|
event = self.__queue.get(block=block, timeout=None)
|
||||||
|
for base in event.__class__.mro():
|
||||||
|
if not issubclass(base, Event):
|
||||||
|
continue
|
||||||
|
self.__handle_event(base, event)
|
||||||
|
self.__queue.task_done()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __handle_event(self, event_type: type[Event], event: Event) -> None:
|
||||||
|
subscribers = self.__subscribers[event_type]
|
||||||
|
subscribers.sort(key=lambda s: s.priority, reverse=True)
|
||||||
|
i = 0
|
||||||
|
while i < len(subscribers):
|
||||||
|
subscriber = subscribers[i]
|
||||||
|
if subscriber.filters is not None and not subscriber.filters(
|
||||||
|
event
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
action = subscriber.handler(event)
|
||||||
|
if action is not None:
|
||||||
|
i = max(0, min(len(subscribers), i + action.move))
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
event.reduce_ttl()
|
||||||
13
src/snakia/core/es/event.py
Normal file
13
src/snakia/core/es/event.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Event(ABC, BaseModel):
|
||||||
|
ttl: int = Field(default=2**6, kw_only=True, ge=0)
|
||||||
|
|
||||||
|
def reduce_ttl(self) -> None:
|
||||||
|
"""Reduce the TTL of the event by 1."""
|
||||||
|
self.ttl -= 1
|
||||||
11
src/snakia/core/es/filter.py
Normal file
11
src/snakia/core/es/filter.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from .event import Event
|
||||||
|
|
||||||
|
|
||||||
|
class Filter[T: Event](Protocol):
|
||||||
|
"""Filter for an event."""
|
||||||
|
|
||||||
|
def __call__(self, event: T) -> bool: ...
|
||||||
12
src/snakia/core/es/handler.py
Normal file
12
src/snakia/core/es/handler.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional, Protocol
|
||||||
|
|
||||||
|
from .action import Action
|
||||||
|
from .event import Event
|
||||||
|
|
||||||
|
|
||||||
|
class Handler[T: Event](Protocol):
|
||||||
|
"""Handler for an event."""
|
||||||
|
|
||||||
|
def __call__(self, event: T) -> Optional[Action]: ...
|
||||||
16
src/snakia/core/es/subscriber.py
Normal file
16
src/snakia/core/es/subscriber.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from .event import Event
|
||||||
|
from .filter import Filter
|
||||||
|
from .handler import Handler
|
||||||
|
|
||||||
|
|
||||||
|
class Subscriber[T: Event](NamedTuple):
|
||||||
|
"""
|
||||||
|
Subscriber for an event."""
|
||||||
|
|
||||||
|
handler: Handler[T]
|
||||||
|
filters: Filter[T] | None
|
||||||
|
priority: int
|
||||||
61
src/snakia/core/except_manager.py
Normal file
61
src/snakia/core/except_manager.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import sys
|
||||||
|
from types import TracebackType
|
||||||
|
from typing import Any, Callable, Protocol, final
|
||||||
|
|
||||||
|
|
||||||
|
class ExceptionHook[T: BaseException](Protocol):
|
||||||
|
def __call__(
|
||||||
|
self, exception: T, frame: TracebackType | None, /
|
||||||
|
) -> bool | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@final
|
||||||
|
class _ExceptionManager:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.__hooks: list[tuple[type[BaseException], ExceptionHook[Any]]] = []
|
||||||
|
sys.excepthook = self._excepthook
|
||||||
|
|
||||||
|
def hook_exception[T: BaseException](
|
||||||
|
self, exception_type: type[T], func: ExceptionHook[T]
|
||||||
|
) -> ExceptionHook[T]:
|
||||||
|
self.__hooks.append((exception_type, func))
|
||||||
|
return func
|
||||||
|
|
||||||
|
def on_exception[T: BaseException](
|
||||||
|
self, exception_type: type[T]
|
||||||
|
) -> Callable[[ExceptionHook[T]], ExceptionHook[T]]:
|
||||||
|
def inner(func: ExceptionHook[T]) -> ExceptionHook[T]:
|
||||||
|
self.hook_exception(exception_type, func)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
def _on_except(
|
||||||
|
self,
|
||||||
|
type_: type[BaseException],
|
||||||
|
exception: BaseException,
|
||||||
|
frame: TracebackType | None,
|
||||||
|
) -> None:
|
||||||
|
for hook_type, hook_func in self.__hooks:
|
||||||
|
if hook_type == type_ or issubclass(hook_type, type_):
|
||||||
|
result = hook_func(exception, frame)
|
||||||
|
if result:
|
||||||
|
break
|
||||||
|
|
||||||
|
def _excepthook(
|
||||||
|
self,
|
||||||
|
type_: type[BaseException],
|
||||||
|
exception: BaseException,
|
||||||
|
frame: TracebackType | None,
|
||||||
|
) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
self._on_except(type_, exception, frame)
|
||||||
|
break
|
||||||
|
except BaseException as e: # noqa: W0718 # pylint: disable=W0718
|
||||||
|
if e is exception:
|
||||||
|
return
|
||||||
|
type_, exception = type(e), e
|
||||||
|
|
||||||
|
|
||||||
|
ExceptionManager = _ExceptionManager()
|
||||||
6
src/snakia/core/loader/__init__.py
Normal file
6
src/snakia/core/loader/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from .loadable import Loadable
|
||||||
|
from .meta import Meta
|
||||||
|
from .plugin import Plugin
|
||||||
|
from .plugin_processor import PluginProcessor
|
||||||
|
|
||||||
|
__all__ = ["Loadable", "Meta", "Plugin", "PluginProcessor"]
|
||||||
18
src/snakia/core/loader/loadable.py
Normal file
18
src/snakia/core/loader/loadable.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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: ...
|
||||||
24
src/snakia/core/loader/loader.py
Normal file
24
src/snakia/core/loader/loader.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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:
|
||||||
|
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()
|
||||||
39
src/snakia/core/loader/meta.py
Normal file
39
src/snakia/core/loader/meta.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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}"
|
||||||
72
src/snakia/core/loader/plugin.py
Normal file
72
src/snakia/core/loader/plugin.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
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
|
||||||
14
src/snakia/core/loader/plugin_processor.py
Normal file
14
src/snakia/core/loader/plugin_processor.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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
|
||||||
26
src/snakia/core/rx/__init__.py
Normal file
26
src/snakia/core/rx/__init__.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
from .async_bindable import AsyncBindable
|
||||||
|
from .base_bindable import BaseBindable, BindableSubscriber, ValueChanged
|
||||||
|
from .bindable import Bindable
|
||||||
|
from .chain import chain
|
||||||
|
from .combine import combine
|
||||||
|
from .concat import concat
|
||||||
|
from .const import const
|
||||||
|
from .filter import filter # noqa: W0622 # pylint: disable=W0622
|
||||||
|
from .map import map # noqa: W0622 # pylint: disable=W0622
|
||||||
|
from .merge import async_merge, merge
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Bindable",
|
||||||
|
"AsyncBindable",
|
||||||
|
"BaseBindable",
|
||||||
|
"BindableSubscriber",
|
||||||
|
"ValueChanged",
|
||||||
|
"chain",
|
||||||
|
"combine",
|
||||||
|
"concat",
|
||||||
|
"const",
|
||||||
|
"filter",
|
||||||
|
"map",
|
||||||
|
"merge",
|
||||||
|
"async_merge",
|
||||||
|
]
|
||||||
105
src/snakia/core/rx/async_bindable.py
Normal file
105
src/snakia/core/rx/async_bindable.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
from typing import Any, Awaitable, Callable, Literal, overload
|
||||||
|
|
||||||
|
from .base_bindable import BaseBindable, BindableSubscriber, ValueChanged
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncBindable[T: Any](BaseBindable[T]):
|
||||||
|
"""
|
||||||
|
An asynchronous bindable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, default_value: T | None = None) -> None:
|
||||||
|
super().__init__(default_value)
|
||||||
|
self.__subscribers: list[BindableSubscriber[T, Awaitable[Any]]] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self) -> T:
|
||||||
|
return self.__value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subscribers(
|
||||||
|
self,
|
||||||
|
) -> tuple[BindableSubscriber[T, Awaitable[Any]], ...]:
|
||||||
|
"""Get the subscribers."""
|
||||||
|
return (*self.__subscribers,)
|
||||||
|
|
||||||
|
async def set(self, value: T) -> None:
|
||||||
|
"""Set the value."""
|
||||||
|
e = ValueChanged(self.__value, value)
|
||||||
|
self.__value = value
|
||||||
|
for subscriber in self.__subscribers:
|
||||||
|
await subscriber(e)
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def subscribe(
|
||||||
|
self,
|
||||||
|
subscriber: BindableSubscriber[T, Awaitable[Any]],
|
||||||
|
/,
|
||||||
|
run_now: Literal[True],
|
||||||
|
) -> Awaitable[None]: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def subscribe(
|
||||||
|
self,
|
||||||
|
subscriber: BindableSubscriber[T, Awaitable[Any]],
|
||||||
|
/,
|
||||||
|
run_now: Literal[False] = False,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
def subscribe(
|
||||||
|
self,
|
||||||
|
subscriber: BindableSubscriber[T, Awaitable[Any]],
|
||||||
|
/,
|
||||||
|
run_now: bool = False,
|
||||||
|
) -> None | Awaitable[None]:
|
||||||
|
"""Subscribe to an value."""
|
||||||
|
self.__subscribers.append(subscriber)
|
||||||
|
if run_now:
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
await subscriber(
|
||||||
|
ValueChanged(self.__default_value, self.__value)
|
||||||
|
)
|
||||||
|
|
||||||
|
return _run()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def unsubscribe(
|
||||||
|
self, subscriber: BindableSubscriber[T, Awaitable[Any]]
|
||||||
|
) -> None:
|
||||||
|
"""Unsubscribe from an value."""
|
||||||
|
self.__subscribers.remove(subscriber)
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def on(
|
||||||
|
self, run_now: Literal[True]
|
||||||
|
) -> Callable[
|
||||||
|
[BindableSubscriber[T, Awaitable[Any]]], Awaitable[None]
|
||||||
|
]: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def on(
|
||||||
|
self, run_now: Literal[False] = False
|
||||||
|
) -> Callable[[BindableSubscriber[T, Awaitable[Any]]], None]: ...
|
||||||
|
|
||||||
|
def on(self, run_now: bool = False) -> Callable[
|
||||||
|
[BindableSubscriber[T, Awaitable[Any]]],
|
||||||
|
None | Awaitable[None],
|
||||||
|
]:
|
||||||
|
"""Decorator to subscribe to an value."""
|
||||||
|
|
||||||
|
def wrapper(
|
||||||
|
subscriber: BindableSubscriber[T, Awaitable[Any]],
|
||||||
|
) -> None | Awaitable[None]:
|
||||||
|
self.__subscribers.append(subscriber)
|
||||||
|
if run_now:
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
await subscriber(
|
||||||
|
ValueChanged(self.__default_value, self.__value)
|
||||||
|
)
|
||||||
|
|
||||||
|
return _run()
|
||||||
|
return None
|
||||||
|
|
||||||
|
return wrapper
|
||||||
28
src/snakia/core/rx/base_bindable.py
Normal file
28
src/snakia/core/rx/base_bindable.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
from typing import Any, NamedTuple, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class ValueChanged[T](NamedTuple):
|
||||||
|
old_value: T
|
||||||
|
new_value: T
|
||||||
|
|
||||||
|
|
||||||
|
class BindableSubscriber[T: Any, R: Any](Protocol):
|
||||||
|
def __call__(self, value: ValueChanged[T], /) -> R: ...
|
||||||
|
|
||||||
|
|
||||||
|
class BaseBindable[T: Any]:
|
||||||
|
def __init__(self, default_value: T | None = None) -> None:
|
||||||
|
if default_value is not None:
|
||||||
|
self.__default_value: T = default_value
|
||||||
|
self.__value: T = default_value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_value(self) -> T:
|
||||||
|
return self.__default_value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self) -> T:
|
||||||
|
return self.__value
|
||||||
|
|
||||||
|
def set_silent(self, value: T) -> None:
|
||||||
|
self.__value = value
|
||||||
47
src/snakia/core/rx/bindable.py
Normal file
47
src/snakia/core/rx/bindable.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from .base_bindable import BaseBindable, BindableSubscriber, ValueChanged
|
||||||
|
|
||||||
|
|
||||||
|
class Bindable[T: Any](BaseBindable[T]):
|
||||||
|
"""
|
||||||
|
A bindable value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, default_value: T | None = None) -> None:
|
||||||
|
super().__init__(default_value)
|
||||||
|
self.__subscribers: list[BindableSubscriber[T, Any]] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subscribers(self) -> tuple[BindableSubscriber[T, Any], ...]:
|
||||||
|
"""Get the subscribers."""
|
||||||
|
return (*self.__subscribers,)
|
||||||
|
|
||||||
|
def set(self, value: T) -> None:
|
||||||
|
"""Set the value."""
|
||||||
|
e = ValueChanged(self.__value, value)
|
||||||
|
self.set_silent(value)
|
||||||
|
for subscriber in self.__subscribers:
|
||||||
|
subscriber(e)
|
||||||
|
|
||||||
|
def subscribe(
|
||||||
|
self, subscriber: BindableSubscriber[T, Any], /, run_now: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""Subscribe to an value."""
|
||||||
|
self.__subscribers.append(subscriber)
|
||||||
|
if run_now:
|
||||||
|
subscriber(ValueChanged(self.default_value, self.value))
|
||||||
|
|
||||||
|
def unsubscribe(self, subscriber: BindableSubscriber[T, Any]) -> None:
|
||||||
|
"""Unsubscribe from an value."""
|
||||||
|
self.__subscribers.remove(subscriber)
|
||||||
|
|
||||||
|
def on(
|
||||||
|
self, run_now: bool = False
|
||||||
|
) -> Callable[[BindableSubscriber[T, Any]], None]:
|
||||||
|
"""Decorator to subscribe to an value."""
|
||||||
|
|
||||||
|
def wrapper(subscriber: BindableSubscriber[T, Any]) -> None:
|
||||||
|
self.subscribe(subscriber, run_now)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
53
src/snakia/core/rx/chain.py
Normal file
53
src/snakia/core/rx/chain.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
from typing import Any, Callable, overload
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def chain[**P, A](func1: Callable[P, A], /) -> Callable[P, A]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def chain[**P, A, B](
|
||||||
|
func1: Callable[P, A], func2: Callable[[A], B], /
|
||||||
|
) -> Callable[P, B]: ...
|
||||||
|
@overload
|
||||||
|
def chain[**P, A, B, C](
|
||||||
|
func1: Callable[P, A], func2: Callable[[A], B], func3: Callable[[B], C], /
|
||||||
|
) -> Callable[P, C]: ...
|
||||||
|
@overload
|
||||||
|
def chain[**P, A, B, C, D](
|
||||||
|
func1: Callable[P, A],
|
||||||
|
func2: Callable[[A], B],
|
||||||
|
func3: Callable[[B], C],
|
||||||
|
func4: Callable[[C], D],
|
||||||
|
/,
|
||||||
|
) -> Callable[P, D]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def chain[**P, A, B, C, D, E](
|
||||||
|
func1: Callable[P, A],
|
||||||
|
func2: Callable[[A], B],
|
||||||
|
func3: Callable[[B], C],
|
||||||
|
func4: Callable[[C], D],
|
||||||
|
func5: Callable[[D], E],
|
||||||
|
/,
|
||||||
|
) -> Callable[P, E]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def chain[**P](
|
||||||
|
func1: Callable[P, Any], /, *funcs: Callable[[Any], Any]
|
||||||
|
) -> Callable[P, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
def chain[**P](
|
||||||
|
func1: Callable[P, Any], /, *funcs: Callable[[Any], Any]
|
||||||
|
) -> Callable[P, Any]:
|
||||||
|
|
||||||
|
def inner(*args: P.args, **kwargs: P.kwargs) -> Any:
|
||||||
|
v = func1(*args, **kwargs)
|
||||||
|
for f in funcs:
|
||||||
|
v = f(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
return inner
|
||||||
94
src/snakia/core/rx/combine.py
Normal file
94
src/snakia/core/rx/combine.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import operator
|
||||||
|
from typing import Any, Callable, overload
|
||||||
|
|
||||||
|
from snakia.utils import to_async
|
||||||
|
|
||||||
|
from .async_bindable import AsyncBindable
|
||||||
|
from .base_bindable import ValueChanged
|
||||||
|
from .bindable import Bindable
|
||||||
|
from .concat import concat
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def combine[A, R](
|
||||||
|
source1: Bindable[A] | AsyncBindable[A],
|
||||||
|
/,
|
||||||
|
*,
|
||||||
|
combiner: Callable[[A], R],
|
||||||
|
) -> Bindable[R]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def combine[A, B, R](
|
||||||
|
source1: Bindable[A] | AsyncBindable[A],
|
||||||
|
source2: Bindable[B] | AsyncBindable[B],
|
||||||
|
/,
|
||||||
|
*,
|
||||||
|
combiner: Callable[[A, B], R],
|
||||||
|
) -> Bindable[R]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def combine[A, B, C, R](
|
||||||
|
source1: Bindable[A] | AsyncBindable[A],
|
||||||
|
source2: Bindable[B] | AsyncBindable[B],
|
||||||
|
source3: Bindable[C] | AsyncBindable[C],
|
||||||
|
/,
|
||||||
|
*,
|
||||||
|
combiner: Callable[[A, B, C], R],
|
||||||
|
) -> Bindable[R]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def combine[A, B, C, D, R](
|
||||||
|
source1: Bindable[A] | AsyncBindable[A],
|
||||||
|
source2: Bindable[B] | AsyncBindable[B],
|
||||||
|
source3: Bindable[C] | AsyncBindable[C],
|
||||||
|
source4: Bindable[D] | AsyncBindable[D],
|
||||||
|
/,
|
||||||
|
*,
|
||||||
|
combiner: Callable[[A, B, C, D], R],
|
||||||
|
) -> Bindable[R]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def combine[A, B, C, D, R](
|
||||||
|
source1: Bindable[A] | AsyncBindable[A],
|
||||||
|
source2: Bindable[B] | AsyncBindable[B],
|
||||||
|
source3: Bindable[C] | AsyncBindable[C],
|
||||||
|
source4: Bindable[D] | AsyncBindable[D],
|
||||||
|
/,
|
||||||
|
*,
|
||||||
|
combiner: Callable[[A, B, C, D], R],
|
||||||
|
) -> Bindable[R]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def combine[R](
|
||||||
|
*sources: Bindable[Any] | AsyncBindable[Any],
|
||||||
|
combiner: Callable[..., R],
|
||||||
|
) -> Bindable[R]: ...
|
||||||
|
|
||||||
|
|
||||||
|
def combine[R](
|
||||||
|
*sources: Bindable[Any] | AsyncBindable[Any],
|
||||||
|
combiner: Callable[..., R],
|
||||||
|
) -> Bindable[R]:
|
||||||
|
combined = Bindable[R]()
|
||||||
|
values = [*map(lambda s: s.value, sources)]
|
||||||
|
|
||||||
|
for i, source in enumerate(sources):
|
||||||
|
|
||||||
|
def make_subscriber(
|
||||||
|
index: int,
|
||||||
|
) -> Callable[[ValueChanged[Any]], None]:
|
||||||
|
return concat(
|
||||||
|
lambda v: operator.setitem(values, index, v.new_value),
|
||||||
|
lambda _: combiner(*values),
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(source, Bindable):
|
||||||
|
source.subscribe(make_subscriber(i))
|
||||||
|
else:
|
||||||
|
source.subscribe(to_async(make_subscriber(i)))
|
||||||
|
return combined
|
||||||
9
src/snakia/core/rx/concat.py
Normal file
9
src/snakia/core/rx/concat.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
def concat[**P](*funcs: Callable[P, Any]) -> Callable[P, None]:
|
||||||
|
def inner(*args: P.args, **kwargs: P.kwargs) -> None:
|
||||||
|
for f in funcs:
|
||||||
|
f(*args, **kwargs)
|
||||||
|
|
||||||
|
return inner
|
||||||
5
src/snakia/core/rx/const.py
Normal file
5
src/snakia/core/rx/const.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
def const[T](value: T) -> Callable[[], T]:
|
||||||
|
return lambda: value
|
||||||
9
src/snakia/core/rx/filter.py
Normal file
9
src/snakia/core/rx/filter.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import builtins
|
||||||
|
from typing import Callable, Iterable, TypeGuard
|
||||||
|
|
||||||
|
|
||||||
|
# noqa: W0622 # pylint: disable=W0622
|
||||||
|
def filter[S, T](
|
||||||
|
f: Callable[[S], TypeGuard[T]],
|
||||||
|
) -> Callable[[Iterable[S]], Iterable[T]]:
|
||||||
|
return lambda iterable: builtins.filter(f, iterable)
|
||||||
9
src/snakia/core/rx/map.py
Normal file
9
src/snakia/core/rx/map.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import builtins
|
||||||
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
|
|
||||||
|
# noqa: W0622 # pylint: disable=W0622
|
||||||
|
def map[T: Any, U](
|
||||||
|
func: Callable[[T], U], /
|
||||||
|
) -> Callable[[Iterable[T]], Iterable[U]]:
|
||||||
|
return lambda iterable: builtins.map(func, iterable)
|
||||||
20
src/snakia/core/rx/merge.py
Normal file
20
src/snakia/core/rx/merge.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
from .async_bindable import AsyncBindable
|
||||||
|
from .bindable import Bindable
|
||||||
|
|
||||||
|
|
||||||
|
def merge[T](
|
||||||
|
*sources: Bindable[T],
|
||||||
|
) -> Bindable[T]:
|
||||||
|
merged = Bindable[T]()
|
||||||
|
for source in sources:
|
||||||
|
source.subscribe(lambda v: merged.set(v.new_value), run_now=True)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
async def async_merge[T](
|
||||||
|
*sources: AsyncBindable[T],
|
||||||
|
) -> AsyncBindable[T]:
|
||||||
|
merged = AsyncBindable[T]()
|
||||||
|
for source in sources:
|
||||||
|
await source.subscribe(lambda v: merged.set(v.new_value), run_now=True)
|
||||||
|
return merged
|
||||||
13
src/snakia/core/tui/__init__.py
Normal file
13
src/snakia/core/tui/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from .canvas import Canvas
|
||||||
|
from .char import CanvasChar
|
||||||
|
from .renderer import RenderContext, Renderer, RenderTarget
|
||||||
|
from .widget import Widget
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Canvas",
|
||||||
|
"CanvasChar",
|
||||||
|
"Renderer",
|
||||||
|
"RenderContext",
|
||||||
|
"RenderTarget",
|
||||||
|
"Widget",
|
||||||
|
]
|
||||||
173
src/snakia/core/tui/canvas.py
Normal file
173
src/snakia/core/tui/canvas.py
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Final, Iterable
|
||||||
|
|
||||||
|
from .char import CanvasChar
|
||||||
|
|
||||||
|
|
||||||
|
class Canvas:
|
||||||
|
"""
|
||||||
|
A canvas is a 2D array of characters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = "__buffer", "__default", "width", "height"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, width: int, height: int, default_value: CanvasChar = CanvasChar()
|
||||||
|
) -> None:
|
||||||
|
width = max(width, 0)
|
||||||
|
height = max(height, 0)
|
||||||
|
|
||||||
|
self.width: Final[int] = width
|
||||||
|
self.height: Final[int] = height
|
||||||
|
self.__default: Final[CanvasChar] = default_value
|
||||||
|
|
||||||
|
self.__buffer: list[CanvasChar] = [default_value] * self.total
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return self.width * self.height
|
||||||
|
|
||||||
|
def get(self, x: int, y: int, /) -> CanvasChar:
|
||||||
|
"""Get the character at the given position."""
|
||||||
|
return self.__buffer[self._get_index(x, y)]
|
||||||
|
|
||||||
|
def get_row(self, y: int, /) -> Iterable[CanvasChar]:
|
||||||
|
"""Get the row at the given position."""
|
||||||
|
start_index = self._get_index(0, y)
|
||||||
|
end_index = start_index + self.width
|
||||||
|
return self.__buffer[start_index:end_index]
|
||||||
|
|
||||||
|
def get_column(self, x: int, /) -> Iterable[CanvasChar]:
|
||||||
|
"""Get the column at the given position."""
|
||||||
|
return (
|
||||||
|
self.__buffer[self._get_index(x, y)] for y in range(self.height)
|
||||||
|
)
|
||||||
|
|
||||||
|
def set(self, x: int, y: int, value: CanvasChar, /) -> None:
|
||||||
|
"""Set the character at the given position."""
|
||||||
|
self.__buffer[self._get_index(x, y)] = value
|
||||||
|
|
||||||
|
def set_row(self, y: int, value: CanvasChar, /) -> None:
|
||||||
|
"""Set the row at the given position."""
|
||||||
|
start_index = self._get_index(0, y)
|
||||||
|
end_index = start_index + self.width
|
||||||
|
self.__buffer[start_index:end_index] = [value] * self.width
|
||||||
|
|
||||||
|
def set_column(self, x: int, value: CanvasChar, /) -> None:
|
||||||
|
"""Set the column at the given position."""
|
||||||
|
for y in range(self.height):
|
||||||
|
self.set(x, y, value)
|
||||||
|
|
||||||
|
def set_area(
|
||||||
|
self,
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
value: CanvasChar,
|
||||||
|
) -> None:
|
||||||
|
"""Set the area at the given position."""
|
||||||
|
for i in range(
|
||||||
|
self._get_index(x, y), self._get_index(x + width, y + height)
|
||||||
|
):
|
||||||
|
self.__buffer[i] = value
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear the canvas."""
|
||||||
|
self.fill(self.__default)
|
||||||
|
|
||||||
|
def fill(self, value: CanvasChar, /) -> None:
|
||||||
|
"""Fill the canvas with the given value."""
|
||||||
|
self.__buffer = [value] * self.total
|
||||||
|
|
||||||
|
def draw_line_h(
|
||||||
|
self,
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
length: int,
|
||||||
|
char: CanvasChar,
|
||||||
|
) -> None:
|
||||||
|
"""Draw a horizontal line."""
|
||||||
|
for i in range(length):
|
||||||
|
self.set(x + i, y, char)
|
||||||
|
|
||||||
|
def draw_line_v(
|
||||||
|
self,
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
length: int,
|
||||||
|
char: CanvasChar,
|
||||||
|
) -> None:
|
||||||
|
"""Draw a vertical line."""
|
||||||
|
for i in range(length):
|
||||||
|
self.set(x, y + i, char)
|
||||||
|
|
||||||
|
def draw_rect(
|
||||||
|
self,
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
char: CanvasChar,
|
||||||
|
) -> None:
|
||||||
|
"""Draw a rectangle."""
|
||||||
|
# Bottom and top
|
||||||
|
self.draw_line_h(x, y, width, char)
|
||||||
|
self.draw_line_h(x, y + height - 1, width, char)
|
||||||
|
|
||||||
|
# Left and right
|
||||||
|
self.draw_line_v(x, y, height, char)
|
||||||
|
self.draw_line_v(x + width - 1, y, height, char)
|
||||||
|
|
||||||
|
def copy_from(self, other: Canvas, x: int = 0, y: int = 0) -> None:
|
||||||
|
"""Copy the given canvas to the current canvas."""
|
||||||
|
for dy in range(min(other.height, self.height - y)):
|
||||||
|
for dx in range(min(other.width, self.width - x)):
|
||||||
|
self.set(x + dx, y + dy, other.get(dx, dy))
|
||||||
|
|
||||||
|
def draw_text(self, x: int, y: int, text: str, char: CanvasChar) -> None:
|
||||||
|
"""Draw text on the canvas."""
|
||||||
|
for i, c in enumerate(text):
|
||||||
|
if x + i < self.width:
|
||||||
|
self.set(
|
||||||
|
x + i,
|
||||||
|
y,
|
||||||
|
CanvasChar(
|
||||||
|
char=c,
|
||||||
|
fg_color=char.fg_color,
|
||||||
|
bg_color=char.bg_color,
|
||||||
|
bold=char.bold,
|
||||||
|
italic=char.italic,
|
||||||
|
underline=char.underline,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def draw_filled_rect(
|
||||||
|
self,
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
char: CanvasChar,
|
||||||
|
) -> None:
|
||||||
|
"""Draw a filled rectangle."""
|
||||||
|
for dy in range(height):
|
||||||
|
for dx in range(width):
|
||||||
|
self.set(x + dx, y + dy, char)
|
||||||
|
|
||||||
|
def is_valid_position(self, x: int, y: int) -> bool:
|
||||||
|
"""Check if the given position is valid."""
|
||||||
|
return 0 <= x < self.width and 0 <= y < self.height
|
||||||
|
|
||||||
|
def get_subcanvas(self, x: int, y: int, width: int, height: int) -> Canvas:
|
||||||
|
"""Get a subcanvas from the current canvas."""
|
||||||
|
subcanvas = Canvas(width, height, self.__default)
|
||||||
|
for dy in range(height):
|
||||||
|
for dx in range(width):
|
||||||
|
if self.is_valid_position(x + dx, y + dy):
|
||||||
|
subcanvas.set(dx, dy, self.get(x + dx, y + dy))
|
||||||
|
return subcanvas
|
||||||
|
|
||||||
|
def _get_index(self, x: int, y: int) -> int:
|
||||||
|
return self.width * y + x
|
||||||
30
src/snakia/core/tui/char.py
Normal file
30
src/snakia/core/tui/char.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CanvasChar:
|
||||||
|
char: str = " "
|
||||||
|
fg_color: str | None = None
|
||||||
|
bg_color: str | None = None
|
||||||
|
bold: bool = False
|
||||||
|
italic: bool = False
|
||||||
|
underline: bool = False
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.char
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
attrs = []
|
||||||
|
if self.fg_color:
|
||||||
|
attrs.append(f"fg={self.fg_color}")
|
||||||
|
if self.bg_color:
|
||||||
|
attrs.append(f"bg={self.bg_color}")
|
||||||
|
if self.bold:
|
||||||
|
attrs.append("bold")
|
||||||
|
if self.italic:
|
||||||
|
attrs.append("italic")
|
||||||
|
if self.underline:
|
||||||
|
attrs.append("underline")
|
||||||
|
|
||||||
|
attr_str = f"[{', '.join(attrs)}]" if attrs else ""
|
||||||
|
return f"CanvasChar('{self.char}'{attr_str})"
|
||||||
4
src/snakia/core/tui/render/__init__.py
Normal file
4
src/snakia/core/tui/render/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from .ansi import ANSIRenderer
|
||||||
|
from .plain_text import PlainTextRenderer
|
||||||
|
|
||||||
|
__all__ = ["ANSIRenderer", "PlainTextRenderer"]
|
||||||
75
src/snakia/core/tui/render/ansi.py
Normal file
75
src/snakia/core/tui/render/ansi.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
from snakia.core.tui import Canvas, CanvasChar, Renderer, RenderTarget
|
||||||
|
|
||||||
|
|
||||||
|
class ANSIRenderer(Renderer):
|
||||||
|
def __init__(self, target: RenderTarget) -> None:
|
||||||
|
super().__init__(target)
|
||||||
|
self._current_char = CanvasChar()
|
||||||
|
|
||||||
|
def render(self, canvas: Canvas) -> None:
|
||||||
|
for y in range(canvas.height):
|
||||||
|
for x in range(canvas.width):
|
||||||
|
char = canvas.get(x, y)
|
||||||
|
self._render_char(char)
|
||||||
|
self.target.write("\n")
|
||||||
|
|
||||||
|
def _render_char(self, char: CanvasChar) -> None:
|
||||||
|
if char != self._current_char:
|
||||||
|
self._reset_attributes()
|
||||||
|
self._apply_attributes(char)
|
||||||
|
self._current_char = char
|
||||||
|
self.target.write(char.char)
|
||||||
|
|
||||||
|
def _reset_attributes(self) -> None:
|
||||||
|
self.target.write("\033[0m")
|
||||||
|
|
||||||
|
def _apply_attributes(self, char: CanvasChar) -> None:
|
||||||
|
codes = []
|
||||||
|
|
||||||
|
if char.bold:
|
||||||
|
codes.append("1")
|
||||||
|
if char.italic:
|
||||||
|
codes.append("3")
|
||||||
|
if char.underline:
|
||||||
|
codes.append("4")
|
||||||
|
|
||||||
|
if char.fg_color:
|
||||||
|
codes.append(f"38;5;{self._color_to_ansi(char.fg_color)}")
|
||||||
|
if char.bg_color:
|
||||||
|
codes.append(f"48;5;{self._color_to_ansi(char.bg_color)}")
|
||||||
|
|
||||||
|
if codes:
|
||||||
|
self.target.write(f"\033[{';'.join(codes)}m")
|
||||||
|
|
||||||
|
def _color_to_ansi(self, color: str) -> int:
|
||||||
|
color_map = {
|
||||||
|
"black": 0,
|
||||||
|
"red": 1,
|
||||||
|
"green": 2,
|
||||||
|
"yellow": 3,
|
||||||
|
"blue": 4,
|
||||||
|
"magenta": 5,
|
||||||
|
"cyan": 6,
|
||||||
|
"white": 7,
|
||||||
|
"bright_black": 8,
|
||||||
|
"bright_red": 9,
|
||||||
|
"bright_green": 10,
|
||||||
|
"bright_yellow": 11,
|
||||||
|
"bright_blue": 12,
|
||||||
|
"bright_magenta": 13,
|
||||||
|
"bright_cyan": 14,
|
||||||
|
"bright_white": 15,
|
||||||
|
}
|
||||||
|
return color_map.get(color.lower(), 7)
|
||||||
|
|
||||||
|
def clear_screen(self) -> None:
|
||||||
|
self.target.write("\033[2J")
|
||||||
|
|
||||||
|
def hide_cursor(self) -> None:
|
||||||
|
self.target.write("\033[?25l")
|
||||||
|
|
||||||
|
def show_cursor(self) -> None:
|
||||||
|
self.target.write("\033[?25h")
|
||||||
|
|
||||||
|
def set_cursor_position(self, x: int, y: int) -> None:
|
||||||
|
self.target.write(f"\033[{y + 1};{x + 1}H")
|
||||||
22
src/snakia/core/tui/render/plain_text.py
Normal file
22
src/snakia/core/tui/render/plain_text.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from snakia.core.tui import Canvas, Renderer
|
||||||
|
|
||||||
|
|
||||||
|
class PlainTextRenderer(Renderer):
|
||||||
|
def render(self, canvas: Canvas) -> None:
|
||||||
|
for y in range(canvas.height):
|
||||||
|
for x in range(canvas.width):
|
||||||
|
char = canvas.get(x, y)
|
||||||
|
self.target.write(char.char)
|
||||||
|
self.target.write("\n")
|
||||||
|
|
||||||
|
def clear_screen(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def hide_cursor(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def show_cursor(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_cursor_position(self, x: int, y: int) -> None:
|
||||||
|
pass
|
||||||
59
src/snakia/core/tui/renderer.py
Normal file
59
src/snakia/core/tui/renderer.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from .canvas import Canvas
|
||||||
|
|
||||||
|
|
||||||
|
class RenderTarget(Protocol):
|
||||||
|
def write(self, text: str) -> None: ...
|
||||||
|
|
||||||
|
def flush(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class Renderer(ABC):
|
||||||
|
def __init__(self, target: RenderTarget) -> None:
|
||||||
|
self.target = target
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def render(self, canvas: Canvas) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def clear_screen(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def hide_cursor(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def show_cursor(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_cursor_position(self, x: int, y: int) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RenderContext:
|
||||||
|
def __init__(self, renderer: Renderer) -> None:
|
||||||
|
self.renderer = renderer
|
||||||
|
self._cursor_visible = True
|
||||||
|
|
||||||
|
def __enter__(self) -> RenderContext:
|
||||||
|
self.renderer.hide_cursor()
|
||||||
|
self.renderer.clear_screen()
|
||||||
|
self._cursor_visible = False
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||||
|
if not self._cursor_visible:
|
||||||
|
self.renderer.show_cursor()
|
||||||
|
self.renderer.target.flush()
|
||||||
|
|
||||||
|
def render(self, canvas: Canvas) -> None:
|
||||||
|
self.renderer.set_cursor_position(0, 0)
|
||||||
|
self.renderer.render(canvas)
|
||||||
|
self.renderer.target.flush()
|
||||||
36
src/snakia/core/tui/widget.py
Normal file
36
src/snakia/core/tui/widget.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Final, final
|
||||||
|
|
||||||
|
from snakia.core.rx import AsyncBindable, Bindable
|
||||||
|
from snakia.utils import to_async
|
||||||
|
|
||||||
|
from .canvas import Canvas
|
||||||
|
|
||||||
|
|
||||||
|
class Widget(ABC):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.dirty: Final = Bindable(True)
|
||||||
|
self.__cache: Canvas = Canvas(0, 0)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def on_render(self) -> Canvas: ...
|
||||||
|
|
||||||
|
@final
|
||||||
|
def render(self) -> Canvas:
|
||||||
|
if self.dirty.value:
|
||||||
|
result = self.on_render()
|
||||||
|
self.__cache = result
|
||||||
|
self.dirty.set(False)
|
||||||
|
return self.__cache
|
||||||
|
|
||||||
|
@final
|
||||||
|
def state[T](self, default_value: T) -> Bindable[T]:
|
||||||
|
field = Bindable(default_value)
|
||||||
|
field.subscribe(lambda _: self.dirty.set(True))
|
||||||
|
return field
|
||||||
|
|
||||||
|
@final
|
||||||
|
def async_state[T](self, default_value: T) -> AsyncBindable[T]:
|
||||||
|
field = AsyncBindable(default_value)
|
||||||
|
field.subscribe(to_async(lambda _: self.dirty.set(True)))
|
||||||
|
return field
|
||||||
13
src/snakia/core/tui/widgets/__init__.py
Normal file
13
src/snakia/core/tui/widgets/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from .box import BoxWidget
|
||||||
|
from .container import ContainerWidget
|
||||||
|
from .horizontal_split import HorizontalSplitWidget
|
||||||
|
from .text import TextWidget
|
||||||
|
from .vertical_split import VerticalSplitWidget
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ContainerWidget",
|
||||||
|
"TextWidget",
|
||||||
|
"BoxWidget",
|
||||||
|
"HorizontalSplitWidget",
|
||||||
|
"VerticalSplitWidget",
|
||||||
|
]
|
||||||
21
src/snakia/core/tui/widgets/box.py
Normal file
21
src/snakia/core/tui/widgets/box.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
from snakia.core.tui import Widget
|
||||||
|
from snakia.core.tui.canvas import Canvas
|
||||||
|
from snakia.core.tui.char import CanvasChar
|
||||||
|
|
||||||
|
|
||||||
|
class BoxWidget(Widget):
|
||||||
|
def __init__(
|
||||||
|
self, width: int, height: int, char: CanvasChar = CanvasChar("█")
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.width = self.state(width)
|
||||||
|
self.height = self.state(height)
|
||||||
|
self.char = self.state(char)
|
||||||
|
|
||||||
|
def on_render(self) -> Canvas:
|
||||||
|
width = self.width.value
|
||||||
|
height = self.height.value
|
||||||
|
char = self.char.value
|
||||||
|
canvas = Canvas(width, height, CanvasChar())
|
||||||
|
canvas.draw_filled_rect(0, 0, width, height, char)
|
||||||
|
return canvas
|
||||||
9
src/snakia/core/tui/widgets/container.py
Normal file
9
src/snakia/core/tui/widgets/container.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
from typing import Final, Iterable
|
||||||
|
|
||||||
|
from snakia.core.tui import Widget
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerWidget(Widget):
|
||||||
|
def __init__(self, children: Iterable[Widget]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.children: Final = self.state([*children])
|
||||||
43
src/snakia/core/tui/widgets/horizontal_split.py
Normal file
43
src/snakia/core/tui/widgets/horizontal_split.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from snakia.core.tui import Widget
|
||||||
|
from snakia.core.tui.canvas import Canvas
|
||||||
|
from snakia.core.tui.char import CanvasChar
|
||||||
|
|
||||||
|
from .container import ContainerWidget
|
||||||
|
|
||||||
|
|
||||||
|
class HorizontalSplitWidget(ContainerWidget):
|
||||||
|
def __init__(
|
||||||
|
self, children: Iterable[Widget], splitter_char: str = "|"
|
||||||
|
) -> None:
|
||||||
|
super().__init__(children)
|
||||||
|
self.splitter_char = splitter_char
|
||||||
|
|
||||||
|
def on_render(self) -> Canvas:
|
||||||
|
children_list = self.children.value
|
||||||
|
if not children_list:
|
||||||
|
return Canvas(0, 0, CanvasChar())
|
||||||
|
|
||||||
|
child_canvases = [child.render() for child in children_list]
|
||||||
|
total_width = (
|
||||||
|
sum(canvas.width for canvas in child_canvases)
|
||||||
|
+ len(child_canvases)
|
||||||
|
- 1
|
||||||
|
)
|
||||||
|
max_height = max(canvas.height for canvas in child_canvases)
|
||||||
|
|
||||||
|
result = Canvas(total_width, max_height, CanvasChar())
|
||||||
|
|
||||||
|
x_offset = 0
|
||||||
|
for i, canvas in enumerate(child_canvases):
|
||||||
|
result.copy_from(canvas, x_offset, 0)
|
||||||
|
x_offset += canvas.width
|
||||||
|
|
||||||
|
if i < len(child_canvases) - 1:
|
||||||
|
splitter_char = CanvasChar(self.splitter_char)
|
||||||
|
for y in range(max_height):
|
||||||
|
result.set(x_offset, y, splitter_char)
|
||||||
|
x_offset += 1
|
||||||
|
|
||||||
|
return result
|
||||||
17
src/snakia/core/tui/widgets/text.py
Normal file
17
src/snakia/core/tui/widgets/text.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from snakia.core.tui import Widget
|
||||||
|
from snakia.core.tui.canvas import Canvas
|
||||||
|
from snakia.core.tui.char import CanvasChar
|
||||||
|
|
||||||
|
|
||||||
|
class TextWidget(Widget):
|
||||||
|
def __init__(self, text: str, char: CanvasChar = CanvasChar()) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.text = self.state(text)
|
||||||
|
self.char = self.state(char)
|
||||||
|
|
||||||
|
def on_render(self) -> Canvas:
|
||||||
|
text = self.text.value
|
||||||
|
char = self.char.value
|
||||||
|
canvas = Canvas(len(text), 1, CanvasChar())
|
||||||
|
canvas.draw_text(0, 0, text, char)
|
||||||
|
return canvas
|
||||||
43
src/snakia/core/tui/widgets/vertical_split.py
Normal file
43
src/snakia/core/tui/widgets/vertical_split.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from snakia.core.tui import Widget
|
||||||
|
from snakia.core.tui.canvas import Canvas
|
||||||
|
from snakia.core.tui.char import CanvasChar
|
||||||
|
|
||||||
|
from .container import ContainerWidget
|
||||||
|
|
||||||
|
|
||||||
|
class VerticalSplitWidget(ContainerWidget):
|
||||||
|
def __init__(
|
||||||
|
self, children: Iterable[Widget], splitter_char: str = "-"
|
||||||
|
) -> None:
|
||||||
|
super().__init__(children)
|
||||||
|
self.splitter_char = splitter_char
|
||||||
|
|
||||||
|
def on_render(self) -> Canvas:
|
||||||
|
children_list = self.children.value
|
||||||
|
if not children_list:
|
||||||
|
return Canvas(0, 0, CanvasChar())
|
||||||
|
|
||||||
|
child_canvases = [child.render() for child in children_list]
|
||||||
|
max_width = max(canvas.width for canvas in child_canvases)
|
||||||
|
total_height = (
|
||||||
|
sum(canvas.height for canvas in child_canvases)
|
||||||
|
+ len(child_canvases)
|
||||||
|
- 1
|
||||||
|
)
|
||||||
|
|
||||||
|
result = Canvas(max_width, total_height, CanvasChar())
|
||||||
|
|
||||||
|
y_offset = 0
|
||||||
|
for i, canvas in enumerate(child_canvases):
|
||||||
|
result.copy_from(canvas, 0, y_offset)
|
||||||
|
y_offset += canvas.height
|
||||||
|
|
||||||
|
if i < len(child_canvases) - 1:
|
||||||
|
splitter_char = CanvasChar(self.splitter_char)
|
||||||
|
for x in range(max_width):
|
||||||
|
result.set(x, y_offset, splitter_char)
|
||||||
|
y_offset += 1
|
||||||
|
|
||||||
|
return result
|
||||||
18
src/snakia/decorators/__init__.py
Normal file
18
src/snakia/decorators/__init__.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
from .inject_after import after_hook, inject_after
|
||||||
|
from .inject_before import before_hook, inject_before
|
||||||
|
from .inject_const import inject_const
|
||||||
|
from .inject_replace import inject_replace, replace_hook
|
||||||
|
from .pass_exceptions import pass_exceptions
|
||||||
|
from .singleton import singleton
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"inject_replace",
|
||||||
|
"replace_hook",
|
||||||
|
"inject_after",
|
||||||
|
"after_hook",
|
||||||
|
"inject_before",
|
||||||
|
"before_hook",
|
||||||
|
"inject_const",
|
||||||
|
"pass_exceptions",
|
||||||
|
"singleton",
|
||||||
|
]
|
||||||
22
src/snakia/decorators/inject_after.py
Normal file
22
src/snakia/decorators/inject_after.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from .inject_replace import inject_replace
|
||||||
|
|
||||||
|
|
||||||
|
def inject_after[T: object, **P, R](
|
||||||
|
obj: T, target: Callable[P, R], hook: Callable[[R], R]
|
||||||
|
) -> T:
|
||||||
|
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
|
return hook(target(*args, **kwargs))
|
||||||
|
|
||||||
|
return inject_replace(obj, target, inner)
|
||||||
|
|
||||||
|
|
||||||
|
def after_hook[**P, R](
|
||||||
|
obj: object, target: Callable[P, R]
|
||||||
|
) -> Callable[[Callable[[R], R]], Callable[[R], R]]:
|
||||||
|
def hook(new: Callable[[R], R]) -> Callable[[R], R]:
|
||||||
|
inject_after(obj, target, new)
|
||||||
|
return new
|
||||||
|
|
||||||
|
return hook
|
||||||
24
src/snakia/decorators/inject_before.py
Normal file
24
src/snakia/decorators/inject_before.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from .inject_replace import inject_replace
|
||||||
|
|
||||||
|
|
||||||
|
def inject_before[T: object, **P, R](
|
||||||
|
obj: T, target: Callable[P, R], hook: Callable[P, Any]
|
||||||
|
) -> T:
|
||||||
|
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
|
hook(*args, **kwargs)
|
||||||
|
return target(*args, **kwargs)
|
||||||
|
|
||||||
|
return inject_replace(obj, target, inner)
|
||||||
|
|
||||||
|
|
||||||
|
def before_hook[**P, R](
|
||||||
|
obj: object, target: Callable[P, R]
|
||||||
|
) -> Callable[[Callable[P, Any]], Callable[P, Any]]:
|
||||||
|
|
||||||
|
def hook(new: Callable[P, Any]) -> Callable[P, Any]:
|
||||||
|
inject_before(obj, target, new)
|
||||||
|
return new
|
||||||
|
|
||||||
|
return hook
|
||||||
47
src/snakia/decorators/inject_const.py
Normal file
47
src/snakia/decorators/inject_const.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import sys
|
||||||
|
from types import FunctionType
|
||||||
|
from typing import Any, Callable, cast
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 13):
|
||||||
|
|
||||||
|
def inject_const[T: Callable[..., Any]](**consts: Any) -> Callable[[T], T]:
|
||||||
|
def inner(func: T) -> T:
|
||||||
|
values = [*func.__code__.co_consts]
|
||||||
|
for i, name in enumerate(func.__code__.co_varnames):
|
||||||
|
if name in consts:
|
||||||
|
values[i + 1] = consts[name]
|
||||||
|
return cast(
|
||||||
|
T,
|
||||||
|
FunctionType(
|
||||||
|
code=func.__code__.replace(co_consts=(*values,)),
|
||||||
|
globals=func.__globals__,
|
||||||
|
name=func.__name__,
|
||||||
|
argdefs=func.__defaults__,
|
||||||
|
closure=func.__closure__,
|
||||||
|
kwdefaults=func.__kwdefaults__,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
def inject_const[T: Callable[..., Any]](**consts: Any) -> Callable[[T], T]:
|
||||||
|
def inner(func: T) -> T:
|
||||||
|
values = [*func.__code__.co_consts]
|
||||||
|
for i, name in enumerate(func.__code__.co_varnames):
|
||||||
|
if name in consts:
|
||||||
|
values[i + 1] = consts[name]
|
||||||
|
return cast(
|
||||||
|
T,
|
||||||
|
FunctionType(
|
||||||
|
code=func.__code__.replace(co_consts=(*values,)),
|
||||||
|
globals=func.__globals__,
|
||||||
|
name=func.__name__,
|
||||||
|
argdefs=func.__defaults__,
|
||||||
|
closure=func.__closure__,
|
||||||
|
# kwdefaults=func.__kwdefaults__,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return inner
|
||||||
20
src/snakia/decorators/inject_replace.py
Normal file
20
src/snakia/decorators/inject_replace.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
def inject_replace[T: object, **P, R](
|
||||||
|
obj: T, old: Callable[P, R], new: Callable[P, R]
|
||||||
|
) -> T:
|
||||||
|
for k, v in obj.__dict__.items():
|
||||||
|
if v is old:
|
||||||
|
setattr(obj, k, new)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def replace_hook[**P, R](
|
||||||
|
obj: object, old: Callable[P, R]
|
||||||
|
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||||
|
def hook(new: Callable[P, R]) -> Callable[P, R]:
|
||||||
|
inject_replace(obj, old, new)
|
||||||
|
return new
|
||||||
|
|
||||||
|
return hook
|
||||||
32
src/snakia/decorators/pass_exceptions.py
Normal file
32
src/snakia/decorators/pass_exceptions.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, overload
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def pass_exceptions[**P](
|
||||||
|
*errors: type[Exception],
|
||||||
|
) -> Callable[[Callable[P, Any | None]], Callable[P, Any | None]]: ...
|
||||||
|
@overload
|
||||||
|
def pass_exceptions[**P, R](
|
||||||
|
*errors: type[Exception],
|
||||||
|
default: R,
|
||||||
|
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
||||||
|
def pass_exceptions(
|
||||||
|
*errors: type[Exception],
|
||||||
|
default: Any = None,
|
||||||
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||||
|
|
||||||
|
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
|
|
||||||
|
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except Exception as e: # noqa: W0718 # pylint: disable=W0718
|
||||||
|
if type(e) not in errors:
|
||||||
|
raise e from Exception()
|
||||||
|
return default
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
2
src/snakia/decorators/singleton.py
Normal file
2
src/snakia/decorators/singleton.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
def singleton[T](cls: type[T]) -> T:
|
||||||
|
return cls()
|
||||||
15
src/snakia/field/__init__.py
Normal file
15
src/snakia/field/__init__.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
from .auto import AutoField
|
||||||
|
from .bool import BoolField
|
||||||
|
from .field import Field
|
||||||
|
from .float import FloatField
|
||||||
|
from .int import IntField
|
||||||
|
from .str import StrField
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Field",
|
||||||
|
"AutoField",
|
||||||
|
"BoolField",
|
||||||
|
"FloatField",
|
||||||
|
"IntField",
|
||||||
|
"StrField",
|
||||||
|
]
|
||||||
25
src/snakia/field/auto.py
Normal file
25
src/snakia/field/auto.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import pickle
|
||||||
|
from typing import Final, override
|
||||||
|
|
||||||
|
from .field import Field
|
||||||
|
|
||||||
|
|
||||||
|
class AutoField[T](Field[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
|
||||||
|
|
||||||
|
@override
|
||||||
|
def serialize(self, value: T, /) -> bytes:
|
||||||
|
return pickle.dumps(value)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def deserialize(self, serialized: bytes, /) -> T:
|
||||||
|
value = pickle.loads(serialized)
|
||||||
|
if not isinstance(value, self.__target_type or object):
|
||||||
|
return self.default_value
|
||||||
|
return value # type: ignore
|
||||||
13
src/snakia/field/bool.py
Normal file
13
src/snakia/field/bool.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from typing import override
|
||||||
|
|
||||||
|
from .field import Field
|
||||||
|
|
||||||
|
|
||||||
|
class BoolField(Field[bool]):
|
||||||
|
@override
|
||||||
|
def serialize(self, value: bool, /) -> bytes:
|
||||||
|
return b"\x01" if value else b"\x00"
|
||||||
|
|
||||||
|
@override
|
||||||
|
def deserialize(self, serialized: bytes, /) -> bool:
|
||||||
|
return serialized == b"\x01"
|
||||||
49
src/snakia/field/field.py
Normal file
49
src/snakia/field/field.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable, Final, final
|
||||||
|
|
||||||
|
from snakia.property.priv_property import PrivProperty
|
||||||
|
from snakia.utils import inherit
|
||||||
|
|
||||||
|
|
||||||
|
class Field[T: Any](ABC, PrivProperty[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
|
||||||
|
|
||||||
|
:param value: value to serialize
|
||||||
|
:type value: T
|
||||||
|
:return: serialized value
|
||||||
|
:rtype: bytes
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def deserialize(self, serialized: bytes, /) -> T:
|
||||||
|
"""Deserialize a value
|
||||||
|
|
||||||
|
:param serialized: serialized value
|
||||||
|
:type serialized: bytes
|
||||||
|
:return: deserialized value
|
||||||
|
:rtype: T
|
||||||
|
"""
|
||||||
|
|
||||||
|
@final
|
||||||
|
@classmethod
|
||||||
|
def custom[R](
|
||||||
|
cls: type[Field[Any]],
|
||||||
|
serialize: Callable[[R], str],
|
||||||
|
deserialize: Callable[[str], R],
|
||||||
|
) -> type[Field[R]]:
|
||||||
|
return inherit(
|
||||||
|
cls, {"serialize": serialize, "deserialize": deserialize}
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def type(cls) -> type[T]: ...
|
||||||
14
src/snakia/field/float.py
Normal file
14
src/snakia/field/float.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import struct
|
||||||
|
from typing import override
|
||||||
|
|
||||||
|
from .field import Field
|
||||||
|
|
||||||
|
|
||||||
|
class FloatField(Field[float]):
|
||||||
|
@override
|
||||||
|
def serialize(self, value: float, /) -> bytes:
|
||||||
|
return struct.pack(">f", value)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def deserialize(self, serialized: bytes, /) -> float:
|
||||||
|
return struct.unpack(">f", serialized)[0] # type: ignore
|
||||||
14
src/snakia/field/int.py
Normal file
14
src/snakia/field/int.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
from typing import override
|
||||||
|
|
||||||
|
from .field import Field
|
||||||
|
|
||||||
|
|
||||||
|
class IntField(Field[int]):
|
||||||
|
@override
|
||||||
|
def serialize(self, value: int, /) -> bytes:
|
||||||
|
length = (value.bit_length() + 7) // 8
|
||||||
|
return value.to_bytes(length, "little")
|
||||||
|
|
||||||
|
@override
|
||||||
|
def deserialize(self, serialized: bytes, /) -> int:
|
||||||
|
return int.from_bytes(serialized, "little")
|
||||||
17
src/snakia/field/str.py
Normal file
17
src/snakia/field/str.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from typing import Final, override
|
||||||
|
|
||||||
|
from .field import Field
|
||||||
|
|
||||||
|
|
||||||
|
class StrField(Field[str]):
|
||||||
|
def __init__(self, default_value: str, *, encoding: str = "utf-8") -> None:
|
||||||
|
super().__init__(default_value)
|
||||||
|
self.encoding: Final = encoding
|
||||||
|
|
||||||
|
@override
|
||||||
|
def serialize(self, value: str, /) -> bytes:
|
||||||
|
return value.encode(self.encoding)
|
||||||
|
|
||||||
|
@override
|
||||||
|
def deserialize(self, serialized: bytes, /) -> str:
|
||||||
|
return serialized.decode(self.encoding)
|
||||||
16
src/snakia/field/t.py
Normal file
16
src/snakia/field/t.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# noqa: W0622 # pylint: disable=W0622
|
||||||
|
from .auto import AutoField as auto
|
||||||
|
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 .str import StrField as str
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"auto",
|
||||||
|
"bool",
|
||||||
|
"field",
|
||||||
|
"float",
|
||||||
|
"int",
|
||||||
|
"str",
|
||||||
|
]
|
||||||
20
src/snakia/platform/__init__.py
Normal file
20
src/snakia/platform/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
from .android import AndroidLayer
|
||||||
|
from .freebsd import FreebsdLayer
|
||||||
|
from .ios import IosLayer
|
||||||
|
from .layer import PlatformLayer
|
||||||
|
from .linux import LinuxLayer
|
||||||
|
from .macos import MacosLayer
|
||||||
|
from .os import OS, PlatformOS
|
||||||
|
from .windows import WindowsLayer
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"PlatformOS",
|
||||||
|
"OS",
|
||||||
|
"PlatformLayer",
|
||||||
|
"AndroidLayer",
|
||||||
|
"FreebsdLayer",
|
||||||
|
"IosLayer",
|
||||||
|
"LinuxLayer",
|
||||||
|
"MacosLayer",
|
||||||
|
"WindowsLayer",
|
||||||
|
)
|
||||||
53
src/snakia/platform/android.py
Normal file
53
src/snakia/platform/android.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ctypes import CDLL, Array, c_char, c_char_p, create_string_buffer
|
||||||
|
from typing import Any, Final, Literal, cast, overload
|
||||||
|
|
||||||
|
from .layer import PlatformLayer
|
||||||
|
from .os import PlatformOS
|
||||||
|
|
||||||
|
|
||||||
|
class AndroidLayer(PlatformLayer[Literal[PlatformOS.ANDROID]]):
|
||||||
|
target = PlatformOS.ANDROID
|
||||||
|
|
||||||
|
PROP_VALUE_MAX: Final = 92
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_prop(self, name: str) -> str | None: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def get_prop[T](self, name: str, default: T) -> str | T: ...
|
||||||
|
|
||||||
|
def get_prop(self, name: str, default: Any = None) -> Any:
|
||||||
|
buffer = create_string_buffer(self.PROP_VALUE_MAX)
|
||||||
|
length = self.system_property_get(name.encode("UTF-8"), buffer)
|
||||||
|
if length == 0:
|
||||||
|
return default
|
||||||
|
return buffer.value.decode("UTF-8", "backslashreplace")
|
||||||
|
|
||||||
|
def system_property_get(self, name: bytes, default: Array[c_char]) -> int:
|
||||||
|
func = getattr(CDLL("libc.so"), "__system_property_get")
|
||||||
|
func.argtypes = (c_char_p, c_char_p)
|
||||||
|
result = cast(int, func(name, default))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def release(self, default: str = "") -> str:
|
||||||
|
return self.get_prop("ro.build.version.release", default)
|
||||||
|
|
||||||
|
def api_level(self, default: int) -> int:
|
||||||
|
return int(self.get_prop("ro.build.version.sdk", default))
|
||||||
|
|
||||||
|
def manufacturer(self, default: str = "") -> str:
|
||||||
|
return self.get_prop("ro.product.manufacturer", default)
|
||||||
|
|
||||||
|
def model(self, default: str = "") -> str:
|
||||||
|
return self.get_prop("ro.product.model", default)
|
||||||
|
|
||||||
|
def device(self, default: str = "") -> str:
|
||||||
|
return self.get_prop("ro.product.device", default)
|
||||||
|
|
||||||
|
def is_emulator(self, default: bool) -> bool:
|
||||||
|
prop = self.get_prop("ro.kernel.qemu", None)
|
||||||
|
if prop is None:
|
||||||
|
return default
|
||||||
|
return prop == "1"
|
||||||
11
src/snakia/platform/freebsd.py
Normal file
11
src/snakia/platform/freebsd.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from .layer import PlatformLayer
|
||||||
|
from .os import PlatformOS
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: create a freebds layer
|
||||||
|
class FreebsdLayer(PlatformLayer[Literal[PlatformOS.FREEBSD]]):
|
||||||
|
target = PlatformOS.FREEBSD
|
||||||
11
src/snakia/platform/ios.py
Normal file
11
src/snakia/platform/ios.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from .layer import PlatformLayer
|
||||||
|
from .os import PlatformOS
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: create a ios layer
|
||||||
|
class IosLayer(PlatformLayer[Literal[PlatformOS.IOS]]):
|
||||||
|
target = PlatformOS.IOS
|
||||||
28
src/snakia/platform/layer.py
Normal file
28
src/snakia/platform/layer.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import ClassVar, Self, final, overload
|
||||||
|
|
||||||
|
from .os import PlatformOS
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformLayer[T: PlatformOS]:
|
||||||
|
target: ClassVar[PlatformOS] = PlatformOS.UNKNOWN
|
||||||
|
|
||||||
|
@final
|
||||||
|
def __init__(self, platform: PlatformOS) -> None:
|
||||||
|
if platform != self.target:
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"{self.__class__.__name__} is not implemented for {platform._name_}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
def try_get(cls, platform: T, /) -> Self: ...
|
||||||
|
@overload
|
||||||
|
@classmethod
|
||||||
|
def try_get(cls, platform: PlatformOS, /) -> Self | None: ...
|
||||||
|
@classmethod
|
||||||
|
def try_get(cls, platform: PlatformOS, /) -> Self | None:
|
||||||
|
if platform == cls.target:
|
||||||
|
return cls(platform)
|
||||||
|
return None
|
||||||
57
src/snakia/platform/linux.py
Normal file
57
src/snakia/platform/linux.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from .layer import PlatformLayer
|
||||||
|
from .os import PlatformOS
|
||||||
|
|
||||||
|
|
||||||
|
class LinuxLayer(PlatformLayer[Literal[PlatformOS.LINUX]]):
|
||||||
|
target = PlatformOS.LINUX
|
||||||
|
|
||||||
|
def os_release_raw(self) -> str:
|
||||||
|
"""Read /etc/os-release or /usr/lib/os-release"""
|
||||||
|
try:
|
||||||
|
return open("/etc/os-release", encoding="utf-8").read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return open("/usr/lib/os-release", encoding="utf-8").read()
|
||||||
|
|
||||||
|
def os_release(self) -> dict[str, str]:
|
||||||
|
"""Parse `os_release_raw` and return a dict"""
|
||||||
|
raw = self.os_release_raw()
|
||||||
|
info = {
|
||||||
|
"ID": "linux",
|
||||||
|
}
|
||||||
|
os_release_line = re.compile(
|
||||||
|
"^(?P<name>[a-zA-Z0-9_]+)=(?P<quote>[\"']?)(?P<value>.*)(?P=quote)$"
|
||||||
|
)
|
||||||
|
os_release_unescape = re.compile(r"\\([\\\$\"\'`])")
|
||||||
|
|
||||||
|
for line in raw.split("\n"):
|
||||||
|
mo = os_release_line.match(line)
|
||||||
|
if mo is not None:
|
||||||
|
info[mo.group("name")] = os_release_unescape.sub(
|
||||||
|
r"\1", mo.group("value")
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
|
def distro_name(self) -> str:
|
||||||
|
"""Return the distro name."""
|
||||||
|
return self.os_release().get("name", "linux")
|
||||||
|
|
||||||
|
def distro_pretty_name(self) -> str:
|
||||||
|
"""Return the distro pretty name."""
|
||||||
|
return self.os_release().get("PRETTY_NAME", "Linux")
|
||||||
|
|
||||||
|
def distro_id(self) -> str:
|
||||||
|
"""Return the distro id."""
|
||||||
|
return self.os_release().get("ID", "linux")
|
||||||
|
|
||||||
|
def version(self) -> str:
|
||||||
|
"""Return the distro version."""
|
||||||
|
return self.os_release().get("VERSION_ID", "0")
|
||||||
|
|
||||||
|
def codename(self) -> str:
|
||||||
|
"""Return the distro codename."""
|
||||||
|
return self.os_release().get("VERSION_CODENAME", "unknown")
|
||||||
11
src/snakia/platform/macos.py
Normal file
11
src/snakia/platform/macos.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from .layer import PlatformLayer
|
||||||
|
from .os import PlatformOS
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: create a macos layer
|
||||||
|
class MacosLayer(PlatformLayer[Literal[PlatformOS.MACOS]]):
|
||||||
|
target = PlatformOS.MACOS
|
||||||
52
src/snakia/platform/os.py
Normal file
52
src/snakia/platform/os.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from enum import IntEnum
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformOS(IntEnum):
|
||||||
|
UNKNOWN = 0
|
||||||
|
ANDROID = 1
|
||||||
|
FREEBSD = 2
|
||||||
|
IOS = 3
|
||||||
|
LINUX = 4
|
||||||
|
MACOS = 5
|
||||||
|
WINDOWS = 6
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_apple(self) -> bool:
|
||||||
|
"""MacOS, iOS"""
|
||||||
|
return self in [PlatformOS.MACOS, PlatformOS.IOS]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_linux(self) -> bool:
|
||||||
|
"""Linux, Android"""
|
||||||
|
return self in [PlatformOS.LINUX, PlatformOS.ANDROID]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def resolve(cls) -> PlatformOS:
|
||||||
|
"""Get the current platform."""
|
||||||
|
platform = sys.platform
|
||||||
|
result = PlatformOS.UNKNOWN
|
||||||
|
|
||||||
|
if platform in ["win32", "win16", "dos", "cygwin", "msys"]:
|
||||||
|
result = PlatformOS.WINDOWS
|
||||||
|
if platform.startswith("linux"):
|
||||||
|
result = PlatformOS.LINUX
|
||||||
|
if platform.startswith("freebsd"):
|
||||||
|
result = PlatformOS.FREEBSD
|
||||||
|
if platform == "darwin":
|
||||||
|
result = PlatformOS.MACOS
|
||||||
|
if platform == "ios":
|
||||||
|
result = PlatformOS.IOS
|
||||||
|
if platform == "android":
|
||||||
|
result = PlatformOS.ANDROID
|
||||||
|
if platform.startswith("java"):
|
||||||
|
result = PlatformOS.UNKNOWN
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
OS: Final[PlatformOS] = PlatformOS.resolve()
|
||||||
|
"""The current platform."""
|
||||||
11
src/snakia/platform/windows.py
Normal file
11
src/snakia/platform/windows.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from .layer import PlatformLayer
|
||||||
|
from .os import PlatformOS
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: create a windows layer
|
||||||
|
class WindowsLayer(PlatformLayer[Literal[PlatformOS.WINDOWS]]):
|
||||||
|
target = PlatformOS.WINDOWS
|
||||||
19
src/snakia/property/__init__.py
Normal file
19
src/snakia/property/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from .cell_property import CellProperty
|
||||||
|
from .classproperty import ClassProperty
|
||||||
|
from .hook_property import HookProperty
|
||||||
|
from .initonly import Initonly, initonly
|
||||||
|
from .priv_property import PrivProperty
|
||||||
|
from .property import Property
|
||||||
|
from .readonly import Readonly, readonly
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CellProperty",
|
||||||
|
"ClassProperty",
|
||||||
|
"HookProperty",
|
||||||
|
"Initonly",
|
||||||
|
"initonly",
|
||||||
|
"PrivProperty",
|
||||||
|
"Property",
|
||||||
|
"Readonly",
|
||||||
|
"readonly",
|
||||||
|
]
|
||||||
67
src/snakia/property/cell_property.py
Normal file
67
src/snakia/property/cell_property.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
from typing import Any, Callable, Self
|
||||||
|
|
||||||
|
from snakia.types import empty
|
||||||
|
|
||||||
|
type _Cell[T] = T | None
|
||||||
|
type _Getter[T] = Callable[[Any, _Cell[T]], T]
|
||||||
|
type _Setter[T] = Callable[[Any, _Cell[T], T], _Cell[T]]
|
||||||
|
type _Deleter[T] = Callable[[Any, _Cell[T]], _Cell[T]]
|
||||||
|
|
||||||
|
|
||||||
|
class CellProperty[T]:
|
||||||
|
"""
|
||||||
|
A property that uses a cell to store its value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("__name", "__fget", "__fset", "__fdel")
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
fget: _Getter[T],
|
||||||
|
fset: _Setter[T] = empty.func,
|
||||||
|
fdel: _Deleter[T] = empty.func,
|
||||||
|
) -> None:
|
||||||
|
self.__fget: _Getter[T] = fget
|
||||||
|
self.__fset: _Setter[T] = fset
|
||||||
|
self.__fdel: _Deleter[T] = fdel
|
||||||
|
self.__name = ""
|
||||||
|
|
||||||
|
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:
|
||||||
|
cell = self.__fget(instance, self.__get_cell(instance))
|
||||||
|
self.__set_cell(instance, cell)
|
||||||
|
return cell
|
||||||
|
|
||||||
|
def __set__(self, instance: Any, value: T, /) -> None:
|
||||||
|
cell = self.__fset(instance, self.__get_cell(instance), value)
|
||||||
|
self.__set_cell(instance, cell)
|
||||||
|
|
||||||
|
def __delete__(self, instance: Any, /) -> None:
|
||||||
|
cell = self.__fdel(instance, self.__get_cell(instance))
|
||||||
|
self.__set_cell(instance, cell)
|
||||||
|
|
||||||
|
def getter(self, fget: _Getter[T], /) -> Self:
|
||||||
|
"""Descriptor getter."""
|
||||||
|
self.__fget = fget
|
||||||
|
return self
|
||||||
|
|
||||||
|
def setter(self, fset: _Setter[T], /) -> Self:
|
||||||
|
"""Descriptor setter."""
|
||||||
|
self.__fset = fset
|
||||||
|
return self
|
||||||
|
|
||||||
|
def deleter(self, fdel: _Deleter[T], /) -> Self:
|
||||||
|
"""Descriptor deleter."""
|
||||||
|
self.__fdel = fdel
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __get_cell(self, instance: Any) -> T | None:
|
||||||
|
return getattr(instance, self.__name, None)
|
||||||
|
|
||||||
|
def __set_cell(self, instance: Any, value: T | None) -> None:
|
||||||
|
if value is None:
|
||||||
|
delattr(instance, self.__name)
|
||||||
|
else:
|
||||||
|
setattr(instance, self.__name, value)
|
||||||
64
src/snakia/property/classproperty.py
Normal file
64
src/snakia/property/classproperty.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
from typing import Any, Callable, Self
|
||||||
|
|
||||||
|
from snakia.types import empty
|
||||||
|
|
||||||
|
|
||||||
|
class ClassProperty[T]:
|
||||||
|
"""
|
||||||
|
Class property
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("__fget", "__fset", "__fdel")
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
fget: Callable[[Any], T],
|
||||||
|
fset: Callable[[Any, T], None] = empty.func,
|
||||||
|
fdel: Callable[[Any], None] = empty.func,
|
||||||
|
) -> None:
|
||||||
|
self.__fget = fget
|
||||||
|
self.__fset = fset
|
||||||
|
self.__fdel = fdel
|
||||||
|
|
||||||
|
def __get__(self, _: Any, owner: type | None = None, /) -> T:
|
||||||
|
return self.__fget(owner)
|
||||||
|
|
||||||
|
def __set__(self, instance: Any | None, value: T, /) -> None:
|
||||||
|
owner = type(instance) if instance else instance
|
||||||
|
return self.__fset(owner, value)
|
||||||
|
|
||||||
|
def __delete__(self, instance: Any | None, /) -> None:
|
||||||
|
owner = type(instance) if instance else instance
|
||||||
|
return self.__fdel(owner)
|
||||||
|
|
||||||
|
def getter(self, fget: Callable[[Any], T], /) -> Self:
|
||||||
|
"""Descriptor getter."""
|
||||||
|
self.__fget = fget
|
||||||
|
return self
|
||||||
|
|
||||||
|
def setter(self, fset: Callable[[Any, T], None], /) -> Self:
|
||||||
|
"""Descriptor setter."""
|
||||||
|
self.__fset = fset
|
||||||
|
return self
|
||||||
|
|
||||||
|
def deleter(self, fdel: Callable[[Any], None], /) -> Self:
|
||||||
|
"""Descriptor deleter."""
|
||||||
|
self.__fdel = fdel
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def classproperty[T](
|
||||||
|
fget: Callable[[Any], T] = empty.func,
|
||||||
|
fset: Callable[[Any, T], None] = empty.func,
|
||||||
|
fdel: Callable[[Any], None] = empty.func,
|
||||||
|
) -> ClassProperty[T]:
|
||||||
|
"""Create a class property.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fget (Callable[[Any], T], optional): The getter function. Defaults to empty.func.
|
||||||
|
fset (Callable[[Any, T], None], optional): The setter function. Defaults to empty.func.
|
||||||
|
fdel (Callable[[Any], None], optional): The deleter function. Defaults to empty.func.
|
||||||
|
Returns:
|
||||||
|
ClassProperty[T]: The class property.
|
||||||
|
"""
|
||||||
|
return ClassProperty(fget, fset, fdel)
|
||||||
53
src/snakia/property/hook_property.py
Normal file
53
src/snakia/property/hook_property.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
from typing import Any, Callable, Self
|
||||||
|
|
||||||
|
from snakia.types import empty
|
||||||
|
|
||||||
|
from .priv_property import PrivProperty
|
||||||
|
|
||||||
|
|
||||||
|
class HookProperty[T](PrivProperty[T]):
|
||||||
|
"""
|
||||||
|
A property that calls a function when the property is set, get, or deleted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("__on_set", "__on_get", "__on_del")
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
on_get: Callable[[T], None],
|
||||||
|
on_set: Callable[[T], None] = empty.func,
|
||||||
|
on_del: Callable[[T], None] = empty.func,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.__on_set: Callable[[T], None] = on_set
|
||||||
|
self.__on_get: Callable[[T], None] = on_get
|
||||||
|
self.__on_del: Callable[[T], None] = on_del
|
||||||
|
|
||||||
|
def __get__(self, instance: Any, owner: type | None = None, /) -> T:
|
||||||
|
value = super().__get__(instance, owner)
|
||||||
|
self.__on_get(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def __set__(self, instance: Any, value: T, /) -> None:
|
||||||
|
self.__on_set(value)
|
||||||
|
return super().__set__(instance, value)
|
||||||
|
|
||||||
|
def __delete__(self, instance: Any, /) -> None:
|
||||||
|
value = super().__get__(instance)
|
||||||
|
self.__on_del(value)
|
||||||
|
return super().__delete__(instance)
|
||||||
|
|
||||||
|
def getter(self, on_get: Callable[[T], None], /) -> Self:
|
||||||
|
"""Descriptor getter."""
|
||||||
|
self.__on_get = on_get
|
||||||
|
return self
|
||||||
|
|
||||||
|
def setter(self, on_set: Callable[[T], None], /) -> Self:
|
||||||
|
"""Descriptor setter."""
|
||||||
|
self.__on_set = on_set
|
||||||
|
return self
|
||||||
|
|
||||||
|
def deleter(self, on_del: Callable[[T], None], /) -> Self:
|
||||||
|
"""Descriptor deleter."""
|
||||||
|
self.__on_del = on_del
|
||||||
|
return self
|
||||||
17
src/snakia/property/initonly.py
Normal file
17
src/snakia/property/initonly.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .priv_property import PrivProperty
|
||||||
|
|
||||||
|
|
||||||
|
class Initonly[T](PrivProperty[T]):
|
||||||
|
"""Property that can only be set once."""
|
||||||
|
|
||||||
|
def __set__(self, instance: Any, value: T, /) -> None:
|
||||||
|
if hasattr(instance, self.name):
|
||||||
|
return
|
||||||
|
super().__set__(instance, value)
|
||||||
|
|
||||||
|
|
||||||
|
def initonly() -> Initonly[Any]:
|
||||||
|
"""Factory for `Initonly`."""
|
||||||
|
return Initonly()
|
||||||
29
src/snakia/property/priv_property.py
Normal file
29
src/snakia/property/priv_property.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class PrivProperty[T]:
|
||||||
|
__slots__ = "__name", "__default_value"
|
||||||
|
|
||||||
|
__name: str
|
||||||
|
|
||||||
|
def __init__(self, default_value: T | None = None) -> None:
|
||||||
|
self.__default_value: T | None = 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)
|
||||||
|
return getattr(instance, self.__name)
|
||||||
|
|
||||||
|
def __set__(self, instance: Any, value: T, /) -> None:
|
||||||
|
setattr(instance, self.__name, value)
|
||||||
|
|
||||||
|
def __delete__(self, instance: Any, /) -> None:
|
||||||
|
delattr(instance, self.__name)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Return the name of the variable associated with the property."""
|
||||||
|
return self.__name
|
||||||
42
src/snakia/property/property.py
Normal file
42
src/snakia/property/property.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
from typing import Any, Callable, Self
|
||||||
|
|
||||||
|
from snakia.types import empty
|
||||||
|
|
||||||
|
|
||||||
|
class Property[T]:
|
||||||
|
"""
|
||||||
|
A property that can be set, get, and deleted."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
fget: Callable[[Any], T] = empty.func,
|
||||||
|
fset: Callable[[Any, T], None] = empty.func,
|
||||||
|
fdel: Callable[[Any], None] = empty.func,
|
||||||
|
) -> None:
|
||||||
|
self.__fget = fget
|
||||||
|
self.__fset = fset
|
||||||
|
self.__fdel = fdel
|
||||||
|
|
||||||
|
def __get__(self, instance: Any, owner: type | None = None, /) -> T:
|
||||||
|
return self.__fget(instance)
|
||||||
|
|
||||||
|
def __set__(self, instance: Any, value: T, /) -> None:
|
||||||
|
return self.__fset(instance, value)
|
||||||
|
|
||||||
|
def __delete__(self, instance: Any, /) -> None:
|
||||||
|
return self.__fdel(instance)
|
||||||
|
|
||||||
|
def getter(self, fget: Callable[[Any], T], /) -> Self:
|
||||||
|
"""Descriptor getter."""
|
||||||
|
self.__fget = fget
|
||||||
|
return self
|
||||||
|
|
||||||
|
def setter(self, fset: Callable[[Any, T], None], /) -> Self:
|
||||||
|
"""Descriptor setter."""
|
||||||
|
self.__fset = fset
|
||||||
|
return self
|
||||||
|
|
||||||
|
def deleter(self, fdel: Callable[[Any], None], /) -> Self:
|
||||||
|
"""Descriptor deleter."""
|
||||||
|
self.__fdel = fdel
|
||||||
|
return self
|
||||||
33
src/snakia/property/readonly.py
Normal file
33
src/snakia/property/readonly.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
class Readonly[T]:
|
||||||
|
"""
|
||||||
|
Readonly property.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("__fget",)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
fget: Callable[[Any], T],
|
||||||
|
) -> None:
|
||||||
|
self.__fget = fget
|
||||||
|
|
||||||
|
def __get__(self, instance: Any, owner: type | None = None, /) -> T:
|
||||||
|
return self.__fget(instance)
|
||||||
|
|
||||||
|
def __set__(self, instance: Any, value: T, /) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def readonly[T](value: T) -> Readonly[T]:
|
||||||
|
"""Create a readonly property with the given value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (T): The value to set the readonly property to.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Readonly[T]: The readonly property.
|
||||||
|
"""
|
||||||
|
return Readonly(lambda _: value)
|
||||||
5
src/snakia/random/__init__.py
Normal file
5
src/snakia/random/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from .os import OSRandom
|
||||||
|
from .python import PythonRandom
|
||||||
|
from .random import Random
|
||||||
|
|
||||||
|
__all__ = ["OSRandom", "PythonRandom", "Random"]
|
||||||
18
src/snakia/random/os.py
Normal file
18
src/snakia/random/os.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .random import Random
|
||||||
|
|
||||||
|
|
||||||
|
class OSRandom(Random[None]):
|
||||||
|
"""
|
||||||
|
A random number generator that uses the OS (cryptographically secure) to generate random bytes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def bits(self, k: int) -> int:
|
||||||
|
return int.from_bytes(os.urandom((k + 7) // 8)) & ((1 << k) - 1)
|
||||||
|
|
||||||
|
def get_state(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_state(self, value: None) -> None:
|
||||||
|
pass
|
||||||
16
src/snakia/random/python.py
Normal file
16
src/snakia/random/python.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import random
|
||||||
|
|
||||||
|
from .random import Random
|
||||||
|
|
||||||
|
type _State = tuple[int, tuple[int, ...], int | float | None]
|
||||||
|
|
||||||
|
|
||||||
|
class PythonRandom(Random[_State]):
|
||||||
|
def bits(self, k: int) -> int:
|
||||||
|
return random.getrandbits(k)
|
||||||
|
|
||||||
|
def get_state(self) -> _State:
|
||||||
|
return random.getstate()
|
||||||
|
|
||||||
|
def set_state(self, value: _State) -> None:
|
||||||
|
random.setstate(value)
|
||||||
58
src/snakia/random/random.py
Normal file
58
src/snakia/random/random.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import builtins
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, MutableSequence, Sequence, final
|
||||||
|
|
||||||
|
|
||||||
|
class Random[S](ABC):
|
||||||
|
"""
|
||||||
|
A random number generator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def bits(self, k: builtins.int) -> builtins.int:
|
||||||
|
"""Return k random bits."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_state(self, value: S) -> None:
|
||||||
|
"""Set the state of the random number generator."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_state(self) -> S:
|
||||||
|
"""Get the state of the random number generator."""
|
||||||
|
|
||||||
|
@final
|
||||||
|
def bytes(self, n: builtins.int) -> bytes:
|
||||||
|
"""Return n random bytes."""
|
||||||
|
return self.bits(n * 8).to_bytes(n, "little")
|
||||||
|
|
||||||
|
@final
|
||||||
|
def below(self, n: builtins.int) -> builtins.int:
|
||||||
|
"""Return a random int in the range [0,n). Defined for n > 0."""
|
||||||
|
k = n.bit_length()
|
||||||
|
while True:
|
||||||
|
x = self.bits(k)
|
||||||
|
if x < n:
|
||||||
|
return x
|
||||||
|
|
||||||
|
@final
|
||||||
|
def int(self, start: builtins.int, end: builtins.int) -> builtins.int:
|
||||||
|
"""Return a random int in the range [start, end]."""
|
||||||
|
return self.below(end + 1 - start) + start
|
||||||
|
|
||||||
|
@final
|
||||||
|
def float(self) -> float:
|
||||||
|
"""Return a random float in the range [0.0, 1.0)."""
|
||||||
|
return self.bits(32) / (1 << 32)
|
||||||
|
|
||||||
|
@final
|
||||||
|
def choice[T](self, seq: Sequence[T]) -> T:
|
||||||
|
"""Return a random element from a non-empty sequence."""
|
||||||
|
return seq[self.below(len(seq))]
|
||||||
|
|
||||||
|
@final
|
||||||
|
def shuffle[T: MutableSequence[Any]](self, seq: T) -> T:
|
||||||
|
"""Shuffle a sequence in place."""
|
||||||
|
for i in range(len(seq) - 1, 0, -1):
|
||||||
|
j = self.below(i + 1)
|
||||||
|
seq[i], seq[j] = seq[j], seq[i]
|
||||||
|
return seq
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue