commit 19c9b9537de3f796ee52e0ae594bc7e2402fdb87 Author: rus07tam Date: Sun Oct 26 16:26:46 2025 +0000 initial commit diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..8392d15 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake \ No newline at end of file diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml new file mode 100644 index 0000000..fae2a4f --- /dev/null +++ b/.github/workflows/docs-publish.yml @@ -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 diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml new file mode 100644 index 0000000..b7ad910 --- /dev/null +++ b/.github/workflows/pylint.yml @@ -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 diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml new file mode 100644 index 0000000..9e948a6 --- /dev/null +++ b/.github/workflows/pypi-publish.yml @@ -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/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9c190e --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2a7a91 --- /dev/null +++ b/README.md @@ -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 `` +``` + +### 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. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -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) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -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 diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 0000000..62ccb1c --- /dev/null +++ b/docs/source/api.rst @@ -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 \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..314c382 --- /dev/null +++ b/docs/source/conf.py @@ -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"] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..a366656 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,15 @@ +Welcome to Snakia! +================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + api + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` \ No newline at end of file diff --git a/examples/health_plugin.py b/examples/health_plugin.py new file mode 100644 index 0000000..a795d33 --- /dev/null +++ b/examples/health_plugin.py @@ -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() diff --git a/examples/tui.py b/examples/tui.py new file mode 100644 index 0000000..7e64611 --- /dev/null +++ b/examples/tui.py @@ -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() diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..942a6fb --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..541e3f5 --- /dev/null +++ b/flake.nix @@ -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 + ]; + }; + } + ); +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..13fdbbc --- /dev/null +++ b/pyproject.toml @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a56e901 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/src/snakia/__init__.py b/src/snakia/__init__.py new file mode 100644 index 0000000..0365c25 --- /dev/null +++ b/src/snakia/__init__.py @@ -0,0 +1,3 @@ +""" +Snakia framework +""" diff --git a/src/snakia/core/__init__.py b/src/snakia/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/snakia/core/ecs/__init__.py b/src/snakia/core/ecs/__init__.py new file mode 100644 index 0000000..caee3c2 --- /dev/null +++ b/src/snakia/core/ecs/__init__.py @@ -0,0 +1,5 @@ +from .component import Component +from .processor import Processor +from .system import System + +__all__ = ["Processor", "Component", "System"] diff --git a/src/snakia/core/ecs/component.py b/src/snakia/core/ecs/component.py new file mode 100644 index 0000000..a4e237b --- /dev/null +++ b/src/snakia/core/ecs/component.py @@ -0,0 +1,7 @@ +from abc import ABC + +from pydantic import BaseModel + + +class Component(ABC, BaseModel): + pass diff --git a/src/snakia/core/ecs/processor.py b/src/snakia/core/ecs/processor.py new file mode 100644 index 0000000..98373e1 --- /dev/null +++ b/src/snakia/core/ecs/processor.py @@ -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. + """ diff --git a/src/snakia/core/ecs/system.py b/src/snakia/core/ecs/system.py new file mode 100644 index 0000000..e023c72 --- /dev/null +++ b/src/snakia/core/ecs/system.py @@ -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 diff --git a/src/snakia/core/engine.py b/src/snakia/core/engine.py new file mode 100644 index 0000000..78c8120 --- /dev/null +++ b/src/snakia/core/engine.py @@ -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() diff --git a/src/snakia/core/es/__init__.py b/src/snakia/core/es/__init__.py new file mode 100644 index 0000000..cf9c7af --- /dev/null +++ b/src/snakia/core/es/__init__.py @@ -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", +) diff --git a/src/snakia/core/es/action.py b/src/snakia/core/es/action.py new file mode 100644 index 0000000..0f805a1 --- /dev/null +++ b/src/snakia/core/es/action.py @@ -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) diff --git a/src/snakia/core/es/dispatcher.py b/src/snakia/core/es/dispatcher.py new file mode 100644 index 0000000..9874f41 --- /dev/null +++ b/src/snakia/core/es/dispatcher.py @@ -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() diff --git a/src/snakia/core/es/event.py b/src/snakia/core/es/event.py new file mode 100644 index 0000000..5c65a64 --- /dev/null +++ b/src/snakia/core/es/event.py @@ -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 diff --git a/src/snakia/core/es/filter.py b/src/snakia/core/es/filter.py new file mode 100644 index 0000000..41ea6e7 --- /dev/null +++ b/src/snakia/core/es/filter.py @@ -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: ... diff --git a/src/snakia/core/es/handler.py b/src/snakia/core/es/handler.py new file mode 100644 index 0000000..6cdb1ba --- /dev/null +++ b/src/snakia/core/es/handler.py @@ -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]: ... diff --git a/src/snakia/core/es/subscriber.py b/src/snakia/core/es/subscriber.py new file mode 100644 index 0000000..6bc9aad --- /dev/null +++ b/src/snakia/core/es/subscriber.py @@ -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 diff --git a/src/snakia/core/except_manager.py b/src/snakia/core/except_manager.py new file mode 100644 index 0000000..a74d571 --- /dev/null +++ b/src/snakia/core/except_manager.py @@ -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() diff --git a/src/snakia/core/loader/__init__.py b/src/snakia/core/loader/__init__.py new file mode 100644 index 0000000..9e099c5 --- /dev/null +++ b/src/snakia/core/loader/__init__.py @@ -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"] diff --git a/src/snakia/core/loader/loadable.py b/src/snakia/core/loader/loadable.py new file mode 100644 index 0000000..51e75d0 --- /dev/null +++ b/src/snakia/core/loader/loadable.py @@ -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: ... diff --git a/src/snakia/core/loader/loader.py b/src/snakia/core/loader/loader.py new file mode 100644 index 0000000..280322d --- /dev/null +++ b/src/snakia/core/loader/loader.py @@ -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() diff --git a/src/snakia/core/loader/meta.py b/src/snakia/core/loader/meta.py new file mode 100644 index 0000000..f0b66d3 --- /dev/null +++ b/src/snakia/core/loader/meta.py @@ -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}" diff --git a/src/snakia/core/loader/plugin.py b/src/snakia/core/loader/plugin.py new file mode 100644 index 0000000..f8abfa5 --- /dev/null +++ b/src/snakia/core/loader/plugin.py @@ -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 diff --git a/src/snakia/core/loader/plugin_processor.py b/src/snakia/core/loader/plugin_processor.py new file mode 100644 index 0000000..f959327 --- /dev/null +++ b/src/snakia/core/loader/plugin_processor.py @@ -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 diff --git a/src/snakia/core/rx/__init__.py b/src/snakia/core/rx/__init__.py new file mode 100644 index 0000000..995ffa5 --- /dev/null +++ b/src/snakia/core/rx/__init__.py @@ -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", +] diff --git a/src/snakia/core/rx/async_bindable.py b/src/snakia/core/rx/async_bindable.py new file mode 100644 index 0000000..178ba18 --- /dev/null +++ b/src/snakia/core/rx/async_bindable.py @@ -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 diff --git a/src/snakia/core/rx/base_bindable.py b/src/snakia/core/rx/base_bindable.py new file mode 100644 index 0000000..58c557d --- /dev/null +++ b/src/snakia/core/rx/base_bindable.py @@ -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 diff --git a/src/snakia/core/rx/bindable.py b/src/snakia/core/rx/bindable.py new file mode 100644 index 0000000..be66139 --- /dev/null +++ b/src/snakia/core/rx/bindable.py @@ -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 diff --git a/src/snakia/core/rx/chain.py b/src/snakia/core/rx/chain.py new file mode 100644 index 0000000..81ce12a --- /dev/null +++ b/src/snakia/core/rx/chain.py @@ -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 diff --git a/src/snakia/core/rx/combine.py b/src/snakia/core/rx/combine.py new file mode 100644 index 0000000..0cc5b1a --- /dev/null +++ b/src/snakia/core/rx/combine.py @@ -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 diff --git a/src/snakia/core/rx/concat.py b/src/snakia/core/rx/concat.py new file mode 100644 index 0000000..d96cec1 --- /dev/null +++ b/src/snakia/core/rx/concat.py @@ -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 diff --git a/src/snakia/core/rx/const.py b/src/snakia/core/rx/const.py new file mode 100644 index 0000000..f0781d9 --- /dev/null +++ b/src/snakia/core/rx/const.py @@ -0,0 +1,5 @@ +from typing import Callable + + +def const[T](value: T) -> Callable[[], T]: + return lambda: value diff --git a/src/snakia/core/rx/filter.py b/src/snakia/core/rx/filter.py new file mode 100644 index 0000000..f7ab74c --- /dev/null +++ b/src/snakia/core/rx/filter.py @@ -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) diff --git a/src/snakia/core/rx/map.py b/src/snakia/core/rx/map.py new file mode 100644 index 0000000..86d7bf1 --- /dev/null +++ b/src/snakia/core/rx/map.py @@ -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) diff --git a/src/snakia/core/rx/merge.py b/src/snakia/core/rx/merge.py new file mode 100644 index 0000000..fb5118e --- /dev/null +++ b/src/snakia/core/rx/merge.py @@ -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 diff --git a/src/snakia/core/tui/__init__.py b/src/snakia/core/tui/__init__.py new file mode 100644 index 0000000..61f6bf3 --- /dev/null +++ b/src/snakia/core/tui/__init__.py @@ -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", +] diff --git a/src/snakia/core/tui/canvas.py b/src/snakia/core/tui/canvas.py new file mode 100644 index 0000000..c44a99a --- /dev/null +++ b/src/snakia/core/tui/canvas.py @@ -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 diff --git a/src/snakia/core/tui/char.py b/src/snakia/core/tui/char.py new file mode 100644 index 0000000..bc4e302 --- /dev/null +++ b/src/snakia/core/tui/char.py @@ -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})" diff --git a/src/snakia/core/tui/render/__init__.py b/src/snakia/core/tui/render/__init__.py new file mode 100644 index 0000000..a35600a --- /dev/null +++ b/src/snakia/core/tui/render/__init__.py @@ -0,0 +1,4 @@ +from .ansi import ANSIRenderer +from .plain_text import PlainTextRenderer + +__all__ = ["ANSIRenderer", "PlainTextRenderer"] diff --git a/src/snakia/core/tui/render/ansi.py b/src/snakia/core/tui/render/ansi.py new file mode 100644 index 0000000..38b2eed --- /dev/null +++ b/src/snakia/core/tui/render/ansi.py @@ -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") diff --git a/src/snakia/core/tui/render/plain_text.py b/src/snakia/core/tui/render/plain_text.py new file mode 100644 index 0000000..a526175 --- /dev/null +++ b/src/snakia/core/tui/render/plain_text.py @@ -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 diff --git a/src/snakia/core/tui/renderer.py b/src/snakia/core/tui/renderer.py new file mode 100644 index 0000000..d64a554 --- /dev/null +++ b/src/snakia/core/tui/renderer.py @@ -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() diff --git a/src/snakia/core/tui/widget.py b/src/snakia/core/tui/widget.py new file mode 100644 index 0000000..b64a263 --- /dev/null +++ b/src/snakia/core/tui/widget.py @@ -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 diff --git a/src/snakia/core/tui/widgets/__init__.py b/src/snakia/core/tui/widgets/__init__.py new file mode 100644 index 0000000..ed4c46e --- /dev/null +++ b/src/snakia/core/tui/widgets/__init__.py @@ -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", +] diff --git a/src/snakia/core/tui/widgets/box.py b/src/snakia/core/tui/widgets/box.py new file mode 100644 index 0000000..7456616 --- /dev/null +++ b/src/snakia/core/tui/widgets/box.py @@ -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 diff --git a/src/snakia/core/tui/widgets/container.py b/src/snakia/core/tui/widgets/container.py new file mode 100644 index 0000000..c8bcb4c --- /dev/null +++ b/src/snakia/core/tui/widgets/container.py @@ -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]) diff --git a/src/snakia/core/tui/widgets/horizontal_split.py b/src/snakia/core/tui/widgets/horizontal_split.py new file mode 100644 index 0000000..a267625 --- /dev/null +++ b/src/snakia/core/tui/widgets/horizontal_split.py @@ -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 diff --git a/src/snakia/core/tui/widgets/text.py b/src/snakia/core/tui/widgets/text.py new file mode 100644 index 0000000..cae2d0f --- /dev/null +++ b/src/snakia/core/tui/widgets/text.py @@ -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 diff --git a/src/snakia/core/tui/widgets/vertical_split.py b/src/snakia/core/tui/widgets/vertical_split.py new file mode 100644 index 0000000..5cdf08a --- /dev/null +++ b/src/snakia/core/tui/widgets/vertical_split.py @@ -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 diff --git a/src/snakia/decorators/__init__.py b/src/snakia/decorators/__init__.py new file mode 100644 index 0000000..afce779 --- /dev/null +++ b/src/snakia/decorators/__init__.py @@ -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", +] diff --git a/src/snakia/decorators/inject_after.py b/src/snakia/decorators/inject_after.py new file mode 100644 index 0000000..c62974f --- /dev/null +++ b/src/snakia/decorators/inject_after.py @@ -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 diff --git a/src/snakia/decorators/inject_before.py b/src/snakia/decorators/inject_before.py new file mode 100644 index 0000000..8f30dc1 --- /dev/null +++ b/src/snakia/decorators/inject_before.py @@ -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 diff --git a/src/snakia/decorators/inject_const.py b/src/snakia/decorators/inject_const.py new file mode 100644 index 0000000..d6610d8 --- /dev/null +++ b/src/snakia/decorators/inject_const.py @@ -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 diff --git a/src/snakia/decorators/inject_replace.py b/src/snakia/decorators/inject_replace.py new file mode 100644 index 0000000..d85e445 --- /dev/null +++ b/src/snakia/decorators/inject_replace.py @@ -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 diff --git a/src/snakia/decorators/pass_exceptions.py b/src/snakia/decorators/pass_exceptions.py new file mode 100644 index 0000000..cd31a89 --- /dev/null +++ b/src/snakia/decorators/pass_exceptions.py @@ -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 diff --git a/src/snakia/decorators/singleton.py b/src/snakia/decorators/singleton.py new file mode 100644 index 0000000..5a1b21a --- /dev/null +++ b/src/snakia/decorators/singleton.py @@ -0,0 +1,2 @@ +def singleton[T](cls: type[T]) -> T: + return cls() diff --git a/src/snakia/field/__init__.py b/src/snakia/field/__init__.py new file mode 100644 index 0000000..654fdd9 --- /dev/null +++ b/src/snakia/field/__init__.py @@ -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", +] diff --git a/src/snakia/field/auto.py b/src/snakia/field/auto.py new file mode 100644 index 0000000..2ed3945 --- /dev/null +++ b/src/snakia/field/auto.py @@ -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 diff --git a/src/snakia/field/bool.py b/src/snakia/field/bool.py new file mode 100644 index 0000000..1cad3f8 --- /dev/null +++ b/src/snakia/field/bool.py @@ -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" diff --git a/src/snakia/field/field.py b/src/snakia/field/field.py new file mode 100644 index 0000000..c59c8ae --- /dev/null +++ b/src/snakia/field/field.py @@ -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]: ... diff --git a/src/snakia/field/float.py b/src/snakia/field/float.py new file mode 100644 index 0000000..10f852a --- /dev/null +++ b/src/snakia/field/float.py @@ -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 diff --git a/src/snakia/field/int.py b/src/snakia/field/int.py new file mode 100644 index 0000000..ca268aa --- /dev/null +++ b/src/snakia/field/int.py @@ -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") diff --git a/src/snakia/field/str.py b/src/snakia/field/str.py new file mode 100644 index 0000000..1bdacfb --- /dev/null +++ b/src/snakia/field/str.py @@ -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) diff --git a/src/snakia/field/t.py b/src/snakia/field/t.py new file mode 100644 index 0000000..f535738 --- /dev/null +++ b/src/snakia/field/t.py @@ -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", +] diff --git a/src/snakia/platform/__init__.py b/src/snakia/platform/__init__.py new file mode 100644 index 0000000..81e0672 --- /dev/null +++ b/src/snakia/platform/__init__.py @@ -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", +) diff --git a/src/snakia/platform/android.py b/src/snakia/platform/android.py new file mode 100644 index 0000000..ea9621f --- /dev/null +++ b/src/snakia/platform/android.py @@ -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" diff --git a/src/snakia/platform/freebsd.py b/src/snakia/platform/freebsd.py new file mode 100644 index 0000000..e2ef860 --- /dev/null +++ b/src/snakia/platform/freebsd.py @@ -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 diff --git a/src/snakia/platform/ios.py b/src/snakia/platform/ios.py new file mode 100644 index 0000000..f29ae61 --- /dev/null +++ b/src/snakia/platform/ios.py @@ -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 diff --git a/src/snakia/platform/layer.py b/src/snakia/platform/layer.py new file mode 100644 index 0000000..8420b43 --- /dev/null +++ b/src/snakia/platform/layer.py @@ -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 diff --git a/src/snakia/platform/linux.py b/src/snakia/platform/linux.py new file mode 100644 index 0000000..7c17af2 --- /dev/null +++ b/src/snakia/platform/linux.py @@ -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[a-zA-Z0-9_]+)=(?P[\"']?)(?P.*)(?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") diff --git a/src/snakia/platform/macos.py b/src/snakia/platform/macos.py new file mode 100644 index 0000000..9a398c4 --- /dev/null +++ b/src/snakia/platform/macos.py @@ -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 diff --git a/src/snakia/platform/os.py b/src/snakia/platform/os.py new file mode 100644 index 0000000..e94dbd8 --- /dev/null +++ b/src/snakia/platform/os.py @@ -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.""" diff --git a/src/snakia/platform/windows.py b/src/snakia/platform/windows.py new file mode 100644 index 0000000..6bc9896 --- /dev/null +++ b/src/snakia/platform/windows.py @@ -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 diff --git a/src/snakia/property/__init__.py b/src/snakia/property/__init__.py new file mode 100644 index 0000000..91e4ec1 --- /dev/null +++ b/src/snakia/property/__init__.py @@ -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", +] diff --git a/src/snakia/property/cell_property.py b/src/snakia/property/cell_property.py new file mode 100644 index 0000000..84a0924 --- /dev/null +++ b/src/snakia/property/cell_property.py @@ -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) diff --git a/src/snakia/property/classproperty.py b/src/snakia/property/classproperty.py new file mode 100644 index 0000000..48691ed --- /dev/null +++ b/src/snakia/property/classproperty.py @@ -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) diff --git a/src/snakia/property/hook_property.py b/src/snakia/property/hook_property.py new file mode 100644 index 0000000..c6fa676 --- /dev/null +++ b/src/snakia/property/hook_property.py @@ -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 diff --git a/src/snakia/property/initonly.py b/src/snakia/property/initonly.py new file mode 100644 index 0000000..2193b4f --- /dev/null +++ b/src/snakia/property/initonly.py @@ -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() diff --git a/src/snakia/property/priv_property.py b/src/snakia/property/priv_property.py new file mode 100644 index 0000000..4203503 --- /dev/null +++ b/src/snakia/property/priv_property.py @@ -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 diff --git a/src/snakia/property/property.py b/src/snakia/property/property.py new file mode 100644 index 0000000..97e8b29 --- /dev/null +++ b/src/snakia/property/property.py @@ -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 diff --git a/src/snakia/property/readonly.py b/src/snakia/property/readonly.py new file mode 100644 index 0000000..0f6f2cf --- /dev/null +++ b/src/snakia/property/readonly.py @@ -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) diff --git a/src/snakia/random/__init__.py b/src/snakia/random/__init__.py new file mode 100644 index 0000000..fd91824 --- /dev/null +++ b/src/snakia/random/__init__.py @@ -0,0 +1,5 @@ +from .os import OSRandom +from .python import PythonRandom +from .random import Random + +__all__ = ["OSRandom", "PythonRandom", "Random"] diff --git a/src/snakia/random/os.py b/src/snakia/random/os.py new file mode 100644 index 0000000..558afd2 --- /dev/null +++ b/src/snakia/random/os.py @@ -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 diff --git a/src/snakia/random/python.py b/src/snakia/random/python.py new file mode 100644 index 0000000..0a680d2 --- /dev/null +++ b/src/snakia/random/python.py @@ -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) diff --git a/src/snakia/random/random.py b/src/snakia/random/random.py new file mode 100644 index 0000000..5fcbec1 --- /dev/null +++ b/src/snakia/random/random.py @@ -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 diff --git a/src/snakia/types/__init__.py b/src/snakia/types/__init__.py new file mode 100644 index 0000000..e93cfd2 --- /dev/null +++ b/src/snakia/types/__init__.py @@ -0,0 +1,17 @@ +"""Utility types""" + +from . import empty +from .color import Color +from .unique import Unique, UniqueType, unique +from .unset import Unset +from .version import Version + +__all__ = [ + "Color", + "Version", + "UniqueType", + "Unique", + "unique", + "Unset", + "empty", +] diff --git a/src/snakia/types/color.py b/src/snakia/types/color.py new file mode 100644 index 0000000..2363c76 --- /dev/null +++ b/src/snakia/types/color.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from typing import Final + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class Color(BaseModel): + model_config = ConfigDict(frozen=True) + + r: int = Field(default=0) + g: int = Field(default=0) + b: int = Field(default=0) + a: int = Field(default=0xFF) + + # noqa: E0213 # pylint: disable=E0213 + @field_validator("r", mode="before") + def _r_validator(cls, value: int) -> int: + return value & 0xFF + + # noqa: E0213 # pylint: disable=E0213 + @field_validator("g", mode="before") + def _g_validator(cls, value: int) -> int: + return value & 0xFF + + # noqa: E0213 # pylint: disable=E0213 + @field_validator("b", mode="before") + def _b_validator(cls, value: int) -> int: + return value & 0xFF + + @property + def hex(self) -> str: + """Return the color in hex format.""" + return f"#{self.r:02x}{self.g:02x}{self.b:02x}{self.a:02x}" + + @property + def rgb(self) -> tuple[int, int, int]: + """Return the color in rgb format.""" + return self.r, self.g, self.b + + @property + def rgba(self) -> tuple[int, int, int, int]: + """Return the color in rgba format.""" + return self.r, self.g, self.b, self.a + + @classmethod + def from_hex(cls, hex_: str, /) -> Color: + """ + Create a color from a hex string. + + Args: + hex_: The hex string to create the color from. + + Returns: + The color created from the hex string. + """ + hex_ = hex_.lstrip("#") + return cls( + r=int(hex_[1:3], 16), + g=int(hex_[3:5], 16), + b=int(hex_[5:7], 16), + a=int(hex_[7:9], 16) if len(hex_) >= 9 else 255, + ) + + @classmethod + def from_rgb(cls, r: int, g: int, b: int, a: int = 255) -> Color: + """ + Create a color from rgb values. + + Args: + r (int): The red value. + g (int): The green value. + b (int): The blue value. + a (int): The alpha value. + + Returns: + Color: The color created from the rgb values. + """ + return cls(r=r, g=g, b=b, a=a) + + def __add__(self, other: Color) -> Color: + return Color( + r=self.r + other.r, + g=self.g + other.g, + b=self.b + other.b, + a=self.a + other.a, + ) + + def __sub__(self, other: Color) -> Color: + return Color( + r=self.r - other.r, + g=self.g - other.g, + b=self.b - other.b, + a=self.a - other.a, + ) + + +BLACK: Final[Color] = Color.from_hex("#000000") +WHITE: Final[Color] = Color.from_hex("#ffffff") +RED: Final[Color] = Color.from_hex("#ff0000") +GREEN: Final[Color] = Color.from_hex("#00ff00") +BLUE: Final[Color] = Color.from_hex("#0000ff") +YELLOW: Final[Color] = Color.from_hex("#ffff00") +CYAN: Final[Color] = Color.from_hex("#00ffff") +MAGENTA: Final[Color] = Color.from_hex("#ff00ff") +GRAY: Final[Color] = Color.from_hex("#808080") +DARK_GRAY: Final[Color] = Color.from_hex("#404040") +LIGHT_GRAY: Final[Color] = Color.from_hex("#c0c0c0") +DARK_RED: Final[Color] = Color.from_hex("#800000") +DARK_GREEN: Final[Color] = Color.from_hex("#008000") +DARK_BLUE: Final[Color] = Color.from_hex("#000080") +DARK_YELLOW: Final[Color] = Color.from_hex("#808000") +DARK_CYAN: Final[Color] = Color.from_hex("#008080") +DARK_MAGENTA: Final[Color] = Color.from_hex("#800080") diff --git a/src/snakia/types/empty.py b/src/snakia/types/empty.py new file mode 100644 index 0000000..c3f2957 --- /dev/null +++ b/src/snakia/types/empty.py @@ -0,0 +1,20 @@ +from typing import Any, final + + +def func(*_: Any, **__: Any) -> Any: + """ + A function that does nothing + """ + + +async def async_func(*_: Any, **__: Any) -> Any: + """ + An async function that does nothing + """ + + +@final +class Class: # noqa: R0903 # pylint: disable=R0903 + """ + A class that does nothing + """ diff --git a/src/snakia/types/unique.py b/src/snakia/types/unique.py new file mode 100644 index 0000000..52aaf6f --- /dev/null +++ b/src/snakia/types/unique.py @@ -0,0 +1,46 @@ +from typing import Any, final + + +@final +class UniqueType(type): + """ + A metaclass that prevents multiple instances of a class from being created. + """ + + def __new__( + mcs, + name: str, + bases: tuple[type, ...], + namespace: dict[str, Any], + /, + **kwds: Any, + ) -> type: + t = super().__new__(mcs, name, bases, {}) + setattr(t, "__new__", lambda cls, *args, **kwargs: cls) + return t + + @final + def __init__( + cls, name: str, bases: tuple[type, ...], namespace: dict[str, Any] + ) -> None: + super().__init__(name, bases, namespace) + + def __instancecheck__(cls, instance: Any) -> bool: + return instance is cls + + def __eq__(cls, other: Any) -> bool: + return cls is other + + def __call__[T](cls: type[T]) -> T: + return cls.__new__(cls) # noqa: E1120 # pylint: disable=E1120 + + +class Unique(metaclass=UniqueType): # noqa: R0903 # pylint: disable=R0903 + """ + A class that prevents multiple instances of a class from being created. + """ + + +def unique(name: str) -> UniqueType: + """Factory for creating a unique type.""" + return UniqueType(name, (), {}) diff --git a/src/snakia/types/unset.py b/src/snakia/types/unset.py new file mode 100644 index 0000000..6c04dd8 --- /dev/null +++ b/src/snakia/types/unset.py @@ -0,0 +1,10 @@ +from typing import final + +from .unique import Unique + + +@final +class Unset(Unique): # noqa: R0903 # pylint: disable=R0903 + """ + A class that represents unset. + """ diff --git a/src/snakia/types/version.py b/src/snakia/types/version.py new file mode 100644 index 0000000..30b4e61 --- /dev/null +++ b/src/snakia/types/version.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from typing import Any, Literal, overload + +from pydantic import BaseModel, ConfigDict, Field + + +class Version(BaseModel): + model_config = ConfigDict(frozen=True) + + major: int = Field(default=0, ge=0) + minor: int = Field(default=0, ge=0) + patch: int = Field(default=0, ge=0) + + def __str__(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}" + + def is_compatible(self, other: Version) -> bool: + return self.major == other.major + + def compare(self, other: Version) -> int: + """ + - `-1` if self < other + - `0` if self == other + - `1` if self > other + """ + return ( + (self.major, self.minor, self.patch) + > (other.major, other.minor, other.patch) + ) - ( + (self.major, self.minor, self.patch) + < (other.major, other.minor, other.patch) + ) + + @overload + def __gt__(self, other: Version) -> bool: ... + + @overload + def __gt__(self, other: Any) -> Literal[False]: ... + + def __gt__(self, other: Any) -> bool: + if not isinstance(other, Version): + return False + return self.compare(other) > 0 + + @overload + def __ge__(self, other: Version) -> bool: ... + + @overload + def __ge__(self, other: Any) -> Literal[False]: ... + + def __ge__(self, other: Any) -> bool: + if not isinstance(other, Version): + return False + return self.compare(other) >= 0 + + @overload + def __lt__(self, other: Version) -> bool: ... + + @overload + def __lt__(self, other: Any) -> Literal[False]: ... + + def __lt__(self, other: Any) -> bool: + if not isinstance(other, Version): + return False + return self.compare(other) < 0 + + @overload + def __le__(self, other: Version) -> bool: ... + + @overload + def __le__(self, other: Any) -> Literal[False]: ... + + def __le__(self, other: Any) -> bool: + if not isinstance(other, Version): + return False + return self.compare(other) <= 0 + + @overload + def __eq__(self, other: Version) -> bool: ... + + @overload + def __eq__(self, other: Any) -> Literal[False]: ... + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Version): + return False + return self.compare(other) == 0 + + @overload + def __ne__(self, other: Version) -> bool: ... + + @overload + def __ne__(self, other: Any) -> Literal[False]: ... + + def __ne__(self, other: Any) -> bool: + if not isinstance(other, Version): + return False + return self.compare(other) != 0 + + @classmethod + def from_tuple(cls, version: tuple[int, int, int]) -> Version: + return cls(major=version[0], minor=version[1], patch=version[2]) + + @classmethod + def from_args(cls, major: int, minor: int, patch: int, *_: Any) -> Version: + return cls(major=major, minor=minor, patch=patch) + + @classmethod + def from_string(cls, version: str) -> Version: + return cls.from_args(*map(int, version.split(".")), 0, 0, 0) diff --git a/src/snakia/utils/__init__.py b/src/snakia/utils/__init__.py new file mode 100644 index 0000000..db30b42 --- /dev/null +++ b/src/snakia/utils/__init__.py @@ -0,0 +1,15 @@ +from .frame import frame +from .inherit import inherit +from .nolock import nolock +from .this import this +from .throw import throw +from .to_async import to_async + +__all__ = [ + "frame", + "inherit", + "nolock", + "this", + "throw", + "to_async", +] diff --git a/src/snakia/utils/frame.py b/src/snakia/utils/frame.py new file mode 100644 index 0000000..bc5a27d --- /dev/null +++ b/src/snakia/utils/frame.py @@ -0,0 +1,8 @@ +import sys +from types import FrameType + + +def frame() -> FrameType: + """Get the current frame.""" + # noqa: W0212 # pylint: disable=W0212 + return sys._getframe(1) diff --git a/src/snakia/utils/gil_enabled.py b/src/snakia/utils/gil_enabled.py new file mode 100644 index 0000000..3ba393b --- /dev/null +++ b/src/snakia/utils/gil_enabled.py @@ -0,0 +1,14 @@ +from typing import TYPE_CHECKING, Final + +if TYPE_CHECKING: + GIL_ENABLED: Final[bool] = bool(...) + """ + Whether the GIL is enabled.""" +else: + import sys + + if sys.version_info >= (3, 13): + # noqa: E1101, W0212 # pylint: disable=E1101,W0212 + GIL_ENABLED = sys._is_gil_enabled() + else: + GIL_ENABLED = True diff --git a/src/snakia/utils/inherit.py b/src/snakia/utils/inherit.py new file mode 100644 index 0000000..a971f62 --- /dev/null +++ b/src/snakia/utils/inherit.py @@ -0,0 +1,18 @@ +from typing import Any + + +def inherit[T: type]( + type_: T, attrs: dict[str, Any] | None = None, /, **kwargs: Any +) -> T: + """ + Create a new class that inherits from the given class. + + Args: + type_: The class to inherit from. + attrs: A dictionary of attributes to add to the new class. + **kwargs: Additional attributes to add to the new class. + + Returns: + A new class that inherits from the given class. + """ + return type("", (type_,), attrs or {}, **kwargs) # type: ignore diff --git a/src/snakia/utils/nolock.py b/src/snakia/utils/nolock.py new file mode 100644 index 0000000..39f001c --- /dev/null +++ b/src/snakia/utils/nolock.py @@ -0,0 +1,20 @@ +from typing import TYPE_CHECKING + +from .gil_enabled import GIL_ENABLED + +if TYPE_CHECKING: + + def nolock() -> None: ... + +else: + + if GIL_ENABLED: + import time + + def nolock() -> None: + time.sleep(0.001) + + else: + + def nolock() -> None: + pass diff --git a/src/snakia/utils/this.py b/src/snakia/utils/this.py new file mode 100644 index 0000000..d5dd37b --- /dev/null +++ b/src/snakia/utils/this.py @@ -0,0 +1,18 @@ +import gc +from types import FunctionType, MethodType +from typing import Any + +from .frame import frame + + +def this() -> Any: + """Get the current function.""" + f = frame() + for obj in gc.get_objects(): + if isinstance(obj, FunctionType): + if obj.__code__ is f.f_code: + return obj + elif isinstance(obj, MethodType): + if obj.__func__.__code__ is f.f_code: + return obj + return None diff --git a/src/snakia/utils/throw.py b/src/snakia/utils/throw.py new file mode 100644 index 0000000..b93d8ac --- /dev/null +++ b/src/snakia/utils/throw.py @@ -0,0 +1,29 @@ +from typing import Any, NoReturn, overload + +from snakia.types.unset import Unset + + +@overload +def throw[T: Exception]( + *exceptions: T, # pyright: ignore[reportInvalidTypeVarUse] + from_: Unset | BaseException = Unset(), +) -> NoReturn: ... + + +@overload +def throw( + exception: BaseException, from_: Unset | BaseException = Unset(), / +) -> NoReturn: ... + + +def throw( + *exceptions: Any, from_: Unset | BaseException = Unset() +) -> NoReturn: + """Throw an exception.""" + if isinstance(from_, Unset): + if len(exceptions) == 1: + raise exceptions[0] + raise ExceptionGroup("", exceptions) + if len(exceptions) == 1: + raise exceptions[0] from from_ + raise ExceptionGroup("", exceptions) from from_ diff --git a/src/snakia/utils/to_async.py b/src/snakia/utils/to_async.py new file mode 100644 index 0000000..1866a01 --- /dev/null +++ b/src/snakia/utils/to_async.py @@ -0,0 +1,10 @@ +from typing import Awaitable, Callable + + +def to_async[**P, R](func: Callable[P, R]) -> Callable[P, Awaitable[R]]: + """Convert a sync function to an async function.""" + + async def inner(*args: P.args, **kwargs: P.kwargs) -> R: + return func(*args, **kwargs) + + return inner diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..d894a9f --- /dev/null +++ b/uv.lock @@ -0,0 +1,139 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "networkx" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload-time = "2025-05-29T11:35:07.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/81/d3b3e95929c4369d30b2a66a91db63c8ed0a98381ae55a45da2cd1cc1288/pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887", size = 2099043, upload-time = "2025-10-14T10:20:28.561Z" }, + { url = "https://files.pythonhosted.org/packages/58/da/46fdac49e6717e3a94fc9201403e08d9d61aa7a770fab6190b8740749047/pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2", size = 1910699, upload-time = "2025-10-14T10:20:30.217Z" }, + { url = "https://files.pythonhosted.org/packages/1e/63/4d948f1b9dd8e991a5a98b77dd66c74641f5f2e5225fee37994b2e07d391/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999", size = 1952121, upload-time = "2025-10-14T10:20:32.246Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a7/e5fc60a6f781fc634ecaa9ecc3c20171d238794cef69ae0af79ac11b89d7/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4", size = 2041590, upload-time = "2025-10-14T10:20:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/70/69/dce747b1d21d59e85af433428978a1893c6f8a7068fa2bb4a927fba7a5ff/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f", size = 2219869, upload-time = "2025-10-14T10:20:35.965Z" }, + { url = "https://files.pythonhosted.org/packages/83/6a/c070e30e295403bf29c4df1cb781317b6a9bac7cd07b8d3acc94d501a63c/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b", size = 2345169, upload-time = "2025-10-14T10:20:37.627Z" }, + { url = "https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47", size = 2070165, upload-time = "2025-10-14T10:20:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/14/0a/e567c2883588dd12bcbc110232d892cf385356f7c8a9910311ac997ab715/pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970", size = 2189067, upload-time = "2025-10-14T10:20:41.015Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1d/3d9fca34273ba03c9b1c5289f7618bc4bd09c3ad2289b5420481aa051a99/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed", size = 2132997, upload-time = "2025-10-14T10:20:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/52/70/d702ef7a6cd41a8afc61f3554922b3ed8d19dd54c3bd4bdbfe332e610827/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8", size = 2307187, upload-time = "2025-10-14T10:20:44.849Z" }, + { url = "https://files.pythonhosted.org/packages/68/4c/c06be6e27545d08b802127914156f38d10ca287a9e8489342793de8aae3c/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431", size = 2305204, upload-time = "2025-10-14T10:20:46.781Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e5/35ae4919bcd9f18603419e23c5eaf32750224a89d41a8df1a3704b69f77e/pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd", size = 1972536, upload-time = "2025-10-14T10:20:48.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/49c5bb6d2a49eb2ee3647a93e3dae7080c6409a8a7558b075027644e879c/pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff", size = 2031132, upload-time = "2025-10-14T10:20:50.421Z" }, + { url = "https://files.pythonhosted.org/packages/06/23/936343dbcba6eec93f73e95eb346810fc732f71ba27967b287b66f7b7097/pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8", size = 1969483, upload-time = "2025-10-14T10:20:52.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" }, + { url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" }, + { url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" }, + { url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" }, + { url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" }, + { url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" }, + { url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" }, + { url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" }, + { url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" }, + { url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" }, + { url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" }, + { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" }, + { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" }, + { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, + { url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087, upload-time = "2025-10-14T10:22:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387, upload-time = "2025-10-14T10:22:59.342Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495, upload-time = "2025-10-14T10:23:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, +] + +[[package]] +name = "snakia" +version = "0.4.0" +source = { editable = "." } +dependencies = [ + { name = "networkx" }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "networkx", specifier = ">=3.4.2" }, + { name = "pydantic", specifier = ">=2.12.3" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +]