Compare commits

...

9 Commits

Author SHA1 Message Date
Denis Laxalde
2a3a1a6c47
Merge 8220bcedf6 into 472fb9f6ac 2024-09-12 13:34:59 +00:00
Tulir Asokan
472fb9f6ac Remove outdated comment
[skip ci]
2024-09-08 00:58:55 +03:00
jkhsjdhjs
65be63fdd2
Fix PluginWebApp base path handling (#240)
Previously, the webapp handler would match without respect to the trailing slash, e.g. matching "foo"
for "foo2". This behavior is changed to respect the trailing slash.

Fixes #239
2024-08-24 18:47:24 +03:00
Tulir Asokan
c218c8cf61 Bump version to 0.5.0 2024-08-24 12:10:19 +03:00
Tulir Asokan
b8714cc6b9 Also update standalone docker image 2024-08-06 18:55:00 +03:00
Tulir Asokan
49adb9b441 Update docker image 2024-08-06 18:52:05 +03:00
Tulir Asokan
09a0efbf19 Remove hard dependency on SQLAlchemy
Fixes #247
2024-08-06 18:47:14 +03:00
Tulir Asokan
861d81d2a6 Update dependencies 2024-07-13 13:22:04 +03:00
Denis Laxalde
8220bcedf6 Fix required=True not accounted for for simple argument
CommandHandler.__parse_args__ checks for the None value returned Argument.match() to possibly raise
if no value got passed to a *required* argument. Unfortunately, SimpleArgument.match() never
returns None but would instead return the empty string it receives (which is being stripped by
the caller, __parse_args__()). As such, `@argument(..., required=True)` does not work and silently
passes an empty string to the command handler.

We fix this returning None early in SimpleArgument.match() when it receives an empty string value.
2023-06-16 10:00:00 +02:00
19 changed files with 99 additions and 52 deletions

View File

@ -10,7 +10,7 @@ default:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
build frontend: build frontend:
image: node:18-alpine image: node:20-alpine
stage: build frontend stage: build frontend
before_script: [] before_script: []
variables: variables:

View File

@ -1,5 +1,9 @@
# v0.5.0 (unreleased) # v0.5.0 (2024-08-24)
* Dropped Python 3.9 support.
* Updated Docker image to Alpine 3.20.
* Updated mautrix-python to 0.20.6 to support authenticated media.
* Removed hard dependency on SQLAlchemy.
* Fixed `main_class` to default to being loaded from the last module instead of * Fixed `main_class` to default to being loaded from the last module instead of
the first if a module name is not explicitly specified. the first if a module name is not explicitly specified.
* This was already the [documented behavior](https://docs.mau.fi/maubot/dev/reference/plugin-metadata.html), * This was already the [documented behavior](https://docs.mau.fi/maubot/dev/reference/plugin-metadata.html),

View File

@ -1,9 +1,9 @@
FROM node:18 AS frontend-builder FROM node:20 AS frontend-builder
COPY ./maubot/management/frontend /frontend COPY ./maubot/management/frontend /frontend
RUN cd /frontend && yarn --prod && yarn build RUN cd /frontend && yarn --prod && yarn build
FROM alpine:3.18 FROM alpine:3.20
RUN apk add --no-cache \ RUN apk add --no-cache \
python3 py3-pip py3-setuptools py3-wheel \ python3 py3-pip py3-setuptools py3-wheel \
@ -11,7 +11,6 @@ RUN apk add --no-cache \
su-exec \ su-exec \
yq \ yq \
py3-aiohttp \ py3-aiohttp \
py3-sqlalchemy \
py3-attrs \ py3-attrs \
py3-bcrypt \ py3-bcrypt \
py3-cffi \ py3-cffi \
@ -34,20 +33,19 @@ RUN apk add --no-cache \
py3-unpaddedbase64 \ py3-unpaddedbase64 \
py3-future \ py3-future \
# plugin deps # plugin deps
#py3-pillow \ py3-pillow \
py3-magic \ py3-magic \
py3-feedparser \ py3-feedparser \
py3-dateutil \ py3-dateutil \
py3-lxml \ py3-lxml \
py3-semver \ py3-semver
&& apk add --no-cache py3-pillow --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community
# TODO remove pillow, magic, feedparser, lxml, gitlab and semver when maubot supports installing dependencies # TODO remove pillow, magic, feedparser, lxml, gitlab and semver when maubot supports installing dependencies
COPY requirements.txt /opt/maubot/requirements.txt COPY requirements.txt /opt/maubot/requirements.txt
COPY optional-requirements.txt /opt/maubot/optional-requirements.txt COPY optional-requirements.txt /opt/maubot/optional-requirements.txt
WORKDIR /opt/maubot WORKDIR /opt/maubot
RUN apk add --virtual .build-deps python3-dev build-base git \ RUN apk add --virtual .build-deps python3-dev build-base git \
&& pip3 install -r requirements.txt -r optional-requirements.txt \ && pip3 install --break-system-packages -r requirements.txt -r optional-requirements.txt \
dateparser langdetect python-gitlab pyquery tzlocal \ dateparser langdetect python-gitlab pyquery tzlocal \
&& apk del .build-deps && apk del .build-deps
# TODO also remove dateparser, langdetect and pyquery when maubot supports installing dependencies # TODO also remove dateparser, langdetect and pyquery when maubot supports installing dependencies

View File

@ -1,4 +1,4 @@
FROM alpine:3.18 FROM alpine:3.20
RUN apk add --no-cache \ RUN apk add --no-cache \
python3 py3-pip py3-setuptools py3-wheel \ python3 py3-pip py3-setuptools py3-wheel \
@ -6,7 +6,6 @@ RUN apk add --no-cache \
su-exec \ su-exec \
yq \ yq \
py3-aiohttp \ py3-aiohttp \
py3-sqlalchemy \
py3-attrs \ py3-attrs \
py3-bcrypt \ py3-bcrypt \
py3-cffi \ py3-cffi \
@ -30,11 +29,10 @@ RUN apk add --no-cache \
py3-unpaddedbase64 \ py3-unpaddedbase64 \
py3-future \ py3-future \
# plugin deps # plugin deps
#py3-pillow \ py3-pillow \
py3-magic \ py3-magic \
py3-feedparser \ py3-feedparser \
py3-lxml \ py3-lxml
&& apk add --no-cache py3-pillow --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community
# py3-gitlab # py3-gitlab
# py3-semver # py3-semver
# TODO remove pillow, magic, feedparser, lxml, gitlab and semver when maubot supports installing dependencies # TODO remove pillow, magic, feedparser, lxml, gitlab and semver when maubot supports installing dependencies
@ -43,7 +41,7 @@ COPY requirements.txt /opt/maubot/requirements.txt
COPY optional-requirements.txt /opt/maubot/optional-requirements.txt COPY optional-requirements.txt /opt/maubot/optional-requirements.txt
WORKDIR /opt/maubot WORKDIR /opt/maubot
RUN apk add --virtual .build-deps python3-dev build-base git \ RUN apk add --virtual .build-deps python3-dev build-base git \
&& pip3 install -r requirements.txt -r optional-requirements.txt \ && pip3 install --break-system-packages -r requirements.txt -r optional-requirements.txt \
dateparser langdetect python-gitlab pyquery semver tzlocal cssselect \ dateparser langdetect python-gitlab pyquery semver tzlocal cssselect \
&& apk del .build-deps && apk del .build-deps
# TODO also remove dateparser, langdetect and pyquery when maubot supports installing dependencies # TODO also remove dateparser, langdetect and pyquery when maubot supports installing dependencies

View File

@ -1 +1 @@
__version__ = "0.4.2" __version__ = "0.5.0"

View File

@ -1,5 +1,4 @@
# The full URI to the database. SQLite and Postgres are fully supported. # The full URI to the database. SQLite and Postgres are fully supported.
# Other DBMSes supported by SQLAlchemy may or may not work.
# Format examples: # Format examples:
# SQLite: sqlite:filename.db # SQLite: sqlite:filename.db
# Postgres: postgresql://username:password@hostname/dbname # Postgres: postgresql://username:password@hostname/dbname

View File

@ -403,6 +403,8 @@ class CustomArgument(Argument):
class SimpleArgument(Argument): class SimpleArgument(Argument):
def match(self, val: str, **kwargs) -> Tuple[str, Any]: def match(self, val: str, **kwargs) -> Tuple[str, Any]:
if not val:
return "", None
if self.pass_raw: if self.pass_raw:
return "", val return "", val
res = re.split(r"\s", val, 1)[0] res = re.split(r"\s", val, 1)[0]

View File

@ -25,7 +25,6 @@ import os.path
from ruamel.yaml import YAML from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap from ruamel.yaml.comments import CommentedMap
import sqlalchemy as sql
from mautrix.types import UserID from mautrix.types import UserID
from mautrix.util import background_task from mautrix.util import background_task
@ -36,6 +35,7 @@ from mautrix.util.logging import TraceLogger
from .client import Client from .client import Client
from .db import DatabaseEngine, Instance as DBInstance from .db import DatabaseEngine, Instance as DBInstance
from .lib.optionalalchemy import Engine, MetaData, create_engine
from .lib.plugin_db import ProxyPostgresDatabase from .lib.plugin_db import ProxyPostgresDatabase
from .loader import DatabaseType, PluginLoader, ZippedPluginLoader from .loader import DatabaseType, PluginLoader, ZippedPluginLoader
from .plugin_base import Plugin from .plugin_base import Plugin
@ -128,7 +128,7 @@ class PluginInstance(DBInstance):
} }
def _introspect_sqlalchemy(self) -> dict: def _introspect_sqlalchemy(self) -> dict:
metadata = sql.MetaData() metadata = MetaData()
metadata.reflect(self.inst_db) metadata.reflect(self.inst_db)
return { return {
table.name: { table.name: {
@ -214,7 +214,7 @@ class PluginInstance(DBInstance):
async def get_db_tables(self) -> dict: async def get_db_tables(self) -> dict:
if self.inst_db_tables is None: if self.inst_db_tables is None:
if isinstance(self.inst_db, sql.engine.Engine): if isinstance(self.inst_db, Engine):
self.inst_db_tables = self._introspect_sqlalchemy() self.inst_db_tables = self._introspect_sqlalchemy()
elif self.inst_db.scheme == Scheme.SQLITE: elif self.inst_db.scheme == Scheme.SQLITE:
self.inst_db_tables = await self._introspect_sqlite() self.inst_db_tables = await self._introspect_sqlite()
@ -294,7 +294,7 @@ class PluginInstance(DBInstance):
"Instance database engine is marked as Postgres, but plugin uses legacy " "Instance database engine is marked as Postgres, but plugin uses legacy "
"database interface, which doesn't support postgres." "database interface, which doesn't support postgres."
) )
self.inst_db = sql.create_engine(f"sqlite:///{self._sqlite_db_path}") self.inst_db = create_engine(f"sqlite:///{self._sqlite_db_path}")
elif self.loader.meta.database_type == DatabaseType.ASYNCPG: elif self.loader.meta.database_type == DatabaseType.ASYNCPG:
if self.database_engine is None: if self.database_engine is None:
if os.path.exists(self._sqlite_db_path) or not self.maubot.plugin_postgres_db: if os.path.exists(self._sqlite_db_path) or not self.maubot.plugin_postgres_db:
@ -329,7 +329,7 @@ class PluginInstance(DBInstance):
async def stop_database(self) -> None: async def stop_database(self) -> None:
if isinstance(self.inst_db, Database): if isinstance(self.inst_db, Database):
await self.inst_db.stop() await self.inst_db.stop()
elif isinstance(self.inst_db, sql.engine.Engine): elif isinstance(self.inst_db, Engine):
self.inst_db.dispose() self.inst_db.dispose()
else: else:
raise RuntimeError(f"Unknown database type {type(self.inst_db).__name__}") raise RuntimeError(f"Unknown database type {type(self.inst_db).__name__}")

View File

@ -0,0 +1,19 @@
try:
from sqlalchemy import MetaData, asc, create_engine, desc
from sqlalchemy.engine import Engine
from sqlalchemy.exc import IntegrityError, OperationalError
except ImportError:
class FakeError(Exception):
pass
class FakeType:
def __init__(self, *args, **kwargs):
raise Exception("SQLAlchemy is not installed")
def create_engine(*args, **kwargs):
raise Exception("SQLAlchemy is not installed")
MetaData = Engine = FakeType
IntegrityError = OperationalError = FakeError
asc = desc = lambda a: a

View File

@ -31,7 +31,7 @@ from ..config import Config
from ..lib.zipimport import ZipImportError, zipimporter from ..lib.zipimport import ZipImportError, zipimporter
from ..plugin_base import Plugin from ..plugin_base import Plugin
from .abc import IDConflictError, PluginClass, PluginLoader from .abc import IDConflictError, PluginClass, PluginLoader
from .meta import PluginMeta from .meta import DatabaseType, PluginMeta
current_version = Version(__version__) current_version = Version(__version__)
yaml = YAML() yaml = YAML()
@ -155,9 +155,9 @@ class ZippedPluginLoader(PluginLoader):
return file, meta return file, meta
@classmethod @classmethod
def verify_meta(cls, source) -> tuple[str, Version]: def verify_meta(cls, source) -> tuple[str, Version, DatabaseType | None]:
_, meta = cls._read_meta(source) _, meta = cls._read_meta(source)
return meta.id, meta.version return meta.id, meta.version, meta.database_type if meta.database else None
def _load_meta(self) -> None: def _load_meta(self) -> None:
file, meta = self._read_meta(self.path) file, meta = self._read_meta(self.path)

View File

@ -19,12 +19,12 @@ from datetime import datetime
from aiohttp import web from aiohttp import web
from asyncpg import PostgresError from asyncpg import PostgresError
from sqlalchemy import asc, desc, engine, exc
import aiosqlite import aiosqlite
from mautrix.util.async_db import Database from mautrix.util.async_db import Database
from ...instance import PluginInstance from ...instance import PluginInstance
from ...lib.optionalalchemy import Engine, IntegrityError, OperationalError, asc, desc
from .base import routes from .base import routes
from .responses import resp from .responses import resp
@ -66,7 +66,7 @@ async def get_table(request: web.Request) -> web.Response:
except KeyError: except KeyError:
order = [] order = []
limit = int(request.query.get("limit", "100")) limit = int(request.query.get("limit", "100"))
if isinstance(instance.inst_db, engine.Engine): if isinstance(instance.inst_db, Engine):
return _execute_query_sqlalchemy(instance, table.select().order_by(*order).limit(limit)) return _execute_query_sqlalchemy(instance, table.select().order_by(*order).limit(limit))
@ -84,7 +84,7 @@ async def query(request: web.Request) -> web.Response:
except KeyError: except KeyError:
return resp.query_missing return resp.query_missing
rows_as_dict = data.get("rows_as_dict", False) rows_as_dict = data.get("rows_as_dict", False)
if isinstance(instance.inst_db, engine.Engine): if isinstance(instance.inst_db, Engine):
return _execute_query_sqlalchemy(instance, sql_query, rows_as_dict) return _execute_query_sqlalchemy(instance, sql_query, rows_as_dict)
elif isinstance(instance.inst_db, Database): elif isinstance(instance.inst_db, Database):
try: try:
@ -133,12 +133,12 @@ async def _execute_query_asyncpg(
def _execute_query_sqlalchemy( def _execute_query_sqlalchemy(
instance: PluginInstance, sql_query: str, rows_as_dict: bool = False instance: PluginInstance, sql_query: str, rows_as_dict: bool = False
) -> web.Response: ) -> web.Response:
assert isinstance(instance.inst_db, engine.Engine) assert isinstance(instance.inst_db, Engine)
try: try:
res = instance.inst_db.execute(sql_query) res = instance.inst_db.execute(sql_query)
except exc.IntegrityError as e: except IntegrityError as e:
return resp.sql_integrity_error(e, sql_query) return resp.sql_integrity_error(e, sql_query)
except exc.OperationalError as e: except OperationalError as e:
return resp.sql_operational_error(e, sql_query) return resp.sql_operational_error(e, sql_query)
data = { data = {
"ok": True, "ok": True,

View File

@ -23,10 +23,17 @@ import traceback
from aiohttp import web from aiohttp import web
from packaging.version import Version from packaging.version import Version
from ...loader import MaubotZipImportError, PluginLoader, ZippedPluginLoader from ...loader import DatabaseType, MaubotZipImportError, PluginLoader, ZippedPluginLoader
from .base import get_config, routes from .base import get_config, routes
from .responses import resp from .responses import resp
try:
import sqlalchemy
has_alchemy = True
except ImportError:
has_alchemy = False
log = logging.getLogger("maubot.server.upload") log = logging.getLogger("maubot.server.upload")
@ -36,9 +43,11 @@ async def put_plugin(request: web.Request) -> web.Response:
content = await request.read() content = await request.read()
file = BytesIO(content) file = BytesIO(content)
try: try:
pid, version = ZippedPluginLoader.verify_meta(file) pid, version, db_type = ZippedPluginLoader.verify_meta(file)
except MaubotZipImportError as e: except MaubotZipImportError as e:
return resp.plugin_import_error(str(e), traceback.format_exc()) return resp.plugin_import_error(str(e), traceback.format_exc())
if db_type == DatabaseType.SQLALCHEMY and not has_alchemy:
return resp.sqlalchemy_not_installed
if pid != plugin_id: if pid != plugin_id:
return resp.pid_mismatch return resp.pid_mismatch
plugin = PluginLoader.id_cache.get(plugin_id, None) plugin = PluginLoader.id_cache.get(plugin_id, None)
@ -55,9 +64,11 @@ async def upload_plugin(request: web.Request) -> web.Response:
content = await request.read() content = await request.read()
file = BytesIO(content) file = BytesIO(content)
try: try:
pid, version = ZippedPluginLoader.verify_meta(file) pid, version, db_type = ZippedPluginLoader.verify_meta(file)
except MaubotZipImportError as e: except MaubotZipImportError as e:
return resp.plugin_import_error(str(e), traceback.format_exc()) return resp.plugin_import_error(str(e), traceback.format_exc())
if db_type == DatabaseType.SQLALCHEMY and not has_alchemy:
return resp.sqlalchemy_not_installed
plugin = PluginLoader.id_cache.get(pid, None) plugin = PluginLoader.id_cache.get(pid, None)
if not plugin: if not plugin:
return await upload_new_plugin(content, pid, version) return await upload_new_plugin(content, pid, version)

View File

@ -15,13 +15,16 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from http import HTTPStatus from http import HTTPStatus
from aiohttp import web from aiohttp import web
from asyncpg import PostgresError from asyncpg import PostgresError
from sqlalchemy.exc import IntegrityError, OperationalError
import aiosqlite import aiosqlite
if TYPE_CHECKING:
from sqlalchemy.exc import IntegrityError, OperationalError
class _Response: class _Response:
@property @property
@ -324,6 +327,16 @@ class _Response:
} }
) )
@property
def sqlalchemy_not_installed(self) -> web.Response:
return web.json_response(
{
"error": "This plugin requires a legacy database, but SQLAlchemy is not installed",
"errcode": "unsupported_plugin_database",
},
status=HTTPStatus.NOT_IMPLEMENTED,
)
@property @property
def table_not_found(self) -> web.Response: def table_not_found(self) -> web.Response:
return web.json_response( return web.json_response(

View File

@ -20,7 +20,6 @@ from abc import ABC
from asyncio import AbstractEventLoop from asyncio import AbstractEventLoop
from aiohttp import ClientSession from aiohttp import ClientSession
from sqlalchemy.engine.base import Engine
from yarl import URL from yarl import URL
from mautrix.util.async_db import Database, UpgradeTable from mautrix.util.async_db import Database, UpgradeTable
@ -30,6 +29,8 @@ from mautrix.util.logging import TraceLogger
from .scheduler import BasicScheduler from .scheduler import BasicScheduler
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlalchemy.engine.base import Engine
from .client import MaubotMatrixClient from .client import MaubotMatrixClient
from .loader import BasePluginLoader from .loader import BasePluginLoader
from .plugin_server import PluginWebApp from .plugin_server import PluginWebApp
@ -56,7 +57,7 @@ class Plugin(ABC):
instance_id: str, instance_id: str,
log: TraceLogger, log: TraceLogger,
config: BaseProxyConfig | None, config: BaseProxyConfig | None,
database: Engine | None, database: Engine | Database | None,
webapp: PluginWebApp | None, webapp: PluginWebApp | None,
webapp_url: str | None, webapp_url: str | None,
loader: BasePluginLoader, loader: BasePluginLoader,

View File

@ -64,14 +64,14 @@ class MaubotServer:
if request.path.startswith(path): if request.path.startswith(path):
request = request.clone( request = request.clone(
rel_url=request.rel_url.with_path( rel_url=request.rel_url.with_path(
request.rel_url.path[len(path) :] request.rel_url.path[len(path) - 1 :]
).with_query(request.query_string) ).with_query(request.query_string)
) )
return await app.handle(request) return await app.handle(request)
return web.Response(status=404) return web.Response(status=404)
def get_instance_subapp(self, instance_id: str) -> tuple[PluginWebApp, str]: def get_instance_subapp(self, instance_id: str) -> tuple[PluginWebApp, str]:
subpath = self.config["server.plugin_base_path"] + instance_id subpath = self.config["server.plugin_base_path"] + instance_id + "/"
url = self.config["server.public_url"] + subpath url = self.config["server.public_url"] + subpath
try: try:
return self.plugin_routes[subpath], url return self.plugin_routes[subpath], url
@ -82,7 +82,7 @@ class MaubotServer:
def remove_instance_webapp(self, instance_id: str) -> None: def remove_instance_webapp(self, instance_id: str) -> None:
try: try:
subpath = self.config["server.plugin_base_path"] + instance_id subpath = self.config["server.plugin_base_path"] + instance_id + "/"
self.plugin_routes.pop(subpath).clear() self.plugin_routes.pop(subpath).clear()
except KeyError: except KeyError:
return return

View File

@ -1,9 +1,8 @@
FROM docker.io/alpine:3.18 FROM docker.io/alpine:3.20
RUN apk add --no-cache \ RUN apk add --no-cache \
python3 py3-pip py3-setuptools py3-wheel \ python3 py3-pip py3-setuptools py3-wheel \
py3-aiohttp \ py3-aiohttp \
py3-sqlalchemy \
py3-attrs \ py3-attrs \
py3-bcrypt \ py3-bcrypt \
py3-cffi \ py3-cffi \
@ -26,8 +25,8 @@ RUN cd /opt/maubot \
python3-dev \ python3-dev \
libffi-dev \ libffi-dev \
build-base \ build-base \
&& pip3 install -r requirements.txt -r optional-requirements.txt \ && pip3 install --break-system-packages -r requirements.txt -r optional-requirements.txt \
&& apk del .build-deps && apk del .build-deps
COPY . /opt/maubot COPY . /opt/maubot
RUN cd /opt/maubot && pip3 install . RUN cd /opt/maubot && pip3 install --break-system-packages .

View File

@ -9,3 +9,6 @@ unpaddedbase64>=1,<3
#/testing #/testing
pytest pytest
pytest-asyncio pytest-asyncio
#/legacydb
SQLAlchemy>1,<1.4

View File

@ -1,16 +1,16 @@
mautrix>=0.20.2,<0.21 mautrix>=0.20.6,<0.21
aiohttp>=3,<4 aiohttp>=3,<4
yarl>=1,<2 yarl>=1,<2
SQLAlchemy>=1,<1.4 asyncpg>=0.20,<0.30
asyncpg>=0.20,<0.29 aiosqlite>=0.16,<0.21
aiosqlite>=0.16,<0.19
commonmark>=0.9,<1 commonmark>=0.9,<1
ruamel.yaml>=0.15.35,<0.18 ruamel.yaml>=0.15.35,<0.19
attrs>=18.1.0 attrs>=18.1.0
bcrypt>=3,<5 bcrypt>=3,<5
packaging>=10 packaging>=10
click>=7,<9 click>=7,<9
colorama>=0.4,<0.5 colorama>=0.4,<0.5
questionary>=1,<2 questionary>=1,<3
jinja2>=2,<4 jinja2>=2,<4
setuptools

View File

@ -41,7 +41,7 @@ setuptools.setup(
install_requires=install_requires, install_requires=install_requires,
extras_require=extras_require, extras_require=extras_require,
python_requires="~=3.9", python_requires="~=3.10",
classifiers=[ classifiers=[
"Development Status :: 4 - Beta", "Development Status :: 4 - Beta",
@ -50,9 +50,9 @@ setuptools.setup(
"Framework :: AsyncIO", "Framework :: AsyncIO",
"Programming Language :: Python", "Programming Language :: Python",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
], ],
entry_points=""" entry_points="""
[console_scripts] [console_scripts]