Compare commits
No commits in common. "master" and "v0.13.0" have entirely different histories.
14 changed files with 36 additions and 185 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -30,7 +30,6 @@ docs/_build
|
||||||
docs/_static
|
docs/_static
|
||||||
docs/templates
|
docs/templates
|
||||||
.coverage
|
.coverage
|
||||||
.pytest_cache/
|
|
||||||
|
|
||||||
# changes during CD/CI
|
# changes during CD/CI
|
||||||
aliases/*/pyproject.toml
|
aliases/*/pyproject.toml
|
||||||
|
|
|
||||||
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -1,15 +1,5 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.14.0
|
|
||||||
|
|
||||||
* Added `ast` module
|
|
||||||
* Deprecate `dei_args()` for problems with the typing system. The function is not going away tho
|
|
||||||
* Module `sqlalchemy`: added `email_column()`
|
|
||||||
|
|
||||||
## 0.13.1 and 0.12.7
|
|
||||||
|
|
||||||
+ Typing fixes
|
|
||||||
|
|
||||||
## 0.13.0 "Laconic Letters"
|
## 0.13.0 "Laconic Letters"
|
||||||
|
|
||||||
+ Added module `argparse` with class `LetterSubparsers()`, which allows pacman-style args by preprocessing them
|
+ Added module `argparse` with class `LetterSubparsers()`, which allows pacman-style args by preprocessing them
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ from .color import OKLabColor, chalk, WebColor, RGBColor, LinearRGBColor, \
|
||||||
from .mat import Matrix
|
from .mat import Matrix
|
||||||
from .argparse import LetterSubparsers
|
from .argparse import LetterSubparsers
|
||||||
|
|
||||||
__version__ = "0.14.0a1"
|
__version__ = "0.13.0"
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ColorFormatter',
|
'ColorFormatter',
|
||||||
|
|
|
||||||
116
src/suou/ast.py
116
src/suou/ast.py
|
|
@ -1,116 +0,0 @@
|
||||||
"""
|
|
||||||
Experimental AST module
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Copyright (c) 2026 Sakuragasaki46.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
See LICENSE for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
|
|
||||||
This software is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
from ast import TypeVar
|
|
||||||
from typing import Any, Collection
|
|
||||||
|
|
||||||
_T = TypeVar('_T')
|
|
||||||
|
|
||||||
class Node:
|
|
||||||
__slots__ = ('_children', )
|
|
||||||
|
|
||||||
_count: int
|
|
||||||
_children: list[Node]
|
|
||||||
_types: Collection[type]
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"{self.__class__.__name__}({', '.join(repr(x) for x in self._children)})"
|
|
||||||
|
|
||||||
def __init__(self, *args):
|
|
||||||
if self._count != len(args):
|
|
||||||
raise TypeError(f'{self.__class__.__name__} must be instanced with {self._count} arguments, got {len(args)}')
|
|
||||||
|
|
||||||
for i, (a, t) in enumerate(zip(args, self._types)):
|
|
||||||
if t != Any and not isinstance(a, t):
|
|
||||||
raise TypeError(f"argument {i} must be of type {t.__name__!r}, got {a.__class__.__name__!r}")
|
|
||||||
|
|
||||||
self._children = list(args)
|
|
||||||
|
|
||||||
def eval(self, ctx: dict):
|
|
||||||
raise TypeError(f'{self.__class__.__name__} cannot be eval()ed')
|
|
||||||
|
|
||||||
def __getitem__(self, key: int):
|
|
||||||
if key >= self._count:
|
|
||||||
raise IndexError('child node index out of range')
|
|
||||||
return self._children[key]
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return self._count
|
|
||||||
|
|
||||||
|
|
||||||
class ZeroOp(Node):
|
|
||||||
_count = 0
|
|
||||||
_types = ()
|
|
||||||
|
|
||||||
|
|
||||||
class UnaryOp(Node):
|
|
||||||
_count = 1
|
|
||||||
_types = (Any,)
|
|
||||||
|
|
||||||
|
|
||||||
class BinaryOp(Node):
|
|
||||||
_count = 2
|
|
||||||
_types = (Any, Any)
|
|
||||||
|
|
||||||
|
|
||||||
class TernaryOp(Node):
|
|
||||||
_count = 3
|
|
||||||
_types = (Any, Any, Any)
|
|
||||||
|
|
||||||
|
|
||||||
class MultiOp(Node):
|
|
||||||
_count = 1
|
|
||||||
_types = (list,)
|
|
||||||
|
|
||||||
def __getitem__(self, key: int):
|
|
||||||
return self._children[0][key]
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
return iter(self._children[0])
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return len(self._children[0])
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Literal(UnaryOp):
|
|
||||||
"""A literal evals to the enclosed value."""
|
|
||||||
def eval(self, ctx: dict):
|
|
||||||
return self[0]
|
|
||||||
|
|
||||||
|
|
||||||
## SUOU provides some ready-made literals, for the sake of ease-of-use.
|
|
||||||
|
|
||||||
class IntLiteral(Literal):
|
|
||||||
_types = (int,)
|
|
||||||
|
|
||||||
class FloatLiteral(Literal):
|
|
||||||
"""
|
|
||||||
WARNING: may be subject to loss of precision.
|
|
||||||
"""
|
|
||||||
_types = (float,)
|
|
||||||
|
|
||||||
class StringLiteral(Literal):
|
|
||||||
_types = (str,)
|
|
||||||
|
|
||||||
|
|
||||||
# This module is experimental and therefore not re-exported into __init__
|
|
||||||
__all__ = (
|
|
||||||
'Node', 'ZeroOp', 'UnaryOp', 'BinaryOp', 'TernaryOp',
|
|
||||||
'MultiOp', 'Literal', 'IntLiteral', 'FloatLiteral',
|
|
||||||
'StringLiteral'
|
|
||||||
)
|
|
||||||
|
|
@ -21,8 +21,6 @@ from __future__ import annotations
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Callable, Collection, TypeVar, Any
|
from typing import Callable, Collection, TypeVar, Any
|
||||||
|
|
||||||
from . import deprecated
|
|
||||||
|
|
||||||
_T = TypeVar('_T')
|
_T = TypeVar('_T')
|
||||||
_U = TypeVar('_U')
|
_U = TypeVar('_U')
|
||||||
|
|
||||||
|
|
@ -117,7 +115,6 @@ class Pronoun(int):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@deprecated('breaks the typing system somewhat; won\'t be removed tho')
|
|
||||||
def dei_args(**renames: dict[str, str]):
|
def dei_args(**renames: dict[str, str]):
|
||||||
"""
|
"""
|
||||||
Allow for aliases in the keyword argument names, in form alias='real_name'.
|
Allow for aliases in the keyword argument names, in form alias='real_name'.
|
||||||
|
|
|
||||||
|
|
@ -22,23 +22,7 @@ from suou.classtools import MISSING
|
||||||
|
|
||||||
_T = TypeVar('_T')
|
_T = TypeVar('_T')
|
||||||
|
|
||||||
def _makelist_callable(l: Callable) -> Callable[..., list]:
|
def makelist(l: Any, wrap: bool = True) -> list | Callable[Any, list]:
|
||||||
@wraps(l)
|
|
||||||
def wrapper(*a, **k):
|
|
||||||
return _makelist_nowrap(l(*a, **k))
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
def _makelist_nowrap(l: Any) -> list:
|
|
||||||
if isinstance(l, (str, bytes, bytearray)):
|
|
||||||
return [l]
|
|
||||||
elif isinstance(l, Iterable):
|
|
||||||
return list(l)
|
|
||||||
elif l in (None, NotImplemented, Ellipsis, MISSING):
|
|
||||||
return []
|
|
||||||
else:
|
|
||||||
return [l]
|
|
||||||
|
|
||||||
def makelist(l: Any, wrap: bool = True) -> list | Callable[..., list]:
|
|
||||||
'''
|
'''
|
||||||
Make a list out of an iterable or a single value.
|
Make a list out of an iterable or a single value.
|
||||||
|
|
||||||
|
|
@ -48,11 +32,17 @@ def makelist(l: Any, wrap: bool = True) -> list | Callable[..., list]:
|
||||||
*Changed in 0.11.0*: ``wrap`` argument is now no more keyword only.
|
*Changed in 0.11.0*: ``wrap`` argument is now no more keyword only.
|
||||||
'''
|
'''
|
||||||
if callable(l) and wrap:
|
if callable(l) and wrap:
|
||||||
return _makelist_callable(l)
|
return wraps(l)(lambda *a, **k: makelist(l(*a, **k), wrap=False))
|
||||||
|
if isinstance(l, (str, bytes, bytearray)):
|
||||||
|
return [l]
|
||||||
|
elif isinstance(l, Iterable):
|
||||||
|
return list(l)
|
||||||
|
elif l in (None, NotImplemented, Ellipsis, MISSING):
|
||||||
|
return []
|
||||||
else:
|
else:
|
||||||
return _makelist_nowrap(l)
|
return [l]
|
||||||
|
|
||||||
def ltuple(seq: Iterable[_T], size: int, /, pad = None) -> tuple[_T, ...]:
|
def ltuple(seq: Iterable[_T], size: int, /, pad = None) -> tuple:
|
||||||
"""
|
"""
|
||||||
Truncate an iterable into a fixed size tuple, if necessary padding it.
|
Truncate an iterable into a fixed size tuple, if necessary padding it.
|
||||||
"""
|
"""
|
||||||
|
|
@ -61,7 +51,7 @@ def ltuple(seq: Iterable[_T], size: int, /, pad = None) -> tuple[_T, ...]:
|
||||||
seq = seq + (pad,) * (size - len(seq))
|
seq = seq + (pad,) * (size - len(seq))
|
||||||
return seq
|
return seq
|
||||||
|
|
||||||
def rtuple(seq: Iterable[_T], size: int, /, pad = None) -> tuple[_T, ...]:
|
def rtuple(seq: Iterable[_T], size: int, /, pad = None) -> tuple:
|
||||||
"""
|
"""
|
||||||
Same as rtuple() but the padding and truncation is made right to left.
|
Same as rtuple() but the padding and truncation is made right to left.
|
||||||
"""
|
"""
|
||||||
|
|
@ -91,7 +81,7 @@ def kwargs_prefix(it: dict[str, Any], prefix: str, *, remove = True, keep_prefix
|
||||||
it.pop(k)
|
it.pop(k)
|
||||||
return ka
|
return ka
|
||||||
|
|
||||||
def additem(obj: MutableMapping, /, name: str | None = None):
|
def additem(obj: MutableMapping, /, name: str = None):
|
||||||
"""
|
"""
|
||||||
Syntax sugar for adding a function to a mapping, immediately.
|
Syntax sugar for adding a function to a mapping, immediately.
|
||||||
"""
|
"""
|
||||||
|
|
@ -103,7 +93,7 @@ def additem(obj: MutableMapping, /, name: str | None = None):
|
||||||
return func
|
return func
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
def addattr(obj: Any, /, name: str | None = None):
|
def addattr(obj: Any, /, name: str = None):
|
||||||
"""
|
"""
|
||||||
Same as additem() but setting as attribute instead.
|
Same as additem() but setting as attribute instead.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ def symbol_table(*args: Iterable[tuple | TokenSym], whitespace: str | None = Non
|
||||||
yield TokenSym('[' + re.escape(whitespace) + ']+', '', discard=True)
|
yield TokenSym('[' + re.escape(whitespace) + ']+', '', discard=True)
|
||||||
|
|
||||||
|
|
||||||
|
symbol_table: Callable[..., list]
|
||||||
|
|
||||||
def ilex(text: str, table: Iterable[TokenSym], *, whitespace = False):
|
def ilex(text: str, table: Iterable[TokenSym], *, whitespace = False):
|
||||||
"""
|
"""
|
||||||
Return a text as a list of tokens, given a token table (iterable of TokenSym).
|
Return a text as a list of tokens, given a token table (iterable of TokenSym).
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ def lucky(validators: Iterable[Callable[[_U], bool]] = ()):
|
||||||
|
|
||||||
*New in 0.7.0*
|
*New in 0.7.0*
|
||||||
"""
|
"""
|
||||||
def decorator(func: Callable[..., _U]) -> Callable[..., _U]:
|
def decorator(func: Callable[_T, _U]) -> Callable[_T, _U]:
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(*args, **kwargs) -> _U:
|
def wrapper(*args, **kwargs) -> _U:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -86,5 +86,4 @@ class PingExtension(markdown.extensions.Extension):
|
||||||
md.inlinePatterns.register(MentionPattern(re.escape(at) + r'(' + self.CHARACTERS + ')', url_prefix), 'ping_mention', 14)
|
md.inlinePatterns.register(MentionPattern(re.escape(at) + r'(' + self.CHARACTERS + ')', url_prefix), 'ping_mention', 14)
|
||||||
|
|
||||||
|
|
||||||
# Optional dependency: do not import into __init__.py
|
|
||||||
__all__ = ('PingExtension', 'SpoilerExtension', 'StrikethroughExtension')
|
__all__ = ('PingExtension', 'SpoilerExtension', 'StrikethroughExtension')
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,11 @@ from itsdangerous import TimestampSigner
|
||||||
from itsdangerous import Signer as _Signer
|
from itsdangerous import Signer as _Signer
|
||||||
from itsdangerous.encoding import int_to_bytes as _int_to_bytes
|
from itsdangerous.encoding import int_to_bytes as _int_to_bytes
|
||||||
|
|
||||||
from .itertools import rtuple
|
from suou.dei import dei_args
|
||||||
from .codecs import jsondecode, jsonencode, want_bytes, want_str, b64decode, b64encode
|
from suou.itertools import rtuple
|
||||||
|
|
||||||
|
from .functools import not_implemented
|
||||||
|
from .codecs import jsondecode, jsonencode, rb64decode, want_bytes, want_str, b64decode, b64encode
|
||||||
from .iding import Siq
|
from .iding import Siq
|
||||||
from .classtools import MISSING
|
from .classtools import MISSING
|
||||||
|
|
||||||
|
|
@ -32,6 +35,7 @@ class UserSigner(TimestampSigner):
|
||||||
itsdangerous.TimestampSigner() instanced from a user ID, with token generation and validation capabilities.
|
itsdangerous.TimestampSigner() instanced from a user ID, with token generation and validation capabilities.
|
||||||
"""
|
"""
|
||||||
user_id: int
|
user_id: int
|
||||||
|
@dei_args(primary_secret='master_secret')
|
||||||
def __init__(self, master_secret: bytes, user_id: int, user_secret: bytes, **kwargs):
|
def __init__(self, master_secret: bytes, user_id: int, user_secret: bytes, **kwargs):
|
||||||
super().__init__(master_secret + user_secret, salt=Siq(user_id).to_bytes(), **kwargs)
|
super().__init__(master_secret + user_secret, salt=Siq(user_id).to_bytes(), **kwargs)
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ from functools import wraps
|
||||||
|
|
||||||
from contextvars import ContextVar, Token
|
from contextvars import ContextVar, Token
|
||||||
from typing import Callable, TypeVar
|
from typing import Callable, TypeVar
|
||||||
from sqlalchemy import Select, Table, select
|
from sqlalchemy import Select, Table, func, select
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase, lazyload
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -31,7 +31,8 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
AsyncSelectPagination = None
|
AsyncSelectPagination = None
|
||||||
|
|
||||||
from ..exceptions import NotFoundError
|
from suou.exceptions import NotFoundError
|
||||||
|
from suou.glue import glue
|
||||||
|
|
||||||
_T = TypeVar('_T')
|
_T = TypeVar('_T')
|
||||||
_U = TypeVar('_U')
|
_U = TypeVar('_U')
|
||||||
|
|
@ -125,11 +126,9 @@ class SQLAlchemy:
|
||||||
self.engine, checkfirst=checkfirst
|
self.engine, checkfirst=checkfirst
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# XXX NOT public API! DO NOT USE
|
||||||
current_session: ContextVar[AsyncSession] = ContextVar('current_session')
|
current_session: ContextVar[AsyncSession] = ContextVar('current_session')
|
||||||
"""
|
|
||||||
XXX NOT public API! DO NOT USE
|
|
||||||
"""
|
|
||||||
|
|
||||||
def async_query(db: SQLAlchemy, multi: False):
|
def async_query(db: SQLAlchemy, multi: False):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from binascii import Incomplete
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any, Callable, TypeVar
|
from typing import Any, Callable, TypeVar
|
||||||
|
|
@ -26,8 +27,10 @@ from sqlalchemy import BigInteger, Boolean, CheckConstraint, Column, Date, Forei
|
||||||
from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Relationship, declarative_base as _declarative_base, relationship
|
from sqlalchemy.orm import DeclarativeBase, InstrumentedAttribute, Relationship, declarative_base as _declarative_base, relationship
|
||||||
from sqlalchemy.types import TypeEngine
|
from sqlalchemy.types import TypeEngine
|
||||||
from sqlalchemy.ext.hybrid import Comparator
|
from sqlalchemy.ext.hybrid import Comparator
|
||||||
|
from suou.functools import future
|
||||||
from suou.classtools import Wanted, Incomplete
|
from suou.classtools import Wanted, Incomplete
|
||||||
from suou.codecs import StringCase
|
from suou.codecs import StringCase
|
||||||
|
from suou.dei import dei_args
|
||||||
from suou.iding import Siq, SiqCache, SiqGen, SiqType
|
from suou.iding import Siq, SiqCache, SiqGen, SiqType
|
||||||
from suou.itertools import kwargs_prefix
|
from suou.itertools import kwargs_prefix
|
||||||
from suou.snowflake import SnowflakeGen
|
from suou.snowflake import SnowflakeGen
|
||||||
|
|
@ -128,23 +131,6 @@ def username_column(
|
||||||
return match_column(length, regex, case=case, nullable=nullable, unique=True, *args, **kwargs)
|
return match_column(length, regex, case=case, nullable=nullable, unique=True, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
EMAIL_RE_USERNAME = r"[a-z0-9-]+(\.[a-z0-9-]+)*"
|
|
||||||
EMAIL_RE_DOMAIN = r"([a-z0-9-]+\.)+[a-z0-9-]{2,15}"
|
|
||||||
EMAIL_RE_YESALIASES = EMAIL_RE_USERNAME + r"(\+" + EMAIL_RE_USERNAME + ")?@" + EMAIL_RE_DOMAIN
|
|
||||||
EMAIL_RE_NOALIASES = EMAIL_RE_USERNAME + r"@" + EMAIL_RE_DOMAIN
|
|
||||||
|
|
||||||
def email_column(
|
|
||||||
length: int = 256, *args, allow_aliases: bool = True, nullable: bool = False, unique: bool = True, **kwargs
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Construct a column containing a email address.
|
|
||||||
|
|
||||||
*New in 0.14.0*
|
|
||||||
"""
|
|
||||||
return match_column(length, EMAIL_RE_YESALIASES if allow_aliases else EMAIL_RE_NOALIASES, case = StringCase.FORCE_LOWER,
|
|
||||||
unique = unique, nullable = nullable, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def bool_column(value: bool = False, nullable: bool = False, **kwargs) -> Column[bool]:
|
def bool_column(value: bool = False, nullable: bool = False, **kwargs) -> Column[bool]:
|
||||||
"""
|
"""
|
||||||
Column for a single boolean value.
|
Column for a single boolean value.
|
||||||
|
|
@ -155,6 +141,7 @@ def bool_column(value: bool = False, nullable: bool = False, **kwargs) -> Column
|
||||||
return Column(Boolean, server_default=def_val, nullable=nullable, **kwargs)
|
return Column(Boolean, server_default=def_val, nullable=nullable, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@dei_args(primary_secret='master_secret')
|
||||||
def declarative_base(domain_name: str, master_secret: bytes, metadata: dict | None = None, **kwargs) -> type[DeclarativeBase]:
|
def declarative_base(domain_name: str, master_secret: bytes, metadata: dict | None = None, **kwargs) -> type[DeclarativeBase]:
|
||||||
"""
|
"""
|
||||||
Drop-in replacement for sqlalchemy.orm.declarative_base()
|
Drop-in replacement for sqlalchemy.orm.declarative_base()
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ from suou.functools import future
|
||||||
|
|
||||||
@future()
|
@future()
|
||||||
class Waiter():
|
class Waiter():
|
||||||
_cached_app: Starlette | None = None
|
_cached_app: Callable | None = None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.routes: list[Route] = []
|
self.routes: list[Route] = []
|
||||||
|
|
@ -60,7 +60,7 @@ class Waiter():
|
||||||
def patch(self, endpoint: str, *a, **k):
|
def patch(self, endpoint: str, *a, **k):
|
||||||
return self._route('PATCH', endpoint, *a, **k)
|
return self._route('PATCH', endpoint, *a, **k)
|
||||||
|
|
||||||
def _route(self, methods: str | list[str], endpoint: str, **kwargs):
|
def _route(self, methods: list[str], endpoint: str, **kwargs):
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
self.routes.append(Route(endpoint, func, methods=makelist(methods, False), **kwargs))
|
self.routes.append(Route(endpoint, func, methods=makelist(methods, False), **kwargs))
|
||||||
return func
|
return func
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue