easterobot 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- easterobot/__init__.py +19 -0
- easterobot/__main__.py +6 -0
- easterobot/bot.py +584 -0
- easterobot/cli.py +127 -0
- easterobot/commands/__init__.py +28 -0
- easterobot/commands/base.py +171 -0
- easterobot/commands/basket.py +99 -0
- easterobot/commands/disable.py +29 -0
- easterobot/commands/edit.py +68 -0
- easterobot/commands/enable.py +35 -0
- easterobot/commands/help.py +33 -0
- easterobot/commands/reset.py +121 -0
- easterobot/commands/search.py +127 -0
- easterobot/commands/top.py +105 -0
- easterobot/config.py +401 -0
- easterobot/info.py +18 -0
- easterobot/logger.py +16 -0
- easterobot/models.py +58 -0
- easterobot/py.typed +1 -0
- easterobot/resources/config.example.yml +226 -0
- easterobot/resources/credits.txt +1 -0
- easterobot/resources/eggs/egg_01.png +0 -0
- easterobot/resources/eggs/egg_02.png +0 -0
- easterobot/resources/eggs/egg_03.png +0 -0
- easterobot/resources/eggs/egg_04.png +0 -0
- easterobot/resources/eggs/egg_05.png +0 -0
- easterobot/resources/eggs/egg_06.png +0 -0
- easterobot/resources/eggs/egg_07.png +0 -0
- easterobot/resources/eggs/egg_08.png +0 -0
- easterobot/resources/eggs/egg_09.png +0 -0
- easterobot/resources/eggs/egg_10.png +0 -0
- easterobot/resources/eggs/egg_11.png +0 -0
- easterobot/resources/eggs/egg_12.png +0 -0
- easterobot/resources/eggs/egg_13.png +0 -0
- easterobot/resources/eggs/egg_14.png +0 -0
- easterobot/resources/eggs/egg_15.png +0 -0
- easterobot/resources/eggs/egg_16.png +0 -0
- easterobot/resources/eggs/egg_17.png +0 -0
- easterobot/resources/eggs/egg_18.png +0 -0
- easterobot/resources/eggs/egg_19.png +0 -0
- easterobot/resources/eggs/egg_20.png +0 -0
- easterobot/resources/logging.conf +47 -0
- easterobot/resources/logo.png +0 -0
- easterobot-1.0.0.dist-info/METADATA +242 -0
- easterobot-1.0.0.dist-info/RECORD +48 -0
- easterobot-1.0.0.dist-info/WHEEL +4 -0
- easterobot-1.0.0.dist-info/entry_points.txt +2 -0
- easterobot-1.0.0.dist-info/licenses/LICENSE +21 -0
easterobot/config.py
ADDED
@@ -0,0 +1,401 @@
|
|
1
|
+
"""Main program."""
|
2
|
+
|
3
|
+
import os
|
4
|
+
import pathlib
|
5
|
+
import random
|
6
|
+
from abc import ABC, abstractmethod
|
7
|
+
from collections.abc import Iterable
|
8
|
+
from typing import (
|
9
|
+
Any,
|
10
|
+
Generic,
|
11
|
+
Literal,
|
12
|
+
Optional,
|
13
|
+
TypeVar,
|
14
|
+
Union,
|
15
|
+
cast,
|
16
|
+
)
|
17
|
+
|
18
|
+
import discord
|
19
|
+
import msgspec
|
20
|
+
from typing_extensions import TypeGuard, get_args, get_origin, override
|
21
|
+
|
22
|
+
RAND = random.SystemRandom()
|
23
|
+
|
24
|
+
T = TypeVar("T")
|
25
|
+
V = TypeVar("V")
|
26
|
+
Members = Union[discord.Member, list[discord.Member]]
|
27
|
+
RESOURCES = pathlib.Path(__file__).parent.resolve() / "resources"
|
28
|
+
|
29
|
+
|
30
|
+
class Serializable(ABC, Generic[V]):
|
31
|
+
_decodable_flag = True
|
32
|
+
|
33
|
+
@abstractmethod
|
34
|
+
def encode(self) -> V:
|
35
|
+
"""Encode current object of msgspec."""
|
36
|
+
|
37
|
+
@classmethod
|
38
|
+
@abstractmethod
|
39
|
+
def decode(cls: type[T], args: tuple[Any, ...], obj: V) -> T:
|
40
|
+
"""Encode current object of msgspec."""
|
41
|
+
|
42
|
+
@staticmethod
|
43
|
+
def decodable(typ: type[Any]) -> TypeGuard["type[Serializable[T]]"]:
|
44
|
+
"""Check if a class is decodable."""
|
45
|
+
return hasattr(typ, "_decodable_flag")
|
46
|
+
|
47
|
+
|
48
|
+
class ConjugableText(Serializable[str]):
|
49
|
+
__slots__ = ("_conjugation", "_text")
|
50
|
+
|
51
|
+
def __init__(self, text: str):
|
52
|
+
"""Create a conjugable text."""
|
53
|
+
self._text = text
|
54
|
+
self._conjugation: Conjugation = {}
|
55
|
+
|
56
|
+
def __str__(self) -> str:
|
57
|
+
"""Get the string representation."""
|
58
|
+
return f"<{self.__class__.__name__} {self._text!r}>"
|
59
|
+
|
60
|
+
__repr__ = __str__
|
61
|
+
|
62
|
+
@override
|
63
|
+
def encode(self) -> str:
|
64
|
+
return self._text
|
65
|
+
|
66
|
+
@override
|
67
|
+
@classmethod
|
68
|
+
def decode(cls, typ: tuple[Any, ...], obj: str) -> "ConjugableText":
|
69
|
+
return cls(obj)
|
70
|
+
|
71
|
+
@staticmethod
|
72
|
+
def gender(member: discord.Member) -> Literal["man", "woman"]:
|
73
|
+
"""Get the gender of a people."""
|
74
|
+
if any(
|
75
|
+
marker in role.name.casefold()
|
76
|
+
for role in member.roles
|
77
|
+
for marker in (
|
78
|
+
"woman",
|
79
|
+
"girl",
|
80
|
+
"femme",
|
81
|
+
"fille",
|
82
|
+
"elle",
|
83
|
+
"her",
|
84
|
+
"she",
|
85
|
+
)
|
86
|
+
):
|
87
|
+
return "woman"
|
88
|
+
return "man"
|
89
|
+
|
90
|
+
def attach(self, conjugation: "Conjugation") -> None:
|
91
|
+
"""Attach conjugation to the text."""
|
92
|
+
self._conjugation = conjugation
|
93
|
+
|
94
|
+
def __call__(self, members: Members) -> str:
|
95
|
+
"""Conjugate the text."""
|
96
|
+
if isinstance(members, discord.Member):
|
97
|
+
members = [members]
|
98
|
+
if not members:
|
99
|
+
gender = "man"
|
100
|
+
else:
|
101
|
+
for member in members:
|
102
|
+
if self.gender(member) == "man":
|
103
|
+
gender = "man"
|
104
|
+
break
|
105
|
+
else:
|
106
|
+
gender = "woman"
|
107
|
+
text = self._text
|
108
|
+
for term, versions in self._conjugation.items():
|
109
|
+
word = versions[gender]
|
110
|
+
text = text.replace("{" + term.lower() + "}", word.lower())
|
111
|
+
text = text.replace("{" + term.upper() + "}", word.upper())
|
112
|
+
text = text.replace("{" + term.title() + "}", word.title())
|
113
|
+
return text.replace("{user}", f"<@{member.id}>")
|
114
|
+
|
115
|
+
|
116
|
+
class RandomItem(
|
117
|
+
Serializable[list[T]], # Stored form
|
118
|
+
):
|
119
|
+
__slots__ = ("choices",)
|
120
|
+
|
121
|
+
def __str__(self) -> str:
|
122
|
+
"""Get the string representation."""
|
123
|
+
return f"<{self.__class__.__name__} {self.choices!r}>"
|
124
|
+
|
125
|
+
__repr__ = __str__
|
126
|
+
|
127
|
+
def __init__(self, choices: Optional[Iterable[T]] = None):
|
128
|
+
"""Create RandomItem."""
|
129
|
+
self.choices = list(choices) if choices is not None else []
|
130
|
+
|
131
|
+
@override
|
132
|
+
def encode(self) -> list[T]:
|
133
|
+
return self.choices
|
134
|
+
|
135
|
+
@override
|
136
|
+
@classmethod
|
137
|
+
def decode(cls, args: tuple[Any, ...], obj: list[T]) -> "RandomItem[T]":
|
138
|
+
return cls(convert(obj, typ=list[args[0]])) # type: ignore[valid-type]
|
139
|
+
|
140
|
+
def rand(self) -> T:
|
141
|
+
"""Get a random choice."""
|
142
|
+
return RAND.choice(self.choices)
|
143
|
+
|
144
|
+
|
145
|
+
class RandomConjugableText(RandomItem[ConjugableText]):
|
146
|
+
def __call__(self, members: Members) -> str:
|
147
|
+
"""Conjugate a random item."""
|
148
|
+
return self.rand()(members)
|
149
|
+
|
150
|
+
@override
|
151
|
+
@classmethod
|
152
|
+
def decode(
|
153
|
+
cls, args: tuple[Any, ...], obj: list[ConjugableText]
|
154
|
+
) -> "RandomConjugableText":
|
155
|
+
return cls(convert(obj, typ=list[ConjugableText]))
|
156
|
+
|
157
|
+
|
158
|
+
class MCooldown(msgspec.Struct):
|
159
|
+
min: float
|
160
|
+
max: float
|
161
|
+
|
162
|
+
def rand(self) -> float:
|
163
|
+
"""Randomize a min to max."""
|
164
|
+
return self.min + RAND.random() * (self.max - self.min)
|
165
|
+
|
166
|
+
|
167
|
+
class MWeights(msgspec.Struct):
|
168
|
+
egg: float
|
169
|
+
speed: float
|
170
|
+
|
171
|
+
|
172
|
+
class MHunt(msgspec.Struct):
|
173
|
+
timeout: float
|
174
|
+
cooldown: MCooldown
|
175
|
+
weights: MWeights
|
176
|
+
|
177
|
+
|
178
|
+
class MCommand(msgspec.Struct):
|
179
|
+
cooldown: float
|
180
|
+
|
181
|
+
|
182
|
+
class MDiscovered(msgspec.Struct):
|
183
|
+
shield: int
|
184
|
+
min: float
|
185
|
+
max: float
|
186
|
+
|
187
|
+
|
188
|
+
class MSpotted(msgspec.Struct):
|
189
|
+
shield: int
|
190
|
+
min: float
|
191
|
+
max: float
|
192
|
+
|
193
|
+
|
194
|
+
class SearchCommand(MCommand):
|
195
|
+
discovered: MDiscovered
|
196
|
+
spotted: MSpotted
|
197
|
+
|
198
|
+
|
199
|
+
class MGender(msgspec.Struct):
|
200
|
+
woman: str = ""
|
201
|
+
man: str = ""
|
202
|
+
|
203
|
+
def __getitem__(self, key: str) -> str:
|
204
|
+
"""Get text."""
|
205
|
+
return getattr(self, key, "")
|
206
|
+
|
207
|
+
|
208
|
+
class MEmbed(msgspec.Struct):
|
209
|
+
text: ConjugableText
|
210
|
+
gif: str
|
211
|
+
|
212
|
+
|
213
|
+
class MText(msgspec.Struct):
|
214
|
+
text: str
|
215
|
+
success: MEmbed
|
216
|
+
fail: MEmbed
|
217
|
+
|
218
|
+
|
219
|
+
Conjugation = dict[str, MGender]
|
220
|
+
|
221
|
+
|
222
|
+
class MCommands(msgspec.Struct):
|
223
|
+
search: SearchCommand
|
224
|
+
top: MCommand
|
225
|
+
basket: MCommand
|
226
|
+
reset: MCommand
|
227
|
+
enable: MCommand
|
228
|
+
disable: MCommand
|
229
|
+
help: MCommand
|
230
|
+
edit: MCommand
|
231
|
+
|
232
|
+
def __getitem__(self, key: str, /) -> MCommand:
|
233
|
+
"""Get a command."""
|
234
|
+
if key not in self.__struct_fields__:
|
235
|
+
raise KeyError(key)
|
236
|
+
try:
|
237
|
+
result = getattr(self, key)
|
238
|
+
if not isinstance(result, MCommand):
|
239
|
+
raise KeyError(key)
|
240
|
+
except AttributeError:
|
241
|
+
raise KeyError(key) from None
|
242
|
+
return result
|
243
|
+
|
244
|
+
|
245
|
+
class MConfig(msgspec.Struct, dict=True):
|
246
|
+
owner_is_admin: bool
|
247
|
+
use_logging_file: bool
|
248
|
+
admins: list[int]
|
249
|
+
database: str
|
250
|
+
group: str
|
251
|
+
hunt: MHunt
|
252
|
+
conjugation: Conjugation
|
253
|
+
failed: RandomConjugableText
|
254
|
+
hidden: RandomConjugableText
|
255
|
+
spotted: RandomConjugableText
|
256
|
+
appear: RandomItem[str]
|
257
|
+
action: RandomItem[MText]
|
258
|
+
commands: MCommands
|
259
|
+
_resources: Optional[Union[pathlib.Path, msgspec.UnsetType]] = (
|
260
|
+
msgspec.field(name="resources", default=msgspec.UNSET)
|
261
|
+
)
|
262
|
+
_working_directory: Optional[Union[pathlib.Path, msgspec.UnsetType]] = (
|
263
|
+
msgspec.field(name="working_directory", default=msgspec.UNSET)
|
264
|
+
)
|
265
|
+
token: Optional[Union[str, msgspec.UnsetType]] = msgspec.UNSET
|
266
|
+
|
267
|
+
def verified_token(self) -> str:
|
268
|
+
"""Get the safe token."""
|
269
|
+
if self.token is None or self.token is msgspec.UNSET:
|
270
|
+
error_message = "Token was not provided"
|
271
|
+
raise TypeError(error_message)
|
272
|
+
if "." not in self.token:
|
273
|
+
error_message = "Wrong token format"
|
274
|
+
raise ValueError(error_message)
|
275
|
+
return self.token
|
276
|
+
|
277
|
+
def attach_default_working_directory(
|
278
|
+
self,
|
279
|
+
path: Union[pathlib.Path, str],
|
280
|
+
) -> None:
|
281
|
+
"""Attach working directory."""
|
282
|
+
self._cwd = pathlib.Path(path)
|
283
|
+
|
284
|
+
@property
|
285
|
+
def working_directory(self) -> pathlib.Path:
|
286
|
+
"""Get the safe token."""
|
287
|
+
if (
|
288
|
+
self._working_directory is None
|
289
|
+
or self._working_directory is msgspec.UNSET
|
290
|
+
):
|
291
|
+
if hasattr(self, "_cwd"):
|
292
|
+
return self._cwd.resolve()
|
293
|
+
return pathlib.Path.cwd().resolve()
|
294
|
+
return self._working_directory.resolve()
|
295
|
+
|
296
|
+
@property
|
297
|
+
def resources(self) -> pathlib.Path:
|
298
|
+
"""Get path to resources or the embed resources if not configured."""
|
299
|
+
if self._resources is None or self._resources is msgspec.UNSET:
|
300
|
+
return RESOURCES
|
301
|
+
if self._resources.is_absolute():
|
302
|
+
return self._resources
|
303
|
+
return self.working_directory / self._resources
|
304
|
+
|
305
|
+
def __post_init__(self) -> None:
|
306
|
+
"""Add conjugation to item and check some value."""
|
307
|
+
for conjugable in self.failed.choices:
|
308
|
+
conjugable.attach(self.conjugation)
|
309
|
+
for conjugable in self.hidden.choices:
|
310
|
+
conjugable.attach(self.conjugation)
|
311
|
+
for conjugable in self.spotted.choices:
|
312
|
+
conjugable.attach(self.conjugation)
|
313
|
+
for choice in self.action.choices:
|
314
|
+
choice.success.text.attach(self.conjugation)
|
315
|
+
choice.fail.text.attach(self.conjugation)
|
316
|
+
|
317
|
+
def conjugate(self, text: str, member: discord.Member) -> str:
|
318
|
+
"""Conjugate the text."""
|
319
|
+
conj = ConjugableText(text)
|
320
|
+
conj.attach(self.conjugation)
|
321
|
+
return conj(member)
|
322
|
+
|
323
|
+
|
324
|
+
def _dec_hook(typ: type[T], obj: Any) -> T:
|
325
|
+
# Get the base type
|
326
|
+
origin: Optional[type[T]] = get_origin(typ)
|
327
|
+
if origin is None:
|
328
|
+
origin = typ
|
329
|
+
args = get_args(typ)
|
330
|
+
if issubclass(origin, discord.PartialEmoji):
|
331
|
+
return discord.PartialEmoji( # type: ignore[return-value]
|
332
|
+
name="_", animated=False, id=obj
|
333
|
+
)
|
334
|
+
if issubclass(origin, pathlib.Path):
|
335
|
+
return cast(T, pathlib.Path(obj))
|
336
|
+
if Serializable.decodable(origin):
|
337
|
+
return cast(T, origin.decode(args, obj))
|
338
|
+
error_message = f"Invalid type {typ!r} for {obj!r}"
|
339
|
+
raise TypeError(error_message)
|
340
|
+
|
341
|
+
|
342
|
+
def _enc_hook(obj: Any) -> Any:
|
343
|
+
if isinstance(obj, discord.PartialEmoji):
|
344
|
+
return obj.id
|
345
|
+
if isinstance(obj, pathlib.Path):
|
346
|
+
return str(obj)
|
347
|
+
if isinstance(obj, Serializable):
|
348
|
+
return obj.encode()
|
349
|
+
error_message = f"Invalid object {obj!r}"
|
350
|
+
raise TypeError(error_message)
|
351
|
+
|
352
|
+
|
353
|
+
def load_yaml(data: Union[bytes, str], typ: type[T]) -> T:
|
354
|
+
"""Load YAML."""
|
355
|
+
return msgspec.yaml.decode( # type: ignore[no-any-return,unused-ignore]
|
356
|
+
data, type=typ, dec_hook=_dec_hook
|
357
|
+
)
|
358
|
+
|
359
|
+
|
360
|
+
def dump_yaml(obj: Any) -> bytes:
|
361
|
+
"""Load YAML."""
|
362
|
+
return msgspec.yaml.encode( # type: ignore[no-any-return,unused-ignore]
|
363
|
+
obj, enc_hook=_enc_hook
|
364
|
+
)
|
365
|
+
|
366
|
+
|
367
|
+
def convert(obj: Any, typ: type[T]) -> T:
|
368
|
+
"""Convert object."""
|
369
|
+
return msgspec.convert( # type: ignore[no-any-return,unused-ignore]
|
370
|
+
obj, type=typ, dec_hook=_dec_hook
|
371
|
+
)
|
372
|
+
|
373
|
+
|
374
|
+
def load_config(
|
375
|
+
data: Union[bytes, str],
|
376
|
+
token: Optional[str] = None,
|
377
|
+
*,
|
378
|
+
env: bool = False,
|
379
|
+
) -> MConfig:
|
380
|
+
"""Load config."""
|
381
|
+
config = load_yaml(data, MConfig)
|
382
|
+
if env:
|
383
|
+
potential_token = os.environ.get("DISCORD_TOKEN")
|
384
|
+
if potential_token is not None:
|
385
|
+
config.token = potential_token
|
386
|
+
if token is not None:
|
387
|
+
config.token = token
|
388
|
+
return config
|
389
|
+
|
390
|
+
|
391
|
+
def agree(
|
392
|
+
singular: str,
|
393
|
+
plural: str,
|
394
|
+
/,
|
395
|
+
amount: Optional[int],
|
396
|
+
*args: Any,
|
397
|
+
) -> str:
|
398
|
+
"""Agree the text to the text."""
|
399
|
+
if amount is None or amount in (-1, 0, 1):
|
400
|
+
return singular.format(amount, *args)
|
401
|
+
return plural.format(amount, *args)
|
easterobot/info.py
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
"""Module holding metadata."""
|
2
|
+
|
3
|
+
from importlib.metadata import Distribution
|
4
|
+
|
5
|
+
_DISTRIBUTION = Distribution.from_name(
|
6
|
+
"easterobot",
|
7
|
+
)
|
8
|
+
_METADATA = _DISTRIBUTION.metadata
|
9
|
+
|
10
|
+
if "Author" in _METADATA:
|
11
|
+
__author__ = str(_METADATA["Author"])
|
12
|
+
__email__ = str(_METADATA["Author-email"])
|
13
|
+
else:
|
14
|
+
__author__, __email__ = _METADATA["Author-email"][:-1].split(" <", 1)
|
15
|
+
__version__ = _METADATA["Version"]
|
16
|
+
__summary__ = _METADATA["Summary"]
|
17
|
+
__copyright__ = f"{__author__} <{__email__}>"
|
18
|
+
__issues__ = "https://github.com/Dashstrom/easterobot/issues"
|
easterobot/logger.py
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
"""Module for logging stuff."""
|
2
|
+
|
3
|
+
import pathlib
|
4
|
+
from logging.handlers import RotatingFileHandler
|
5
|
+
from pathlib import Path
|
6
|
+
from typing import Any, Union
|
7
|
+
|
8
|
+
|
9
|
+
class AutoDirRotatingFileHandler(RotatingFileHandler):
|
10
|
+
def __init__(
|
11
|
+
self, filename: Union[str, pathlib.Path], *args: Any, **kwargs: Any
|
12
|
+
) -> None:
|
13
|
+
"""Show logger."""
|
14
|
+
path = Path(filename)
|
15
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
16
|
+
super().__init__(filename, *args, **kwargs)
|
easterobot/models.py
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
"""Module for models."""
|
2
|
+
|
3
|
+
from sqlalchemy import BigInteger, Integer
|
4
|
+
from sqlalchemy.orm import (
|
5
|
+
DeclarativeBase,
|
6
|
+
Mapped,
|
7
|
+
mapped_column,
|
8
|
+
)
|
9
|
+
|
10
|
+
DISCORD_URL = "https://discord.com/channels"
|
11
|
+
|
12
|
+
|
13
|
+
class Base(DeclarativeBase):
|
14
|
+
pass
|
15
|
+
|
16
|
+
|
17
|
+
class Egg(Base):
|
18
|
+
__tablename__ = "egg"
|
19
|
+
id: Mapped[int] = mapped_column(
|
20
|
+
BigInteger().with_variant(Integer, "sqlite"),
|
21
|
+
primary_key=True,
|
22
|
+
autoincrement=True,
|
23
|
+
)
|
24
|
+
guild_id: Mapped[int] = mapped_column(
|
25
|
+
BigInteger, nullable=False, index=True
|
26
|
+
)
|
27
|
+
channel_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
28
|
+
user_id: Mapped[int] = mapped_column(
|
29
|
+
BigInteger, nullable=False, index=True
|
30
|
+
)
|
31
|
+
emoji_id: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
32
|
+
|
33
|
+
@property
|
34
|
+
def jump_url(self) -> str:
|
35
|
+
"""Url to jump to the egg."""
|
36
|
+
guild_id = self.guild_id or "@me"
|
37
|
+
return f"{DISCORD_URL}/{guild_id}/{self.channel_id}/{self.id}"
|
38
|
+
|
39
|
+
|
40
|
+
class Hunt(Base):
|
41
|
+
__tablename__ = "hunt"
|
42
|
+
channel_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
43
|
+
guild_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
44
|
+
next_egg: Mapped[float] = mapped_column(default=0.0)
|
45
|
+
|
46
|
+
@property
|
47
|
+
def jump_url(self) -> str:
|
48
|
+
"""Url to jump to an hunt."""
|
49
|
+
guild_id = self.guild_id or "@me"
|
50
|
+
return f"{DISCORD_URL}/{guild_id}/{self.channel_id}"
|
51
|
+
|
52
|
+
|
53
|
+
class Cooldown(Base):
|
54
|
+
__tablename__ = "cooldown"
|
55
|
+
user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
56
|
+
guild_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
57
|
+
command: Mapped[str] = mapped_column(primary_key=True)
|
58
|
+
timestamp: Mapped[float] = mapped_column(default=0.0)
|
easterobot/py.typed
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
# https://peps.python.org/pep-0561/#packaging-type-information
|