forked from clan/clan-core
matrix-bot: Init working sending and receiving
matrix-bot: Code cleanup matrix-bot: Code cleanup# matrix-bot: Code cleanup# matrix-bot: Ping on review neede Add .gitignore Working user ping
This commit is contained in:
parent
ef9b733631
commit
c26b7e0a0a
1
pkgs/matrix-bot/.gitignore
vendored
Normal file
1
pkgs/matrix-bot/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
*.json
|
@ -10,3 +10,4 @@ from matrix_bot import main # NOQA
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
{ python3, setuptools, mautrix, ... }:
|
{
|
||||||
|
python3,
|
||||||
|
setuptools,
|
||||||
|
matrix-nio,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
let
|
let
|
||||||
|
|
||||||
pythonDependencies = [
|
pythonDependencies = [ matrix-nio ];
|
||||||
mautrix
|
|
||||||
];
|
|
||||||
|
|
||||||
runtimeDependencies = [ ];
|
runtimeDependencies = [ ];
|
||||||
|
|
||||||
testDependencies =
|
testDependencies = pythonDependencies ++ runtimeDependencies ++ [ ];
|
||||||
runtimeDependencies ++ [
|
|
||||||
];
|
|
||||||
|
|
||||||
in
|
in
|
||||||
python3.pkgs.buildPythonApplication {
|
python3.pkgs.buildPythonApplication {
|
||||||
@ -19,9 +19,7 @@ python3.pkgs.buildPythonApplication {
|
|||||||
src = ./.;
|
src = ./.;
|
||||||
format = "pyproject";
|
format = "pyproject";
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [ setuptools ];
|
||||||
setuptools
|
|
||||||
];
|
|
||||||
|
|
||||||
propagatedBuildInputs = pythonDependencies;
|
propagatedBuildInputs = pythonDependencies;
|
||||||
|
|
||||||
|
@ -1,4 +1,120 @@
|
|||||||
from .main import main
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from matrix_bot.custom_logger import setup_logging
|
||||||
|
from matrix_bot.gitea import GiteaData
|
||||||
|
from matrix_bot.main import bot_main
|
||||||
|
from matrix_bot.matrix import MatrixData
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
curr_dir = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
def create_parser(prog: str | None = None) -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog=prog,
|
||||||
|
description="A gitea bot for matrix",
|
||||||
|
formatter_class=argparse.RawTextHelpFormatter,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug",
|
||||||
|
help="Enable debug logging",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--server",
|
||||||
|
help="The matrix server to connect to",
|
||||||
|
default="https://matrix.clan.lol",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--user",
|
||||||
|
help="The matrix user to connect as",
|
||||||
|
default="@clan-bot:clan.lol",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--avatar",
|
||||||
|
help="The path to the image to use as the avatar",
|
||||||
|
default=curr_dir / "avatar.png",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--repo-owner",
|
||||||
|
help="The owner of gitea the repository",
|
||||||
|
default="clan",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--repo-name",
|
||||||
|
help="The name of the repository",
|
||||||
|
default="clan-core",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--matrix-room",
|
||||||
|
help="The matrix room to join",
|
||||||
|
default="#bot-test:gchq.icu",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--gitea-url",
|
||||||
|
help="The gitea url to connect to",
|
||||||
|
default="https://git.clan.lol",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--trigger-labels",
|
||||||
|
help="The labels that trigger the bot",
|
||||||
|
default=["needs-review"],
|
||||||
|
nargs="+",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = create_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.debug:
|
||||||
|
setup_logging(logging.DEBUG, root_log_name=__name__.split(".")[0])
|
||||||
|
log.debug("Debug log activated")
|
||||||
|
else:
|
||||||
|
setup_logging(logging.INFO, root_log_name=__name__.split(".")[0])
|
||||||
|
|
||||||
|
password = os.getenv("MATRIX_PASSWORD")
|
||||||
|
if not password:
|
||||||
|
log.error("No password provided set the MATRIX_PASSWORD environment variable")
|
||||||
|
|
||||||
|
matrix = MatrixData(
|
||||||
|
server=args.server,
|
||||||
|
user=args.user,
|
||||||
|
avatar=args.avatar,
|
||||||
|
room=args.matrix_room,
|
||||||
|
password=password,
|
||||||
|
)
|
||||||
|
|
||||||
|
gitea = GiteaData(
|
||||||
|
url=args.gitea_url,
|
||||||
|
owner=args.repo_owner,
|
||||||
|
repo=args.repo_name,
|
||||||
|
trigger_labels=args.trigger_labels,
|
||||||
|
access_token=os.getenv("GITEA_ACCESS_TOKEN"),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(bot_main(matrix, gitea))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("User Interrupt", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
@ -1,4 +1,4 @@
|
|||||||
from .main import main
|
from . import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
BIN
pkgs/matrix-bot/matrix_bot/avatar.png
Normal file
BIN
pkgs/matrix-bot/matrix_bot/avatar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 105 KiB |
81
pkgs/matrix-bot/matrix_bot/bot.py
Normal file
81
pkgs/matrix-bot/matrix_bot/bot.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
import aiohttp
|
||||||
|
from nio import (
|
||||||
|
AsyncClient,
|
||||||
|
JoinResponse,
|
||||||
|
JoinedMembersResponse,
|
||||||
|
MatrixRoom,
|
||||||
|
RoomMessageText,
|
||||||
|
)
|
||||||
|
|
||||||
|
from matrix_bot.gitea import (
|
||||||
|
GiteaData,
|
||||||
|
PullState,
|
||||||
|
fetch_pull_requests,
|
||||||
|
fetch_repo_labels,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .locked_open import read_locked_file, write_locked_file
|
||||||
|
from .matrix import MatrixData, send_message
|
||||||
|
|
||||||
|
|
||||||
|
async def message_callback(room: MatrixRoom, event: RoomMessageText) -> None:
|
||||||
|
print(
|
||||||
|
f"Message received in room {room.display_name}\n"
|
||||||
|
f"{room.user_name(event.sender)} | {event.body}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def bot_run(
|
||||||
|
client: AsyncClient,
|
||||||
|
http: aiohttp.ClientSession,
|
||||||
|
matrix: MatrixData,
|
||||||
|
gitea: GiteaData,
|
||||||
|
) -> None:
|
||||||
|
# If you made a new room and haven't joined as that user, you can use
|
||||||
|
room: JoinResponse = await client.join(matrix.room)
|
||||||
|
|
||||||
|
users: JoinedMembersResponse = await client.joined_members(room.room_id)
|
||||||
|
|
||||||
|
if not users.transport_response.ok:
|
||||||
|
raise Exception(f"Failed to get users {users}")
|
||||||
|
|
||||||
|
for user in users.members:
|
||||||
|
print(f"User: {user.user_id} {user.display_name}")
|
||||||
|
|
||||||
|
labels = await fetch_repo_labels(gitea, http)
|
||||||
|
label_ids: list[int] = []
|
||||||
|
for label in labels:
|
||||||
|
if label["name"] in gitea.trigger_labels:
|
||||||
|
label_ids.append(label["id"])
|
||||||
|
|
||||||
|
tstart = time.time()
|
||||||
|
pulls = await fetch_pull_requests(gitea, http, limit=50, state=PullState.ALL)
|
||||||
|
|
||||||
|
last_updated_path = Path("last_updated.json")
|
||||||
|
last_updated = read_locked_file(last_updated_path)
|
||||||
|
|
||||||
|
for pull in pulls:
|
||||||
|
if pull["requested_reviewers"] and pull["mergeable"]:
|
||||||
|
if last_updated == {}:
|
||||||
|
last_updated = pull
|
||||||
|
elif pull["updated_at"] < last_updated["updated_at"]:
|
||||||
|
last_updated = pull
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
log.info(f"Pull request {pull['title']} needs review")
|
||||||
|
message = f"Pull request {pull['title']} needs review\n{pull['html_url']}"
|
||||||
|
await send_message(client, room, message, user_ids=["@qubasa:gchq.icu"])
|
||||||
|
|
||||||
|
write_locked_file(last_updated_path, last_updated)
|
||||||
|
|
||||||
|
tend = time.time()
|
||||||
|
tdiff = round(tend - tstart)
|
||||||
|
log.debug(f"Time taken: {tdiff}s")
|
||||||
|
|
||||||
|
# await client.sync_forever(timeout=30000) # milliseconds
|
89
pkgs/matrix-bot/matrix_bot/gitea.py
Normal file
89
pkgs/matrix-bot/matrix_bot/gitea.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GiteaData:
|
||||||
|
url: str
|
||||||
|
owner: str
|
||||||
|
repo: str
|
||||||
|
access_token: str | None = None
|
||||||
|
trigger_labels: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def endpoint_url(gitea: GiteaData, endpoint: str) -> str:
|
||||||
|
return f"{gitea.url}/api/v1/repos/{gitea.owner}/{gitea.repo}/{endpoint}"
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_repo_labels(
|
||||||
|
gitea: GiteaData,
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Fetch labels from a Gitea repository.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of labels in the repository.
|
||||||
|
"""
|
||||||
|
url = endpoint_url(gitea, "labels")
|
||||||
|
headers = {"Accept": "application/vnd.github.v3+json"}
|
||||||
|
if gitea.access_token:
|
||||||
|
headers["Authorization"] = f"token {gitea.access_token}"
|
||||||
|
|
||||||
|
async with session.get(url, headers=headers) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
labels = await response.json()
|
||||||
|
return labels
|
||||||
|
else:
|
||||||
|
# You may want to handle different statuses differently
|
||||||
|
raise Exception(
|
||||||
|
f"Failed to fetch labels: {response.status}, {await response.text()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PullState(Enum):
|
||||||
|
OPEN = "open"
|
||||||
|
CLOSED = "closed"
|
||||||
|
ALL = "all"
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_pull_requests(
|
||||||
|
gitea: GiteaData,
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
state: PullState,
|
||||||
|
label_ids: list[int] = [],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Fetch pull requests from a Gitea repository.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of pull requests.
|
||||||
|
"""
|
||||||
|
# You can use the same pattern as fetch_repo_labels
|
||||||
|
url = endpoint_url(gitea, "pulls")
|
||||||
|
params = {
|
||||||
|
"state": state.value,
|
||||||
|
"sort": "recentupdate",
|
||||||
|
"limit": limit,
|
||||||
|
"labels": label_ids,
|
||||||
|
}
|
||||||
|
headers = {"accept": "application/json"}
|
||||||
|
|
||||||
|
async with session.get(url, params=params, headers=headers) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
labels = await response.json()
|
||||||
|
return labels
|
||||||
|
else:
|
||||||
|
# You may want to handle different statuses differently
|
||||||
|
raise Exception(
|
||||||
|
f"Failed to fetch labels: {response.status}, {await response.text()}"
|
||||||
|
)
|
31
pkgs/matrix-bot/matrix_bot/locked_open.py
Normal file
31
pkgs/matrix-bot/matrix_bot/locked_open.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
from collections.abc import Generator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def locked_open(filename: str | Path, mode: str = "r") -> Generator:
|
||||||
|
"""
|
||||||
|
This is a context manager that provides an advisory write lock on the file specified by `filename` when entering the context, and releases the lock when leaving the context. The lock is acquired using the `fcntl` module's `LOCK_EX` flag, which applies an exclusive write lock to the file.
|
||||||
|
"""
|
||||||
|
with open(filename, mode) as fd:
|
||||||
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||||
|
yield fd
|
||||||
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
def write_locked_file(path: Path, data: dict[str, Any]) -> None:
|
||||||
|
with locked_open(path, "w+") as f:
|
||||||
|
f.write(json.dumps(data, indent=4))
|
||||||
|
|
||||||
|
|
||||||
|
def read_locked_file(path: Path) -> dict[str, Any]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
with locked_open(path, "r") as f:
|
||||||
|
content: str = f.read()
|
||||||
|
parsed: list[dict] = json.loads(content)
|
||||||
|
return parsed
|
@ -1,36 +1,47 @@
|
|||||||
import argparse
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
from pathlib import Path
|
||||||
|
|
||||||
from matrix_bot.custom_logger import setup_logging
|
import aiohttp
|
||||||
|
|
||||||
|
from matrix_bot.gitea import GiteaData
|
||||||
|
from matrix_bot.matrix import MatrixData
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
curr_dir = Path(__file__).parent
|
||||||
|
|
||||||
def create_parser(prog: str | None = None) -> argparse.ArgumentParser:
|
from nio import (
|
||||||
parser = argparse.ArgumentParser(
|
AsyncClient,
|
||||||
prog=prog,
|
ProfileGetAvatarResponse,
|
||||||
description="A gitea bot for matrix",
|
RoomMessageText,
|
||||||
formatter_class=argparse.RawTextHelpFormatter,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
from matrix_bot.bot import bot_run, message_callback
|
||||||
"--debug",
|
from matrix_bot.matrix import set_avatar, upload_image
|
||||||
help="Enable debug logging",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
return parser
|
|
||||||
def main():
|
|
||||||
parser = create_parser()
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if len(sys.argv) == 1:
|
async def bot_main(
|
||||||
parser.print_help()
|
matrix: MatrixData,
|
||||||
|
gitea: GiteaData,
|
||||||
|
) -> None:
|
||||||
|
log.info(f"Connecting to {matrix.server} as {matrix.user}")
|
||||||
|
client = AsyncClient(matrix.server, matrix.user)
|
||||||
|
client.add_event_callback(message_callback, RoomMessageText)
|
||||||
|
|
||||||
if args.debug:
|
log.info(await client.login(matrix.password))
|
||||||
setup_logging(logging.DEBUG, root_log_name=__name__.split(".")[0])
|
|
||||||
log.debug("Debug log activated")
|
avatar: ProfileGetAvatarResponse = await client.get_avatar()
|
||||||
|
if not avatar.avatar_url:
|
||||||
|
mxc_url = await upload_image(client, matrix.avatar)
|
||||||
|
log.info(f"Uploaded avatar to {mxc_url}")
|
||||||
|
await set_avatar(client, mxc_url)
|
||||||
else:
|
else:
|
||||||
setup_logging(logging.INFO, root_log_name=__name__.split(".")[0])
|
log.info(f"Bot already has an avatar {avatar.avatar_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
await bot_run(client, session, matrix, gitea)
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(e)
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
80
pkgs/matrix-bot/matrix_bot/matrix.py
Normal file
80
pkgs/matrix-bot/matrix_bot/matrix.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from nio import (
|
||||||
|
AsyncClient,
|
||||||
|
JoinResponse,
|
||||||
|
ProfileSetAvatarResponse,
|
||||||
|
RoomSendResponse,
|
||||||
|
UploadResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_image(client: AsyncClient, image_path: str) -> str:
|
||||||
|
with open(image_path, "rb") as image_file:
|
||||||
|
response: UploadResponse
|
||||||
|
response, _ = await client.upload(image_file, content_type="image/png")
|
||||||
|
if not response.transport_response.ok:
|
||||||
|
raise Exception(f"Failed to upload image {response}")
|
||||||
|
return response.content_uri # This is the MXC URL
|
||||||
|
|
||||||
|
|
||||||
|
async def set_avatar(client: AsyncClient, mxc_url: str) -> None:
|
||||||
|
response: ProfileSetAvatarResponse
|
||||||
|
response = await client.set_avatar(mxc_url)
|
||||||
|
if not response.transport_response.ok:
|
||||||
|
raise Exception(f"Failed to set avatar {response}")
|
||||||
|
|
||||||
|
|
||||||
|
from nio import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
client: AsyncClient,
|
||||||
|
room: JoinResponse,
|
||||||
|
message: str,
|
||||||
|
user_ids: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Send a message in a Matrix room, optionally mentioning users.
|
||||||
|
"""
|
||||||
|
# If user_ids are provided, format the message to mention them
|
||||||
|
if user_ids:
|
||||||
|
mention_list = ", ".join(
|
||||||
|
[
|
||||||
|
f"<a href='https://matrix.to/#/{user_id}'>{user_id}</a>"
|
||||||
|
for user_id in user_ids
|
||||||
|
]
|
||||||
|
)
|
||||||
|
body = f"{mention_list}: {message}"
|
||||||
|
formatted_body = f"{mention_list}: {message}"
|
||||||
|
else:
|
||||||
|
body = message
|
||||||
|
formatted_body = message
|
||||||
|
|
||||||
|
content = {
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"format": "org.matrix.custom.html",
|
||||||
|
"body": body,
|
||||||
|
"formatted_body": formatted_body,
|
||||||
|
}
|
||||||
|
|
||||||
|
res: RoomSendResponse = await client.room_send(
|
||||||
|
room_id=room.room_id, message_type="m.room.message", content=content
|
||||||
|
)
|
||||||
|
|
||||||
|
if not res.transport_response.ok:
|
||||||
|
raise Exception(f"Failed to send message {res}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatrixData:
|
||||||
|
server: str
|
||||||
|
user: str
|
||||||
|
avatar: Path
|
||||||
|
password: str
|
||||||
|
room: str
|
@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
name = "matrix-bot"
|
name = "matrix-bot"
|
||||||
description = "matrix bot for release messages from git commits"
|
description = "matrix bot for release messages from git commits"
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
scripts = { clan = "matrix_bot:main" }
|
scripts = { mbot = "matrix_bot:main" }
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
@ -16,10 +16,7 @@ let
|
|||||||
]);
|
]);
|
||||||
in
|
in
|
||||||
mkShell {
|
mkShell {
|
||||||
buildInputs = [
|
buildInputs = [ ruff ] ++ devshellTestDeps;
|
||||||
|
|
||||||
ruff
|
|
||||||
] ++ devshellTestDeps;
|
|
||||||
|
|
||||||
PYTHONBREAKPOINT = "ipdb.set_trace";
|
PYTHONBREAKPOINT = "ipdb.set_trace";
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user