From 9a2b7902e7c8b78c88a2f7f7db5326c6936bbd6c Mon Sep 17 00:00:00 2001 From: RF-Tar-Railt Date: Fri, 3 Jan 2025 19:55:50 +0800 Subject: [PATCH] feat: import `firework.bootstrap` --- {launart/ryanvk => _bootstrap}/__init__.py | 0 _bootstrap/_resolve.py | 88 ++++++ _bootstrap/context.py | 89 ++++++ _bootstrap/core.py | 302 +++++++++++++++++++++ _bootstrap/service.py | 48 ++++ _bootstrap/status.py | 17 ++ _bootstrap/utiles.py | 100 +++++++ pdm.lock | 41 ++- pyproject.toml | 4 +- pyrightconfig.json | 2 +- 10 files changed, 677 insertions(+), 14 deletions(-) rename {launart/ryanvk => _bootstrap}/__init__.py (100%) create mode 100644 _bootstrap/_resolve.py create mode 100644 _bootstrap/context.py create mode 100644 _bootstrap/core.py create mode 100644 _bootstrap/service.py create mode 100644 _bootstrap/status.py create mode 100644 _bootstrap/utiles.py diff --git a/launart/ryanvk/__init__.py b/_bootstrap/__init__.py similarity index 100% rename from launart/ryanvk/__init__.py rename to _bootstrap/__init__.py diff --git a/_bootstrap/_resolve.py b/_bootstrap/_resolve.py new file mode 100644 index 0000000..47b9814 --- /dev/null +++ b/_bootstrap/_resolve.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterable + +if TYPE_CHECKING: + from .service import Service + + +class RequirementResolveFailed(Exception): + pass + + +class DependencyBrokenError(Exception): + pass + + +def _build_dependencies_map(services: Iterable[Service]) -> dict[str, set[str]]: + dependencies_map: dict[str, set[str]] = {} + + for service in services: + dependencies_map[service.id] = set(service.dependencies) | set(service.after) + + for before in service.before: + dependencies_map.setdefault(before, set()).add(service.id) + + return dependencies_map + + +def resolve_dependencies( + services: Iterable[Service], + exclude: Iterable[Service] = (), + *, + reverse: bool = False, +) -> list[list[str]]: + services = list(services) + + dependencies_map = _build_dependencies_map(services) + + unresolved = {s.id: s for s in services} + resolved_id = {i.id for i in exclude} + result: list[list[str]] = [] + + while unresolved: + layer_candidates = [service for service in unresolved.values() if resolved_id.issuperset(dependencies_map[service.id])] + + if not layer_candidates: + raise TypeError("Failed to resolve requirements due to cyclic dependencies or unmet constraints.") + + # 根据是否有 before 约束进行分类 + befores = [] + no_befores = [] + + for service in layer_candidates: + if service.before: + befores.append(service) + else: + no_befores.append(service) + + # 优先无 before 的服务,一旦无 before 的服务存在,就先放这一层 + current_layer = no_befores or befores + + # 从未解决中移除当前层的服务 + for cid in current_layer: + del unresolved[cid] + + resolved_id.update(current_layer) + result.append(current_layer) + + if reverse: + result.reverse() + + return result + + +def validate_services_removal(existed: Iterable[Service], services_to_remove: Iterable[Service]): + graph = {service.id: set() for service in existed} + + for service, deps in _build_dependencies_map(existed).items(): + for dep in deps: + if dep in graph: + graph[dep].add(service) + + to_remove = {service.id for service in services_to_remove} + + for node in to_remove: + for dependent in graph.get(node, ()): + if dependent not in to_remove: + raise DependencyBrokenError(f"Cannot remove node '{node}' because node '{dependent}' depends on it.") diff --git a/_bootstrap/context.py b/_bootstrap/context.py new file mode 100644 index 0000000..62373a5 --- /dev/null +++ b/_bootstrap/context.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import asyncio +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from .status import Phase, ServiceStatusValue, Stage + +if TYPE_CHECKING: + from .core import Bootstrap + + +@dataclass +class ServiceContext: + bootstrap: Bootstrap + + def __post_init__(self): + self._status: ServiceStatusValue = (Stage.EXIT, Phase.WAITING) + self._sigexit = asyncio.Event() + self._notify = asyncio.Event() + + def _forward(self, stage: Stage, phase: Phase): + prev_stage, prev_phase = self._status + + if stage < prev_stage and prev_stage != Stage.EXIT: + raise ValueError(f"Cannot update stage from {prev_stage} to {stage}") + + if stage == prev_stage: + if phase <= prev_phase: + raise ValueError(f"Cannot update phase from {prev_phase} to {phase}") + else: + phase = Phase.WAITING + + self._status = (stage, phase) + + self._notify.set() + self._notify.clear() + + @property + def should_exit(self): + return self._sigexit.is_set() + + async def wait_for(self, stage: Stage, phase: Phase): + val = (stage, phase) + + while val > self._status: + await self._notify.wait() + + async def wait_for_sigexit(self): + await self._sigexit.wait() + + @asynccontextmanager + async def prepare(self): + self._forward(Stage.PREPARE, Phase.WAITING) + await self.wait_for(Stage.PREPARE, Phase.PENDING) + yield + self._forward(Stage.PREPARE, Phase.COMPLETED) + + @asynccontextmanager + async def online(self): + self._forward(Stage.ONLINE, Phase.WAITING) + await self.wait_for(Stage.ONLINE, Phase.PENDING) + yield + self._forward(Stage.ONLINE, Phase.COMPLETED) + + @asynccontextmanager + async def cleanup(self): + self._forward(Stage.CLEANUP, Phase.WAITING) + await self.wait_for(Stage.CLEANUP, Phase.PENDING) + yield + self._forward(Stage.CLEANUP, Phase.COMPLETED) + + def dispatch_prepare(self): + self._forward(Stage.PREPARE, Phase.PENDING) + + def dispatch_online(self): + self._forward(Stage.ONLINE, Phase.PENDING) + + def dispatch_cleanup(self): + self._forward(Stage.CLEANUP, Phase.PENDING) + + def exit(self): + """Call by the manager""" + self._sigexit.set() + + def exit_complete(self): + """Call by the manager""" + self._status = (Stage.EXIT, Phase.COMPLETED) diff --git a/_bootstrap/core.py b/_bootstrap/core.py new file mode 100644 index 0000000..c75bedf --- /dev/null +++ b/_bootstrap/core.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import asyncio +import signal +from typing import TYPE_CHECKING, Any, Iterable +from contextvars import ContextVar + +from exceptiongroup import BaseExceptionGroup # noqa: A004 +from loguru import logger + +from .utiles import TaskGroup, any_completed, cvar, unity + +from ._resolve import resolve_dependencies, validate_services_removal +from .context import ServiceContext +from .status import Phase, Stage + +if TYPE_CHECKING: + from .service import Service + +BOOTSTRAP_CONTEXT: ContextVar[Bootstrap] = ContextVar("BOOTSTRAP_CONTEXT") + + +def _dummy_online(): + async def _dummy_offline(*, trigger_exit: bool = True): + pass + + return _dummy_offline + + +def _cancel_alive_tasks(loop: asyncio.AbstractEventLoop): + to_cancel = asyncio.tasks.all_tasks(loop) + if to_cancel: + for tsk in to_cancel: + tsk.cancel() + loop.run_until_complete(asyncio.gather(*to_cancel, return_exceptions=True)) + + for task in to_cancel: # pragma: no cover + # BELIEVE IN PSF + if task.cancelled(): + continue + if task.exception() is not None: + logger.opt(exception=task.exception()).error(f"Unhandled exception when shutting down {task}:") + + +class UnhandledExit(Exception): + pass + + +class Bootstrap: + initial_services: dict[str, Service] + services: dict[str, Service] + contexts: dict[str, ServiceContext] + daemon_tasks: dict[str, asyncio.Task] + task_group: TaskGroup + + def __init__(self) -> None: + self.initial_services = {} + self.services = {} + self.contexts = {} + self.daemon_tasks = {} + self.task_group = TaskGroup() + + @staticmethod + def current(): + return BOOTSTRAP_CONTEXT.get() + + @property + def running(self): + return self.task_group.main is not None and not self.task_group.main.done() + + def get_service(self, service_or_id: type[Service] | str) -> Service: + if isinstance(service_or_id, type): + service_or_id = service_or_id.id + + return self.services[service_or_id] + + def get_context(self, service_or_id: type[Service] | str) -> ServiceContext: + if isinstance(service_or_id, type): + service_or_id = service_or_id.id + + return self.contexts[service_or_id] + + def add_initial_services(self, *services: Service): + for service in services: + self.initial_services[service.id] = service + + def remove_initial_services(self, *services: type[Service]): + for service in services: + self.initial_services.pop(service.id) + + async def start_lifespan( + self, services: Iterable[Service], *, rollback: bool = False, failed_record: list[asyncio.Task] | None = None + ): + failed = await self._handle_stage_prepare(services) + + if failed_record is not None and failed is not None: + done, curr = failed + + failed_record.extend([i for i in curr if i.done()]) + + if rollback: + self.task_group.drop(done) + + for i in done: + self.contexts[i.get_name()].dispatch_online() + + for i in curr: + if not i.done(): + self.contexts[i.get_name()].dispatch_online() + + await self._handle_stage_cleanup( + [self.services[i.get_name()] for i in done] + [self.services[i.get_name()] for i in curr if not i.done()] + ) + + return _dummy_online + + def _online(): + for service in services: + self.contexts[service.id].dispatch_online() + + async def _offline(*, trigger_exit: bool = True): + failed_offline = await self._handle_stage_cleanup(services, trigger_exit=trigger_exit) + + if failed_record is not None and failed_offline is not None: + failed_record.extend(failed_offline) + + return _offline + + return _online + + async def _service_daemon(self, service: Service, context: ServiceContext): + await service.launch(context) + context.exit_complete() + + async def _handle_stage_prepare(self, services: Iterable[Service]): + bind = {service.id: service for service in services} + resolved = resolve_dependencies(services, exclude=self.services.values()) + previous_tasks: list[asyncio.Task] = [] + + for layer in resolved: + _services = {i: bind[i] for i in layer} + _contexts = {i: ServiceContext(self) for i in layer} + _daemons = {i: self._service_daemon(_services[i], _contexts[i]) for i in layer} + + self.services.update(_services) + self.contexts.update(_contexts) + + daemon_tasks = {k: asyncio.create_task(v, name=k) for k, v in _daemons.items()} + self.daemon_tasks.update(daemon_tasks) + + awaiting_daemon_exit = asyncio.create_task(any_completed(daemon_tasks.values())) + awaiting_dispatch_ready = asyncio.create_task( + unity([i.wait_for(Stage.PREPARE, Phase.WAITING) for i in _contexts.values()]) + ) # awaiting_prepare + completed_task, _ = await any_completed([awaiting_daemon_exit, awaiting_dispatch_ready]) + + if completed_task is awaiting_daemon_exit and not awaiting_dispatch_ready.done(): + return previous_tasks, daemon_tasks.values() + + for context in _contexts.values(): + context.dispatch_prepare() + + awaiting_prepare = asyncio.create_task( + unity([i.wait_for(Stage.PREPARE, Phase.COMPLETED) for i in _contexts.values()]), # awaiting_prepare + ) + completed_task, _ = await any_completed([awaiting_prepare, awaiting_daemon_exit]) + + if completed_task is awaiting_daemon_exit and not awaiting_prepare.done(): + return previous_tasks, daemon_tasks.values() + + layer_tasks = [asyncio.create_task(i.wait_for(Stage.ONLINE, Phase.COMPLETED)) for i in _contexts.values()] + self.task_group.update(layer_tasks) + previous_tasks.extend(layer_tasks) + + async def _handle_stage_cleanup(self, services: Iterable[Service], *, trigger_exit: bool = True): + service_bind = {} + for service in services: + if service.id not in self.services: + raise ValueError(f"Service {service.id} is not registered") + service_bind[service.id] = service + + daemon_bind = {service.id: self.daemon_tasks[service.id] for service in services} + + validate_services_removal(self.services.values(), services) + resolved = resolve_dependencies(services, reverse=True, exclude=self.services.values()) + + for layer in resolved: + _contexts = {i: self.contexts[i] for i in layer} + daemon_tasks = [daemon_bind[i] for i in layer] + + if trigger_exit: + self._sigexit_trig([service_bind[i] for i in layer]) + + awaiting_daemon_exit = asyncio.create_task(any_completed(daemon_tasks)) + awaiting_dispatch_ready = unity([i.wait_for(Stage.CLEANUP, Phase.WAITING) for i in _contexts.values()]) # awaiting_prepare + completed_task, _ = await any_completed([awaiting_daemon_exit, awaiting_dispatch_ready]) + + if completed_task is awaiting_daemon_exit: + return [task for task in daemon_tasks if task.done()] + + for context in _contexts.values(): + context.dispatch_cleanup() + + awaiting_cleanup = asyncio.create_task( + unity([i.wait_for(Stage.CLEANUP, Phase.COMPLETED) for i in _contexts.values()]), # awaiting_prepare + ) + completed_task, _ = await any_completed([awaiting_cleanup, awaiting_daemon_exit]) + await any_completed([awaiting_cleanup, awaiting_daemon_exit]) # update asyncio.Task state + + if completed_task is awaiting_daemon_exit and not awaiting_cleanup.done(): + return [task for task in daemon_tasks if task.done()] + + await asyncio.gather(*[i.wait_for(Stage.EXIT, Phase.COMPLETED) for i in _contexts.values()]) + + for target in layer: + self.services.pop(target) + self.contexts.pop(target) + self.daemon_tasks.pop(target) + + async def launch(self): + if not self.initial_services: + raise ValueError("No services to launch.") + + with cvar(BOOTSTRAP_CONTEXT, self): + failed = [] + + online_dispatch = await self.start_lifespan(self.initial_services.values(), failed_record=failed, rollback=True) + offline_callback = online_dispatch() + + try: + if not failed: + logger.success("Service startup complete, Ctrl-C to exit application.", style="green bold") + await self.task_group.wait() + finally: + await offline_callback(trigger_exit=False) + + if failed: + exceptions = [i.exception() or UnhandledExit() for i in failed] + raise BaseExceptionGroup("service cleanup failed", exceptions) + + def launch_blocking( + self, + *, + loop: asyncio.AbstractEventLoop | None = None, + stop_signal: Iterable[signal.Signals] = (signal.SIGINT,), + ): + import contextlib + import threading + + loop = asyncio.new_event_loop() + + logger.info("Starting launart main task...", style="green bold") + + launch_task = loop.create_task(self.launch(), name="amnesia-launch") + handled_signals: dict[signal.Signals, Any] = {} + + def signal_handler(*_): + return self._on_sys_signal(launch_task) + + if threading.current_thread() is threading.main_thread(): # pragma: worst case + try: + for sig in stop_signal: + handled_signals[sig] = signal.getsignal(sig) + signal.signal(sig, signal_handler) + except ValueError: # pragma: no cover + # `signal.signal` may throw if `threading.main_thread` does + # not support signals + handled_signals.clear() + + loop.run_until_complete(launch_task) + + for sig, handler in handled_signals.items(): + if signal.getsignal(sig) is signal_handler: + signal.signal(sig, handler) + + try: + _cancel_alive_tasks(loop) + loop.run_until_complete(loop.shutdown_asyncgens()) + with contextlib.suppress(RuntimeError, AttributeError): + # LINK: https://docs.python.org/3.10/library/asyncio-eventloop.html#asyncio.loop.shutdown_default_executor + loop.run_until_complete(loop.shutdown_default_executor()) # type: ignore + finally: + asyncio.set_event_loop(None) + logger.success("asyncio shutdown complete.", style="green bold") + + def _sigexit_trig(self, services: Iterable[Service]): + for service in services: + self.contexts[service.id].exit() + + def _on_sys_signal(self, launch_task: asyncio.Task): + self._sigexit_trig(self.services.values()) + + if self.task_group is not None: + self.task_group.stop() + if self.task_group.main is not None: # pragma: worst case + self.task_group.main.cancel() + + if not launch_task.done(): + launch_task.cancel() + # wakeup loop if it is blocked by select() with long timeout + launch_task.get_loop().call_soon_threadsafe(lambda: None) + logger.warning("Ctrl-C triggered by user.", style="dark_orange bold") diff --git a/_bootstrap/service.py b/_bootstrap/service.py new file mode 100644 index 0000000..b86ff11 --- /dev/null +++ b/_bootstrap/service.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterable + +from ._resolve import RequirementResolveFailed as RequirementResolveFailed +from ._resolve import resolve_dependencies as resolve_dependencies +from ._resolve import validate_services_removal + +if TYPE_CHECKING: + from .context import ServiceContext + + +class Service: + id: str + + @property + def dependencies(self) -> tuple[str, ...]: + return () + + @property + def before(self) -> tuple[str, ...]: + return () + + @property + def after(self) -> tuple[str, ...]: + return () + + async def launch(self, context: ServiceContext): + async with context.prepare(): + pass + + async with context.online(): + pass + + async with context.cleanup(): + pass + + +def resolve_services_dependency(services: Iterable[Service], exclude: Iterable[Service], *, reverse: bool = False): + return resolve_dependencies( + services, + exclude=set(exclude), + reverse=reverse, + ) + + +def validate_service_removal(existed: Iterable[Service], remove: Iterable[Service]): + validate_services_removal(existed, remove) diff --git a/_bootstrap/status.py b/_bootstrap/status.py new file mode 100644 index 0000000..dccd15a --- /dev/null +++ b/_bootstrap/status.py @@ -0,0 +1,17 @@ +from enum import Enum + + +class Stage(int, Enum): + PREPARE = 0 + ONLINE = 1 + CLEANUP = 2 + EXIT = 3 + + +class Phase(int, Enum): + WAITING = 0 + PENDING = 1 + COMPLETED = 2 + + +ServiceStatusValue = tuple[Stage, Phase] \ No newline at end of file diff --git a/_bootstrap/utiles.py b/_bootstrap/utiles.py new file mode 100644 index 0000000..a1940c2 --- /dev/null +++ b/_bootstrap/utiles.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import asyncio +from contextlib import contextmanager +from typing import TYPE_CHECKING, Coroutine, Iterable, TypeVar + +from typing_extensions import TypeAlias +from loguru import logger + + +if TYPE_CHECKING: + from contextvars import ContextVar + + +_CoroutineLike: TypeAlias = "Coroutine | asyncio.Task" + + +def into_tasks(awaitables: Iterable[_CoroutineLike]) -> list[asyncio.Task]: + return [i if isinstance(i, asyncio.Task) else asyncio.create_task(i) for i in awaitables] + + +async def unity( + tasks: Iterable[_CoroutineLike], + *, + timeout: float | None = None, # noqa: ASYNC109 + return_when: str = asyncio.ALL_COMPLETED, +): + return await asyncio.wait(into_tasks(tasks), timeout=timeout, return_when=return_when) + + +async def any_completed(tasks: Iterable[_CoroutineLike]): + done, pending = await unity(tasks, return_when=asyncio.FIRST_COMPLETED) + return next(iter(done)), pending + + +def cancel_alive_tasks(loop: asyncio.AbstractEventLoop): + to_cancel = asyncio.tasks.all_tasks(loop) + if to_cancel: + for tsk in to_cancel: + tsk.cancel() + loop.run_until_complete(asyncio.gather(*to_cancel, return_exceptions=True)) + + for task in to_cancel: # pragma: no cover + # BELIEVE IN PSF + if task.cancelled(): + continue + if task.exception() is not None: + logger.opt(exception=task.exception()).error(f"Unhandled exception when shutting down {task}:") + + +T = TypeVar("T") + + +@contextmanager +def cvar(ctx: ContextVar[T], val: T): + token = ctx.set(val) + try: + yield val + finally: + ctx.reset(token) + + +class TaskGroup: + tasks: list[asyncio.Task] + main: asyncio.Task | None = None + _stop: bool = False + + def __init__(self): + self.tasks = [] + + def flush(self): + if self.main is not None: + self.main.cancel() + + def stop(self): + self._stop = True + self.flush() + + def update(self, tasks: Iterable[asyncio.Task | Coroutine]): + tasks = [asyncio.create_task(task) if asyncio.iscoroutine(task) else task for task in tasks] + self.tasks.extend(tasks) + + self.flush() + return tasks + + def drop(self, tasks: Iterable[asyncio.Task]): + for task in tasks: + self.tasks.remove(task) + + self.flush() + + async def wait(self): + while True: + self.main = asyncio.create_task(asyncio.wait(self.tasks)) + try: + return await self.main + except asyncio.CancelledError: + if self._stop: + self.main = None + return diff --git a/pdm.lock b/pdm.lock index 3c3493d..df9df55 100644 --- a/pdm.lock +++ b/pdm.lock @@ -3,10 +3,12 @@ [metadata] groups = ["default", "dev", "saya"] -cross_platform = true -static_urls = false -lock_version = "4.3" -content_hash = "sha256:fb7678631844f1f56a5f86d7dfcfb3889bb416926df5bf2eeb05b9ba01923d43" +strategy = ["cross_platform"] +lock_version = "4.5.0" +content_hash = "sha256:0220f347e9e267ba4bf7667772d4733ef9dc8871a4e83cb7bb0691581c221cdf" + +[[metadata.targets]] +requires_python = ">=3.9" [[package]] name = "black" @@ -54,6 +56,7 @@ requires_python = ">=3.7" summary = "Composable command line interface toolkit" dependencies = [ "colorama; platform_system == \"Windows\"", + "importlib-metadata; python_version < \"3.8\"", ] files = [ {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, @@ -74,6 +77,9 @@ files = [ name = "commonmark" version = "0.9.1" summary = "Python parser for the CommonMark Markdown spec" +dependencies = [ + "future>=0.14.0; python_version < \"3\"", +] files = [ {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, @@ -143,12 +149,12 @@ files = [ [[package]] name = "exceptiongroup" -version = "1.1.2" +version = "1.2.2" requires_python = ">=3.7" summary = "Backport of PEP 654 (exception groups)" files = [ - {file = "exceptiongroup-1.1.2-py3-none-any.whl", hash = "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"}, - {file = "exceptiongroup-1.1.2.tar.gz", hash = "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5"}, + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, ] [[package]] @@ -183,6 +189,7 @@ version = "6.8.0" requires_python = ">=3.8" summary = "Read metadata from Python packages" dependencies = [ + "typing-extensions>=3.6.4; python_version < \"3.8\"", "zipp>=0.5", ] files = [ @@ -215,6 +222,7 @@ version = "0.6.0" requires_python = ">=3.5" summary = "Python logging made (stupidly) simple" dependencies = [ + "aiocontextvars>=0.2.0; python_version < \"3.7\"", "colorama>=0.3.4; sys_platform == \"win32\"", "win32-setctime>=1.0.0; sys_platform == \"win32\"", ] @@ -227,6 +235,9 @@ files = [ name = "mypy-extensions" version = "0.4.3" summary = "Experimental type system extensions for programs checked with the mypy typechecker." +dependencies = [ + "typing>=3.5.3; python_version < \"3.5\"", +] files = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, @@ -267,6 +278,9 @@ name = "pluggy" version = "1.0.0" requires_python = ">=3.6" summary = "plugin and hook calling mechanisms for python" +dependencies = [ + "importlib-metadata>=0.12; python_version < \"3.8\"", +] files = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, @@ -290,6 +304,7 @@ summary = "pytest: simple powerful testing with Python" dependencies = [ "colorama; sys_platform == \"win32\"", "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", + "importlib-metadata>=0.12; python_version < \"3.8\"", "iniconfig", "packaging", "pluggy<2.0,>=0.12", @@ -307,6 +322,7 @@ requires_python = ">=3.7" summary = "Pytest support for asyncio" dependencies = [ "pytest>=7.0.0", + "typing-extensions>=3.7.2; python_version < \"3.8\"", ] files = [ {file = "pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d"}, @@ -320,6 +336,7 @@ requires_python = ">=3.6.3,<4.0.0" summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" dependencies = [ "commonmark<0.10.0,>=0.9.0", + "dataclasses<0.9,>=0.7; python_version < \"3.7\"", "pygments<3.0.0,>=2.6.0", "typing-extensions<5.0,>=4.0.0; python_version < \"3.9\"", ] @@ -364,12 +381,12 @@ files = [ [[package]] name = "typing-extensions" -version = "4.3.0" -requires_python = ">=3.7" -summary = "Backported and Experimental Type Hints for Python 3.7+" +version = "4.12.2" +requires_python = ">=3.8" +summary = "Backported and Experimental Type Hints for Python 3.8+" files = [ - {file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"}, - {file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 507f12f..23ac166 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,10 @@ dependencies = [ "statv>=0.2.2", "loguru>=0.6.0", "creart>=0.3.0", + "typing-extensions>=4.5.0", + "exceptiongroup>=1.2.2", ] -requires-python = ">=3.8" +requires-python = ">=3.9" readme = "README.md" license = {text = "MIT"} diff --git a/pyrightconfig.json b/pyrightconfig.json index f18aafc..04d3c45 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -1,7 +1,7 @@ { "exclude": ["__pypackages__", ".venv"], "reportShadowedImports": false, - "pythonVersion": "3.8", + "pythonVersion": "3.9", "venv": ".venv", "venvPath": ".", "typeCheckingMode": "basic",