Compare commits
4 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
091d57dba7 | ||
![]() |
69a897cfec | ||
![]() |
33db2c01b4 | ||
![]() |
26addb2f7b |
@ -10,8 +10,24 @@ from cista.util import pwgen
|
|||||||
|
|
||||||
del app, server80.app # Only import needed, for Sanic multiprocessing
|
del app, server80.app # Only import needed, for Sanic multiprocessing
|
||||||
|
|
||||||
doc = f"""Cista {cista.__version__} - A file storage for the web.
|
|
||||||
|
|
||||||
|
def create_banner():
|
||||||
|
"""Create a framed banner with the Cista version."""
|
||||||
|
title = f"Cista {cista.__version__}"
|
||||||
|
subtitle = "A file storage for the web"
|
||||||
|
width = max(len(title), len(subtitle)) + 4
|
||||||
|
|
||||||
|
return f"""\
|
||||||
|
╭{"─" * width}╮
|
||||||
|
│{title:^{width}}│
|
||||||
|
│{subtitle:^{width}}│
|
||||||
|
╰{"─" * width}╯
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
banner = create_banner()
|
||||||
|
|
||||||
|
doc = """\
|
||||||
Usage:
|
Usage:
|
||||||
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
||||||
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
||||||
@ -35,6 +51,14 @@ User management:
|
|||||||
--password Reset password
|
--password Reset password
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
first_time_help = """\
|
||||||
|
No config file found! Get started with:
|
||||||
|
cista --user yourname --privileged # If you want user accounts
|
||||||
|
cista -l :8000 /path/to/files # Run the server on localhost:8000
|
||||||
|
|
||||||
|
See cista --help for other options!
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# Dev mode doesn't catch exceptions
|
# Dev mode doesn't catch exceptions
|
||||||
@ -44,11 +68,19 @@ def main():
|
|||||||
try:
|
try:
|
||||||
return _main()
|
return _main()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Error:", e)
|
sys.stderr.write(f"Error: {e}\n")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def _main():
|
def _main():
|
||||||
|
# The banner printing differs by mode, and needs to be done before docopt() printing its messages
|
||||||
|
if any(arg in sys.argv for arg in ("--help", "-h")):
|
||||||
|
sys.stdout.write(banner)
|
||||||
|
elif "--version" in sys.argv:
|
||||||
|
sys.stdout.write(f"cista {cista.__version__}\n")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
sys.stderr.write(banner)
|
||||||
args = docopt(doc)
|
args = docopt(doc)
|
||||||
if args["--user"]:
|
if args["--user"]:
|
||||||
return _user(args)
|
return _user(args)
|
||||||
@ -62,18 +94,11 @@ def _main():
|
|||||||
path = None
|
path = None
|
||||||
_confdir(args)
|
_confdir(args)
|
||||||
exists = config.conffile.exists()
|
exists = config.conffile.exists()
|
||||||
print(config.conffile, exists)
|
|
||||||
import_droppy = args["--import-droppy"]
|
import_droppy = args["--import-droppy"]
|
||||||
necessary_opts = exists or import_droppy or path
|
necessary_opts = exists or import_droppy or path
|
||||||
if not necessary_opts:
|
if not necessary_opts:
|
||||||
# Maybe run without arguments
|
# Maybe run without arguments
|
||||||
print(doc)
|
sys.stderr.write(first_time_help)
|
||||||
print(
|
|
||||||
"No config file found! Get started with one of:\n"
|
|
||||||
" cista --user yourname --privileged\n"
|
|
||||||
" cista --import-droppy\n"
|
|
||||||
" cista -l :8000 /path/to/files\n"
|
|
||||||
)
|
|
||||||
return 1
|
return 1
|
||||||
settings = {}
|
settings = {}
|
||||||
if import_droppy:
|
if import_droppy:
|
||||||
@ -94,7 +119,7 @@ def _main():
|
|||||||
# We have no users, so make it public
|
# We have no users, so make it public
|
||||||
settings["public"] = True
|
settings["public"] = True
|
||||||
operation = config.update_config(settings)
|
operation = config.update_config(settings)
|
||||||
print(f"Config {operation}: {config.conffile}")
|
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
|
||||||
# Prepare to serve
|
# Prepare to serve
|
||||||
unix = None
|
unix = None
|
||||||
url, _ = serve.parse_listen(config.config.listen)
|
url, _ = serve.parse_listen(config.config.listen)
|
||||||
@ -104,7 +129,7 @@ def _main():
|
|||||||
dev = args["--dev"]
|
dev = args["--dev"]
|
||||||
if dev:
|
if dev:
|
||||||
extra += " (dev mode)"
|
extra += " (dev mode)"
|
||||||
print(f"Serving {config.config.path} at {url}{extra}")
|
sys.stderr.write(f"Serving {config.config.path} at {url}{extra}\n")
|
||||||
# Run the server
|
# Run the server
|
||||||
serve.run(dev=dev)
|
serve.run(dev=dev)
|
||||||
return 0
|
return 0
|
||||||
@ -137,7 +162,7 @@ def _user(args):
|
|||||||
"public": False,
|
"public": False,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
print(f"Config {operation}: {config.conffile}\n")
|
sys.stderr.write(f"Config {operation}: {config.conffile}\n\n")
|
||||||
|
|
||||||
name = args["--user"]
|
name = args["--user"]
|
||||||
if not name or not name.isidentifier():
|
if not name or not name.isidentifier():
|
||||||
@ -155,12 +180,12 @@ def _user(args):
|
|||||||
changes["password"] = pw = pwgen.generate()
|
changes["password"] = pw = pwgen.generate()
|
||||||
info += f"\n Password: {pw}\n"
|
info += f"\n Password: {pw}\n"
|
||||||
res = config.update_user(name, changes)
|
res = config.update_user(name, changes)
|
||||||
print(info)
|
sys.stderr.write(f"{info}\n")
|
||||||
if res == "read":
|
if res == "read":
|
||||||
print(" No changes")
|
sys.stderr.write(" No changes\n")
|
||||||
|
|
||||||
if operation == "created":
|
if operation == "created":
|
||||||
print(
|
sys.stderr.write(
|
||||||
"Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n"
|
"Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -119,8 +119,12 @@ async def watch(req, ws):
|
|||||||
# Send updates
|
# Send updates
|
||||||
while True:
|
while True:
|
||||||
await ws.send(await q.get())
|
await ws.send(await q.get())
|
||||||
|
except RuntimeError as e:
|
||||||
|
if str(e) == "cannot schedule new futures after shutdown":
|
||||||
|
return # Server shutting down, drop the WebSocket
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
del watching.pubsub[uuid]
|
watching.pubsub.pop(uuid, None) # Remove whether it got added yet or not
|
||||||
|
|
||||||
|
|
||||||
def subscribe(uuid, ws):
|
def subscribe(uuid, ws):
|
||||||
|
@ -43,14 +43,16 @@ async def main_start(app, loop):
|
|||||||
app.ctx.threadexec = ThreadPoolExecutor(
|
app.ctx.threadexec = ThreadPoolExecutor(
|
||||||
max_workers=workers, thread_name_prefix="cista-ioworker"
|
max_workers=workers, thread_name_prefix="cista-ioworker"
|
||||||
)
|
)
|
||||||
await watching.start(app, loop)
|
watching.start(app, loop)
|
||||||
|
|
||||||
|
|
||||||
@app.after_server_stop
|
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
|
||||||
|
@app.before_server_stop
|
||||||
async def main_stop(app, loop):
|
async def main_stop(app, loop):
|
||||||
quit.set()
|
quit.set()
|
||||||
await watching.stop(app, loop)
|
watching.stop(app)
|
||||||
app.ctx.threadexec.shutdown()
|
app.ctx.threadexec.shutdown()
|
||||||
|
logger.debug("Cista worker threads all finished")
|
||||||
|
|
||||||
|
|
||||||
@app.on_request
|
@app.on_request
|
||||||
|
@ -7,9 +7,11 @@ from contextlib import suppress
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from pathlib import Path, PurePath
|
from pathlib import Path, PurePath
|
||||||
from time import time
|
from time import sleep, time
|
||||||
|
from typing import Callable, Concatenate, Literal, ParamSpec
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
import msgspec.toml
|
||||||
|
|
||||||
|
|
||||||
class Config(msgspec.Struct):
|
class Config(msgspec.Struct):
|
||||||
@ -22,6 +24,13 @@ class Config(msgspec.Struct):
|
|||||||
links: dict[str, Link] = {}
|
links: dict[str, Link] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# Typing: arguments for config-modifying functions
|
||||||
|
P = ParamSpec("P")
|
||||||
|
ResultStr = Literal["modified", "created", "read"]
|
||||||
|
RawModifyFunc = Callable[Concatenate[Config, P], Config]
|
||||||
|
ModifyPublic = Callable[P, ResultStr]
|
||||||
|
|
||||||
|
|
||||||
class User(msgspec.Struct, omit_defaults=True):
|
class User(msgspec.Struct, omit_defaults=True):
|
||||||
privileged: bool = False
|
privileged: bool = False
|
||||||
hash: str = ""
|
hash: str = ""
|
||||||
@ -34,11 +43,13 @@ class Link(msgspec.Struct, omit_defaults=True):
|
|||||||
expires: int = 0
|
expires: int = 0
|
||||||
|
|
||||||
|
|
||||||
config = None
|
# Global variables - initialized during application startup
|
||||||
conffile = None
|
config: Config
|
||||||
|
conffile: Path
|
||||||
|
|
||||||
|
|
||||||
def init_confdir():
|
def init_confdir() -> None:
|
||||||
|
global conffile
|
||||||
if p := os.environ.get("CISTA_HOME"):
|
if p := os.environ.get("CISTA_HOME"):
|
||||||
home = Path(p)
|
home = Path(p)
|
||||||
else:
|
else:
|
||||||
@ -49,8 +60,6 @@ def init_confdir():
|
|||||||
if not home.is_dir():
|
if not home.is_dir():
|
||||||
home.mkdir(parents=True, exist_ok=True)
|
home.mkdir(parents=True, exist_ok=True)
|
||||||
home.chmod(0o700)
|
home.chmod(0o700)
|
||||||
|
|
||||||
global conffile
|
|
||||||
conffile = home / "db.toml"
|
conffile = home / "db.toml"
|
||||||
|
|
||||||
|
|
||||||
@ -77,10 +86,10 @@ def dec_hook(typ, obj):
|
|||||||
raise TypeError
|
raise TypeError
|
||||||
|
|
||||||
|
|
||||||
def config_update(modify):
|
def config_update(
|
||||||
|
modify: RawModifyFunc,
|
||||||
|
) -> ResultStr | Literal["collision"]:
|
||||||
global config
|
global config
|
||||||
if conffile is None:
|
|
||||||
init_confdir()
|
|
||||||
tmpname = conffile.with_suffix(".tmp")
|
tmpname = conffile.with_suffix(".tmp")
|
||||||
try:
|
try:
|
||||||
f = tmpname.open("xb")
|
f = tmpname.open("xb")
|
||||||
@ -95,7 +104,7 @@ def config_update(modify):
|
|||||||
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
|
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
old = b""
|
old = b""
|
||||||
c = None
|
c = Config(path=Path(), listen="", secret=secrets.token_hex(12))
|
||||||
c = modify(c)
|
c = modify(c)
|
||||||
new = msgspec.toml.encode(c, enc_hook=enc_hook)
|
new = msgspec.toml.encode(c, enc_hook=enc_hook)
|
||||||
if old == new:
|
if old == new:
|
||||||
@ -118,17 +127,23 @@ def config_update(modify):
|
|||||||
return "modified" if old else "created"
|
return "modified" if old else "created"
|
||||||
|
|
||||||
|
|
||||||
def modifies_config(modify):
|
def modifies_config(
|
||||||
"""Decorator for functions that modify the config file"""
|
modify: Callable[Concatenate[Config, P], Config],
|
||||||
|
) -> Callable[P, ResultStr]:
|
||||||
|
"""Decorator for functions that modify the config file
|
||||||
|
|
||||||
|
The decorated function takes as first arg Config and returns it modified.
|
||||||
|
The wrapper handles atomic modification and returns a string indicating the result.
|
||||||
|
"""
|
||||||
|
|
||||||
@wraps(modify)
|
@wraps(modify)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> ResultStr:
|
||||||
def m(c):
|
def m(c: Config) -> Config:
|
||||||
return modify(c, *args, **kwargs)
|
return modify(c, *args, **kwargs)
|
||||||
|
|
||||||
# Retry modification in case of write collision
|
# Retry modification in case of write collision
|
||||||
while (c := config_update(m)) == "collision":
|
while (c := config_update(m)) == "collision":
|
||||||
time.sleep(0.01)
|
sleep(0.01)
|
||||||
return c
|
return c
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
@ -136,7 +151,6 @@ def modifies_config(modify):
|
|||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
global config
|
global config
|
||||||
if conffile is None:
|
|
||||||
init_confdir()
|
init_confdir()
|
||||||
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
|
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
@ -145,7 +159,7 @@ def load_config():
|
|||||||
def update_config(conf: Config, changes: dict) -> Config:
|
def update_config(conf: Config, changes: dict) -> Config:
|
||||||
"""Create/update the config with new values, respecting changes done by others."""
|
"""Create/update the config with new values, respecting changes done by others."""
|
||||||
# Encode into dict, update values with new, convert to Config
|
# Encode into dict, update values with new, convert to Config
|
||||||
settings = {} if conf is None else msgspec.to_builtins(conf, enc_hook=enc_hook)
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
settings.update(changes)
|
settings.update(changes)
|
||||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
@ -155,8 +169,13 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
|||||||
"""Create/update a user with new values, respecting changes done by others."""
|
"""Create/update a user with new values, respecting changes done by others."""
|
||||||
# Encode into dict, update values with new, convert to Config
|
# Encode into dict, update values with new, convert to Config
|
||||||
try:
|
try:
|
||||||
u = conf.users[name].__copy__()
|
# Copy user by converting to dict and back
|
||||||
except (KeyError, AttributeError):
|
u = msgspec.convert(
|
||||||
|
msgspec.to_builtins(conf.users[name], enc_hook=enc_hook),
|
||||||
|
User,
|
||||||
|
dec_hook=dec_hook,
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
u = User()
|
u = User()
|
||||||
if "password" in changes:
|
if "password" in changes:
|
||||||
from . import auth
|
from . import auth
|
||||||
@ -165,7 +184,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
|||||||
del changes["password"]
|
del changes["password"]
|
||||||
udict = msgspec.to_builtins(u, enc_hook=enc_hook)
|
udict = msgspec.to_builtins(u, enc_hook=enc_hook)
|
||||||
udict.update(changes)
|
udict.update(changes)
|
||||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook) if conf else {"users": {}}
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook)
|
settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook)
|
||||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
@ -173,6 +192,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
|||||||
@modifies_config
|
@modifies_config
|
||||||
def del_user(conf: Config, name: str) -> Config:
|
def del_user(conf: Config, name: str) -> Config:
|
||||||
"""Delete named user account."""
|
"""Delete named user account."""
|
||||||
ret = conf.__copy__()
|
# Create a copy by converting to dict and back
|
||||||
ret.users.pop(name)
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
return ret
|
settings["users"].pop(name)
|
||||||
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
@ -24,6 +24,17 @@ pillow_heif.register_heif_opener()
|
|||||||
|
|
||||||
bp = Blueprint("preview", url_prefix="/preview")
|
bp = Blueprint("preview", url_prefix="/preview")
|
||||||
|
|
||||||
|
# Map EXIF Orientation value to a corresponding PIL transpose
|
||||||
|
EXIF_ORI = {
|
||||||
|
2: Image.Transpose.FLIP_LEFT_RIGHT,
|
||||||
|
3: Image.Transpose.ROTATE_180,
|
||||||
|
4: Image.Transpose.FLIP_TOP_BOTTOM,
|
||||||
|
5: Image.Transpose.TRANSPOSE,
|
||||||
|
6: Image.Transpose.ROTATE_270,
|
||||||
|
7: Image.Transpose.TRANSVERSE,
|
||||||
|
8: Image.Transpose.ROTATE_90,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<path:path>")
|
@bp.get("/<path:path>")
|
||||||
async def preview(req, path):
|
async def preview(req, path):
|
||||||
@ -69,34 +80,35 @@ def dispatch(path, quality, maxsize, maxzoom):
|
|||||||
|
|
||||||
|
|
||||||
def process_image(path, *, maxsize, quality):
|
def process_image(path, *, maxsize, quality):
|
||||||
t_load_start = perf_counter()
|
t_load = perf_counter()
|
||||||
img = Image.open(path)
|
with Image.open(path) as img:
|
||||||
# Force decode to include I/O in load timing
|
# Force decode to include I/O in load timing
|
||||||
img.load()
|
img.load()
|
||||||
t_load_end = perf_counter()
|
t_proc = perf_counter()
|
||||||
# Resize
|
# Resize
|
||||||
orig_w, orig_h = img.size
|
w, h = img.size
|
||||||
t_proc_start = perf_counter()
|
img.thumbnail((min(w, maxsize), min(h, maxsize)))
|
||||||
img.thumbnail((min(orig_w, maxsize), min(orig_h, maxsize)))
|
# Transpose pixels according to EXIF Orientation
|
||||||
t_proc_end = perf_counter()
|
orientation = img.getexif().get(274, 1)
|
||||||
|
if orientation in EXIF_ORI:
|
||||||
|
img = img.transpose(EXIF_ORI[orientation])
|
||||||
# Save as AVIF
|
# Save as AVIF
|
||||||
imgdata = io.BytesIO()
|
imgdata = io.BytesIO()
|
||||||
t_save_start = perf_counter()
|
t_save = perf_counter()
|
||||||
img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1)
|
img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1)
|
||||||
t_save_end = perf_counter()
|
|
||||||
|
|
||||||
|
t_end = perf_counter()
|
||||||
ret = imgdata.getvalue()
|
ret = imgdata.getvalue()
|
||||||
|
|
||||||
load_ms = (t_load_end - t_load_start) * 1000
|
load_ms = (t_proc - t_load) * 1000
|
||||||
proc_ms = (t_proc_end - t_proc_start) * 1000
|
proc_ms = (t_save - t_proc) * 1000
|
||||||
save_ms = (t_save_end - t_save_start) * 1000
|
save_ms = (t_end - t_save) * 1000
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms out=%.1fKB",
|
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms",
|
||||||
path.name,
|
path.name,
|
||||||
load_ms,
|
load_ms,
|
||||||
proc_ms,
|
proc_ms,
|
||||||
save_ms,
|
save_ms,
|
||||||
len(ret) / 1024,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import signal
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sanic import Sanic
|
from sanic import Sanic
|
||||||
@ -12,14 +11,6 @@ def run(*, dev=False):
|
|||||||
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
||||||
from .app import app
|
from .app import app
|
||||||
|
|
||||||
# Set up immediate exit on Ctrl+C for faster termination
|
|
||||||
def signal_handler(signum, frame):
|
|
||||||
print("\nReceived interrupt signal, exiting immediately...")
|
|
||||||
os._exit(0)
|
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
|
||||||
|
|
||||||
url, opts = parse_listen(config.config.listen)
|
url, opts = parse_listen(config.config.listen)
|
||||||
# Silence Sanic's warning about running in production rather than debug
|
# Silence Sanic's warning about running in production rather than debug
|
||||||
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
||||||
|
@ -440,7 +440,7 @@ def watcher_poll(loop):
|
|||||||
quit.wait(0.1 + 8 * dur)
|
quit.wait(0.1 + 8 * dur)
|
||||||
|
|
||||||
|
|
||||||
async def start(app, loop):
|
def start(app, loop):
|
||||||
global rootpath
|
global rootpath
|
||||||
config.load_config()
|
config.load_config()
|
||||||
rootpath = config.config.path
|
rootpath = config.config.path
|
||||||
@ -454,6 +454,6 @@ async def start(app, loop):
|
|||||||
app.ctx.watcher.start()
|
app.ctx.watcher.start()
|
||||||
|
|
||||||
|
|
||||||
async def stop(app, loop):
|
def stop(app):
|
||||||
quit.set()
|
quit.set()
|
||||||
app.ctx.watcher.join()
|
app.ctx.watcher.join()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user